Replace wttr.in/Open-Meteo with Yandex Weather API

- Switch from wttr.in + Open-Meteo fallback to Yandex Weather API
- Add YANDEX_WEATHER_API_KEY to .env and config validation
- Enrich weather output: wind gusts, wind direction (Russian)
- Pressure now in mmHg directly from Yandex (no conversion needed)
- Update rate limiter: weather_limiter + open_meteo_limiter → yandex_weather_limiter
- Remove dead code: API_URL_WEATHER, fetch_open_meteo, translate_weather
- Update all tests (271 passed)
This commit is contained in:
deadzilla 2026-07-20 23:31:11 +05:00
parent ee04f67506
commit 48e64c2bc8
10 changed files with 647 additions and 549 deletions

View File

@ -3,11 +3,10 @@ MORNING_TIME=07:00
MORNING_CHANNEL_ID=channel_id
LOG_LEVEL=INFO
CAT_API_KEY=your_cat_api_key_here
YANDEX_WEATHER_API_KEY=your_yandex_weather_api_key_here
CAT_API_RATE=1
CAT_API_BURST=3
WEATHER_API_RATE=1
WEATHER_API_BURST=3
OPEN_METEO_API_RATE=2
OPEN_METEO_API_BURST=5
YANDEX_WEATHER_API_RATE=1
YANDEX_WEATHER_API_BURST=3
HABR_RSS_RATE=1
HABR_RSS_BURST=2

5
bot.py
View File

@ -249,6 +249,11 @@ def _validate_config() -> None:
logger.error("Токен Discord не найден в .env")
sys.exit(1)
yandex_key = os.getenv("YANDEX_WEATHER_API_KEY")
if not yandex_key:
logger.error("YANDEX_WEATHER_API_KEY не найден в .env")
sys.exit(1)
morning_time = os.getenv("MORNING_TIME", "07:00")
try:
hour, minute = map(int, morning_time.split(":"))

View File

@ -1,6 +1,6 @@
import logging
from discord.ext import commands
from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console
from utils.pogoda import fetch_weather, format_weather_data_for_console
logger = logging.getLogger(__name__)
@ -11,7 +11,7 @@ class Pg(commands.Cog):
@commands.command(name="pg")
async def pg(self, ctx: commands.Context) -> None:
"""Прогноз погоды в Магнитогорске"""
data = await fetch_weather(API_URL_WEATHER)
data = await fetch_weather()
if data is None:
logger.warning(
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author

View File

@ -7,18 +7,14 @@ from commands.pg import Pg
class TestPgInit:
"""Тесты инициализации Cog Pg."""
def test_uses_api_url_constant(self) -> None:
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной)."""
from utils.pogoda import API_URL_WEATHER
def test_cog_initialized(self) -> None:
"""Cog инициализируется без ошибок."""
cog = Pg()
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую
assert not hasattr(cog, "api_url")
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
assert cog is not None
class TestPgCommand:
"""Тесты команды !pogoda."""
"""Тесты команды !pg."""
def _make_cog(self):
return Pg()
@ -29,16 +25,25 @@ class TestPgCommand:
return ctx
def _make_weather_data(self, **extra):
"""Создать mock weather data с дефолтными полями."""
"""Создать mock weather data (Яндекс Погода формат).
windspeedKmph м/с от Яндекса.
wind_gust порывы ветра в м/с.
wind_dir направление ветра.
pressure уже в мм рт. ст.
weatherDesc на русском (из yandex_condition_to_russian).
"""
defaults = {
"current_condition": [
{
"temp_C": "22",
"FeelsLikeC": "24",
"weatherDesc": [{"value": "Clear"}],
"humidity": "45",
"windspeedKmph": "18",
"pressure": "1013",
"temp_C": 22,
"FeelsLikeC": 24,
"weatherDesc": [{"value": "Ясно"}],
"humidity": 45,
"windspeedKmph": 5.0, # м/с
"wind_gust": 8.0,
"wind_dir": "n",
"pressure": 735.0,
}
]
}
@ -47,7 +52,7 @@ class TestPgCommand:
@pytest.mark.asyncio
async def test_pg_success(self) -> None:
"""Успешный запрос погоды должен отправить embed с данными."""
"""Успешный запрос погоды должен отправить сообщение с данными."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data()
@ -62,7 +67,7 @@ class TestPgCommand:
assert "Описание: Ясно" in args
assert "Влажность: 45%" in args
assert "Ветер: 5.0 м/с" in args
assert "Давление: 759.8 мм рт. ст." in args
assert "Давление: 735.0 мм рт. ст." in args
@pytest.mark.asyncio
async def test_pg_fetch_returns_none(self) -> None:
@ -130,7 +135,7 @@ class TestPgCommand:
"""windspeedKmph = 0 — wind должен быть 0.0."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="0")
weather = self._make_weather_data(windspeedKmph=0)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
@ -162,22 +167,9 @@ class TestPgCommand:
assert "Влажность: —%" in args
assert "Давление: — мм рт. ст." in args
@pytest.mark.asyncio
async def test_pg_translate_unknown_weather(self) -> None:
"""Неизвестное описание погоды должно возвращать оригинал."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}])
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Описание: UnknownXYZ" in args
@pytest.mark.asyncio
async def test_pg_russian_weather_description(self) -> None:
"""Описание погоды на русском должно корректно переводиться."""
"""Описание погоды на русском должно корректно отображаться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(
@ -191,27 +183,58 @@ class TestPgCommand:
assert "Описание: Переменная облачность" in args
@pytest.mark.asyncio
async def test_pg_negative_pressure(self) -> None:
"""Отрицательное давление должно конвертироваться."""
async def test_pg_with_wind_gust(self) -> None:
"""Порывы ветра должны отображаться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(pressure="-50")
weather = self._make_weather_data(
windspeedKmph=3.0,
wind_gust=10.5,
wind_dir="n",
)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Давление: -37.5 мм рт. ст." in args
assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args
@pytest.mark.asyncio
async def test_pg_high_wind(self) -> None:
"""Большая скорость ветра должна корректно округляться."""
async def test_pg_with_wind_direction(self) -> None:
"""Направление ветра должно переводиться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="123")
weather = self._make_weather_data(
windspeedKmph=5.0,
wind_gust=8.0,
wind_dir="se",
)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Ветер: 34.2 м/с" in args
assert "юго-восточный" in args
@pytest.mark.asyncio
async def test_pg_float_values(self) -> None:
"""Float значения из Яндекс Погоды должны работать."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(
temp_C=17.3,
FeelsLikeC=16.8,
humidity=87.5,
windspeedKmph=2.1, # м/с
wind_gust=5.5,
wind_dir="n",
pressure=750.1,
)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Температура: 17.3°C" in args
assert "Ветер: 2.1 м/с" in args
assert "Давление: 750.1 мм рт. ст." in args

View File

@ -2,124 +2,70 @@ import pytest
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
from unittest.mock import patch, MagicMock
from utils.pogoda import fetch_weather, fetch_open_meteo
from utils.pogoda import fetch_weather
class TestFetchWeather:
"""Тесты функции fetch_weather() — получение погоды с retry-логикой."""
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
@pytest.fixture(autouse=True)
def _mock_api_key(self) -> None:
"""Мокаем _get_api_key для всех тестов в классе."""
with patch("utils.pogoda._get_api_key", return_value="test-key"):
yield
@patch("utils.pogoda._session.get")
async def test_fetch_weather_success(self, mock_get) -> None:
"""Успешный ответ должен вернуть JSON-данные."""
mock_response = MagicMock()
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_weather("https://test.example.com")
assert result == {"current_condition": [{"temp_C": 20}]}
@patch("utils.pogoda._session.get")
async def test_fetch_weather_fallback_on_ssl_error(self, mock_get) -> None:
"""SSLError на первой попытке → fallback на Open-Meteo."""
mock_get.side_effect = [
SSLError("SSL Error"),
MagicMock(json=MagicMock(return_value={"result": "fallback"})),
]
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"}
result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get")
async def test_fetch_weather_fallback_on_connection_error(self, mock_get) -> None:
"""ConnectionError → fallback на Open-Meteo."""
mock_get.side_effect = ConnectionError("No connection")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"}
result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get")
async def test_fetch_weather_fallback_on_timeout(self, mock_get) -> None:
"""Timeout → fallback на Open-Meteo."""
mock_get.side_effect = Timeout("Timed out")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"}
result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get")
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
"""Все попытки не удались → fallback на Open-Meteo."""
mock_get.side_effect = ConnectionError("No connection")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = None
result = await fetch_weather("https://test.example.com")
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_weather_request_exception(self, mock_get) -> None:
"""Общий RequestException → fallback на Open-Meteo."""
mock_get.side_effect = requests.RequestException("Generic error")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"}
result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get")
async def test_fetch_weather_http_error_no_fallback(self, mock_get) -> None:
"""HTTP-ошибка (raise_for_status) не ловится, падает."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_get.return_value = mock_response
with pytest.raises(Exception):
await fetch_weather("https://test.example.com")
class TestFetchOpenMeteo:
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_success(self, mock_get) -> None:
"""Успешный ответ должен вернуть данные в формате current_condition."""
"""Успешный ответ должен вернуть данные в унифицированном формате."""
mock_response = MagicMock()
mock_response.json.return_value = {
"current": {
"temperature": 15,
"apparent_temperature": 12,
"weather_code": 3,
"wind_speed_10m": 5.5,
"relative_humidity_2m": 65,
"pressure_msl": 1013,
"fact": {
"temp": 15,
"feels_like": 12,
"condition": "cloudy",
"wind_speed": 5.5,
"wind_gust": 8.0,
"wind_dir": "n",
"humidity": 65,
"pressure_mm": 735.0,
"pressure_pa": 980,
}
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None
assert "current_condition" in result
assert result["current_condition"][0]["temp_C"] == 15
assert result["current_condition"][0]["FeelsLikeC"] == 12
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Облачно"}]
assert result["current_condition"][0]["humidity"] == 65
assert result["current_condition"][0]["pressure"] == 1013
assert result["current_condition"][0]["pressure"] == 735.0
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
async def test_fetch_weather_custom_coords(self, mock_get) -> None:
"""Кастомные координаты должны быть в URL."""
mock_response = MagicMock()
mock_response.json.return_value = {
"current": {
"temperature": 25,
"apparent_temperature": 22,
"weather_code": 0,
"wind_speed_10m": 3,
"relative_humidity_2m": 50,
"pressure_msl": 1020,
"fact": {
"temp": 25,
"feels_like": 22,
"condition": "clear",
"wind_speed": 3,
"wind_gust": 5,
"wind_dir": "s",
"humidity": 50,
"pressure_mm": 760.0,
"pressure_pa": 1013,
}
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
result = await fetch_weather(lat=55.7558, lon=37.6173)
assert result is not None
mock_get.assert_called_once()
call_url = mock_get.call_args[0][0]
@ -127,117 +73,215 @@ class TestFetchOpenMeteo:
assert "37.6173" in call_url
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_missing_weather_code(self, mock_get) -> None:
"""Отсутствующий weather_code'Неизвестно'."""
async def test_fetch_weather_missing_condition(self, mock_get) -> None:
"""Отсутствующий condition'Неизвестно'."""
mock_response = MagicMock()
mock_response.json.return_value = {"current": {"temperature": 10}}
mock_response.json.return_value = {"fact": {"temp": 10}}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None
assert result["current_condition"][0]["weatherDesc"] == [
{"value": "Неизвестно"}
]
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
"""SSLError → вернуть None."""
mock_get.side_effect = SSLError("SSL Error")
with patch("utils.pogoda.fetch_open_meteo"):
# Внутренний fallback тоже падает, проверяем что возвращается None
pass
result = await fetch_open_meteo()
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_connection_error(self, mock_get) -> None:
"""ConnectionError → вернуть None."""
mock_get.side_effect = ConnectionError("No connection")
result = await fetch_open_meteo()
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_timeout(self, mock_get) -> None:
"""Timeout → вернуть None."""
mock_get.side_effect = Timeout("Timed out")
result = await fetch_open_meteo()
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_request_exception(self, mock_get) -> None:
"""Общий RequestException → вернуть None."""
mock_get.side_effect = requests.RequestException("Error")
result = await fetch_open_meteo()
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_json_parse_error(self, mock_get) -> None:
"""Ошибка парсинга JSON → вернуть None."""
mock_response = MagicMock()
mock_response.json.side_effect = requests.RequestException("JSON Error")
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_open_meteo()
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
"""Retry: первая попытка падает, вторая успешна."""
async def test_fetch_weather_ssl_error_retry(self, mock_get) -> None:
"""SSLError на первой попытке → retry → успех."""
success_response = MagicMock()
success_response.json.return_value = {
"current": {
"temperature": 20,
"apparent_temperature": 18,
"weather_code": 1,
"wind_speed_10m": 4,
"relative_humidity_2m": 60,
"pressure_msl": 1015,
"fact": {
"temp": 20,
"feels_like": 18,
"condition": "partly_cloudy",
"wind_speed": 4,
"wind_gust": 6,
"wind_dir": "w",
"humidity": 60,
"pressure_mm": 740.0,
"pressure_pa": 987,
}
}
success_response.raise_for_status = MagicMock()
mock_get.side_effect = [ConnectionError("fail"), success_response]
result = await fetch_open_meteo(max_retries=2)
mock_get.side_effect = [SSLError("SSL Error"), success_response]
result = await fetch_weather(max_retries=2)
assert result is not None
assert mock_get.call_count == 2
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
"""Все попытки неудачны → None."""
async def test_fetch_weather_connection_error_retry(self, mock_get) -> None:
"""ConnectionError → retry."""
success_response = MagicMock()
success_response.json.return_value = {
"fact": {
"temp": 20,
"feels_like": 18,
"condition": "partly_cloudy",
"wind_speed": 4,
"wind_gust": 6,
"wind_dir": "w",
"humidity": 60,
"pressure_mm": 740.0,
"pressure_pa": 987,
}
}
success_response.raise_for_status = MagicMock()
mock_get.side_effect = [ConnectionError("No connection"), success_response]
result = await fetch_weather(max_retries=2)
assert result is not None
assert mock_get.call_count == 2
@patch("utils.pogoda._session.get")
async def test_fetch_weather_timeout_retry(self, mock_get) -> None:
"""Timeout → retry."""
success_response = MagicMock()
success_response.json.return_value = {
"fact": {
"temp": 20,
"feels_like": 18,
"condition": "partly_cloudy",
"wind_speed": 4,
"wind_gust": 6,
"wind_dir": "w",
"humidity": 60,
"pressure_mm": 740.0,
"pressure_pa": 987,
}
}
success_response.raise_for_status = MagicMock()
mock_get.side_effect = [Timeout("Timed out"), success_response]
result = await fetch_weather(max_retries=2)
assert result is not None
assert mock_get.call_count == 2
@patch("utils.pogoda._session.get")
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
"""Все попытки не удались → None."""
mock_get.side_effect = [
ConnectionError("fail"),
ConnectionError("fail"),
ConnectionError("fail"),
]
result = await fetch_open_meteo(max_retries=3)
result = await fetch_weather(max_retries=3)
assert result is None
assert mock_get.call_count == 3
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_http_error(self, mock_get) -> None:
async def test_fetch_weather_request_exception(self, mock_get) -> None:
"""Общий RequestException → None."""
mock_get.side_effect = requests.RequestException("Generic error")
result = await fetch_weather(max_retries=1)
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_weather_http_error(self, mock_get) -> None:
"""HTTP 404 → raise_for_status бросит исключение → None."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather(max_retries=1)
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_wind_speed_0(self, mock_get) -> None:
async def test_fetch_weather_json_parse_error(self, mock_get) -> None:
"""Ошибка парсинга JSON → None."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.side_effect = requests.RequestException("JSON Error")
mock_get.return_value = mock_response
result = await fetch_weather(max_retries=1)
assert result is None
@patch("utils.pogoda._session.get")
async def test_fetch_weather_wind_speed_0(self, mock_get) -> None:
"""Нулевая скорость ветра должна корректно обрабатываться."""
mock_response = MagicMock()
mock_response.json.return_value = {
"current": {
"temperature": 0,
"apparent_temperature": -2,
"weather_code": 45,
"wind_speed_10m": 0,
"relative_humidity_2m": 95,
"pressure_msl": 1000,
"fact": {
"temp": 0,
"feels_like": -2,
"condition": "fog",
"wind_speed": 0,
"wind_gust": 0,
"wind_dir": "n",
"humidity": 95,
"pressure_mm": 750.0,
"pressure_pa": 1000,
}
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None
assert result["current_condition"][0]["windspeedKmph"] == 0
assert result["current_condition"][0]["pressure"] == 1000
assert result["current_condition"][0]["pressure"] == 750.0
@patch("utils.pogoda._session.get")
async def test_fetch_weather_negative_temp(self, mock_get) -> None:
"""Отрицательная температура."""
mock_response = MagicMock()
mock_response.json.return_value = {
"fact": {
"temp": -15,
"feels_like": -22,
"condition": "heavy_snow",
"wind_speed": 8,
"wind_gust": 12,
"wind_dir": "n",
"humidity": 90,
"pressure_mm": 720.0,
"pressure_pa": 960,
}
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = await fetch_weather()
assert result is not None
assert result["current_condition"][0]["temp_C"] == -15
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Сильный снег"}]
@patch("utils.pogoda._session.get")
async def test_fetch_weather_includes_headers(self, mock_get) -> None:
"""Запрос должен содержать X-Yandex-API-Key заголовок."""
mock_response = MagicMock()
mock_response.json.return_value = {
"fact": {
"temp": 20,
"feels_like": 18,
"condition": "clear",
"wind_speed": 3,
"wind_gust": 5,
"wind_dir": "s",
"humidity": 50,
"pressure_mm": 760.0,
"pressure_pa": 1013,
}
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
with patch("utils.pogoda._get_api_key", return_value="test-key"):
await fetch_weather()
mock_get.assert_called_once()
call_kwargs = mock_get.call_args[1]
assert "X-Yandex-API-Key" in call_kwargs["headers"]
assert call_kwargs["headers"]["X-Yandex-API-Key"] == "test-key"

View File

@ -1,9 +1,10 @@
import pytest
from utils.pogoda import (
translate_weather,
pressure_to_mmhg,
wmo_to_russian,
yandex_condition_to_russian,
format_weather_data_for_console,
get_weather_description,
)
@ -15,12 +16,14 @@ class TestFormatWeatherDataForConsole:
data = {
"current_condition": [
{
"temp_C": "25",
"FeelsLikeC": "26",
"weatherDesc": [{"value": "Clear"}],
"humidity": "45",
"windspeedKmph": "10",
"pressure": "1013",
"temp_C": 25,
"FeelsLikeC": 26,
"weatherDesc": [{"value": "Ясно"}],
"humidity": 45,
"windspeedKmph": 5.0, # м/с от Яндекса
"wind_gust": 8.0,
"wind_dir": "n",
"pressure": 735.0, # уже в мм рт. ст.
}
]
}
@ -32,8 +35,8 @@ class TestFormatWeatherDataForConsole:
assert "Температура: 25°C (ощущается как 26°C)" in result[0]
assert "Описание: Ясно" in result[1]
assert "Влажность: 45%" in result[2]
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
assert "Давление: 759.8 мм рт. ст." in result[4]
assert "Ветер: 5.0 м/с (порывы 8.0 м/с), северный" in result[3]
assert "Давление: 735.0 мм рт. ст." in result[4]
def test_format_empty_data(self) -> None:
"""Пустые данные должны возвращать None."""
@ -52,16 +55,16 @@ class TestFormatWeatherDataForConsole:
assert result is None, "Отсутствие current_condition должно вернуть None"
def test_format_with_dashes(self) -> None:
"""Неизвестные значения должны отображаться как ''."""
"""None значения должны отображаться как ''."""
data = {
"current_condition": [
{
"temp_C": "",
"FeelsLikeC": "",
"weatherDesc": [{"value": ""}],
"humidity": "",
"windspeedKmph": "",
"pressure": "",
"temp_C": None,
"FeelsLikeC": None,
"weatherDesc": [{"value": "Неизвестно"}],
"humidity": None,
"windspeedKmph": None,
"pressure": None,
}
]
}
@ -70,41 +73,24 @@ class TestFormatWeatherDataForConsole:
assert isinstance(result, list), "Результат должен быть списком строк"
assert "Температура: —°C (ощущается как —°C)" in result[0]
assert "Описание: " in result[1]
assert "Описание: Неизвестно" in result[1]
assert "Влажность: —%" in result[2]
assert "Ветер: — м/с" in result[3]
assert "Давление: — мм рт. ст." in result[4]
def test_format_wind_conversion(self) -> None:
"""Проверка конвертации ветра из км/ч в м/с."""
data = {
"current_condition": [
{
"temp_C": "20",
"FeelsLikeC": "19",
"weatherDesc": [{"value": "Cloudy"}],
"humidity": "60",
"windspeedKmph": "36",
"pressure": "1000",
}
]
}
result = format_weather_data_for_console(data)
# 36 / 3.6 = 10.0
assert "Ветер: 10.0 м/с" in result[3]
def test_format_negative_temperature(self) -> None:
"""Отрицательная температура должна отображаться корректно."""
data = {
"current_condition": [
{
"temp_C": "-5",
"FeelsLikeC": "-10",
"weatherDesc": [{"value": "Snow"}],
"humidity": "80",
"windspeedKmph": "20",
"pressure": "980",
"temp_C": -5,
"FeelsLikeC": -10,
"weatherDesc": [{"value": "Снег"}],
"humidity": 80,
"windspeedKmph": 5,
"wind_gust": 10,
"wind_dir": "n",
"pressure": 720.0,
}
]
}
@ -114,88 +100,54 @@ class TestFormatWeatherDataForConsole:
assert isinstance(result, list), "Результат должен быть списком строк"
assert "Температура: -5°C (ощущается как -10°C)" in result[0]
def test_format_without_gust_and_dir(self) -> None:
"""Без порывов и направления ветра — базовый формат."""
data = {
"current_condition": [
{
"temp_C": 17.3,
"FeelsLikeC": 16.8,
"weatherDesc": [{"value": "Облачно"}],
"humidity": 87.5,
"windspeedKmph": 2.1, # м/с
"pressure": 750.0,
}
]
}
class TestTranslateWeather:
@pytest.mark.parametrize(
"english, expected",
[
("Clear", "Ясно"),
("Sunny", "Ясно"),
("Partly cloudy", "Переменная облачность"),
("Cloudy", "Облачно"),
("Overcast", "Пасмурно"),
("Fog", "Туман"),
("Foggy", "Туманно"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Light rain", "Небольшой дождь"),
("Moderate rain", "Умеренный дождь"),
("Heavy rain", "Сильный дождь"),
(
"Moderate or heavy rain at times",
"Дождь",
), # длинный ключ проверяется первым
("Heavy rain at times", "Сильный дождь"),
("Light snow", "Небольшой снег"),
("Moderate snow", "Умеренный снег"),
("Heavy snow", "Сильный снег"),
("Blowing snow", "Метель"),
("Light freezing rain", "Лёгкий ледяной дождь"),
("Heavy freezing rain", "Сильный ледяной дождь"),
("Moderate or heavy freezing rain", "Сильный ледяной дождь"),
("Light sleet", "Light sleet"),
("Moderate or heavy sleet", "Moderate or heavy sleet"),
("Thundery outbreaks in nearby", "Гроза вблизи"),
("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"),
("Patchy sleet nearby", "Местами слякоть"),
("Patchy light drizzle", "Местами лёгкая морось"),
("Moderate or heavy snow in area", "Снег"),
("Moderate or heavy rain in area", "Дождь"),
],
)
def test_translate_known(self, english, expected) -> None:
"""Известные переводы должны возвращать ожидаемый результат."""
assert translate_weather(english) == expected
result = format_weather_data_for_console(data)
assert "Температура: 17.3°C" in result[0]
assert "Ветер: 2.1 м/с" in result[3]
@pytest.mark.parametrize(
"input_value, expected",
[
("", ""),
(None, ""),
(" ", ""), # строка из пробелов тоже считается пустой
],
)
def test_translate_empty(self, input_value, expected) -> None:
"""Пустой, None или только пробелы должен возвращать ''."""
assert translate_weather(input_value) == expected
def test_format_all_wind_directions(self) -> None:
"""Все направления ветра должны переводиться корректно."""
directions = [
("n", "северный"),
("ne", "северо-восточный"),
("e", "восточный"),
("se", "юго-восточный"),
("s", "южный"),
("sw", "юго-западный"),
("w", "западный"),
("nw", "северо-западный"),
]
def test_translate_unknown_returns_original(self) -> None:
"""Неизвестный перевод должен возвращать оригинальный текст."""
unknown_text = "Unknown weather condition XYZ"
assert translate_weather(unknown_text) == unknown_text
def test_translate_partial_match(self) -> None:
"""Частичное совпадение ключа в тексте должно сработать."""
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
text_with_prefix = "Light Moderate or heavy rain in area"
assert translate_weather(text_with_prefix) == "Дождь"
def test_translate_longer_key_priority(self) -> None:
"""Длинные ключи проверяются первыми (_WEATHER_MAPPING отсортирован по убыванию длины).
"Moderate or heavy rain at times" проверится до "Heavy rain"."""
text = "Moderate or heavy rain at times"
assert translate_weather(text) == "Дождь"
def test_translate_case_insensitive(self) -> None:
"""Перевод должен быть регистронезависимым."""
assert translate_weather("CLEAR") == "Ясно"
assert translate_weather("partly cloudy") == "Переменная облачность"
assert translate_weather("HEAVY RAIN") == "Сильный дождь"
def test_translate_with_whitespace(self) -> None:
"""Текст с пробелами по краям должен корректно переводиться."""
assert translate_weather(" Clear ") == "Ясно"
for code, expected in directions:
data = {
"current_condition": [
{
"temp_C": 20,
"FeelsLikeC": 18,
"weatherDesc": [{"value": "Ясно"}],
"humidity": 50,
"windspeedKmph": 3.0,
"wind_dir": code,
"pressure": 750.0,
}
]
}
result = format_weather_data_for_console(data)
assert expected in result[3], f"Направление {code}{expected} не найдено"
class TestPressureToMMHG:
@ -260,17 +212,20 @@ class TestPressureToMMHG:
class TestWmoToRussian:
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды."""
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды.
Оставлен для обратной совместимости.
"""
@pytest.mark.parametrize(
"code, expected",
[
(0, "Ясно"),
(1, "Ясно"),
(1, "Преимущественно ясно"),
(2, "Переменная облачность"),
(3, "Пасмурно"),
(45, "Туман"),
(48, "Туман"),
(48, "Изморозь"),
(51, "Лёгкая морось"),
(53, "Морось"),
(55, "Сильная морось"),
@ -318,3 +273,68 @@ class TestWmoToRussian:
def test_wmo_float_code(self) -> None:
"""Дробный код — не найдётся в mapping."""
assert wmo_to_russian(1.5) == "Неизвестно"
class TestYandexConditionToRussian:
"""Тесты функции yandex_condition_to_russian() — перевод Яндекс condition."""
@pytest.mark.parametrize(
"condition, expected",
[
("clear", "Ясно"),
("partly_cloudy", "Переменная облачность"),
("cloudy", "Облачно"),
("overcast", "Пасмурно"),
("light_rain", "Небольшой дождь"),
("rain", "Дождь"),
("heavy_rain", "Сильный дождь"),
("drizzle", "Морось"),
("heavy_showers", "Сильные осадки"),
("thunderstorm", "Гроза"),
("thunderstorm_with_rain", "Гроза с дождём"),
("thunderstorm_with_heavy_rain", "Сильная гроза с дождём"),
("thunderstorm_with_hail", "Гроза с градом"),
("snow_showers", "Снежные осадки"),
("light_snow", "Небольшой снег"),
("snow", "Снег"),
("heavy_snow", "Сильный снег"),
("snowstorm", "Метель"),
("blizzard", "Буран"),
("fog", "Туман"),
],
)
def test_yandex_known(self, condition, expected) -> None:
"""Известные Яндекс condition должны возвращать ожидаемый перевод."""
assert yandex_condition_to_russian(condition) == expected
def test_yandex_unknown(self) -> None:
"""Неизвестный condition должен возвращать 'Неизвестно'."""
assert yandex_condition_to_russian("unknown_condition") == "Неизвестно"
def test_yandex_none(self) -> None:
"""None должен возвращать 'Неизвестно'."""
assert yandex_condition_to_russian(None) == "Неизвестно"
class TestGetWeatherDescription:
"""Тесты функции get_weather_description()."""
def test_valid_description(self) -> None:
"""Нормальный weatherDesc -> значение."""
current = {"weatherDesc": [{"value": "Ясно"}]}
assert get_weather_description(current) == "Ясно"
def test_empty_current(self) -> None:
"""Пустой current -> ''."""
current = {}
assert get_weather_description(current) == ""
def test_weather_desc_none(self) -> None:
"""weatherDesc с None -> ''."""
current = {"weatherDesc": None}
assert get_weather_description(current) == ""
def test_empty_value(self) -> None:
"""Пустая строка в value -> ''."""
current = {"weatherDesc": [{"value": ""}]}
assert get_weather_description(current) == ""

View File

@ -1,13 +1,11 @@
from .pogoda import (
API_URL_WEATHER,
_session as _weather_session,
fetch_weather,
fetch_open_meteo,
format_weather_data_for_console,
format_weather_for_message,
pressure_to_mmhg,
translate_weather,
wmo_to_russian,
yandex_condition_to_russian,
)
from .news import ( # noqa: E402
_session as _news_session,
@ -24,14 +22,12 @@ from .cat import ( # noqa: E402
__all__ = [
# Погода
"API_URL_WEATHER",
"fetch_weather",
"fetch_open_meteo",
"format_weather_data_for_console",
"format_weather_for_message",
"pressure_to_mmhg",
"translate_weather",
"wmo_to_russian",
"yandex_condition_to_russian",
# Новости
"RSS_URL_ARTICLES",
"RSS_URL_POSTS",
@ -56,4 +52,3 @@ def close_all_sessions() -> None:
session.close()
except Exception:
pass # Cleanup — игнорируем ошибки

View File

@ -11,7 +11,6 @@ import discord
from discord.ext import commands
from utils.pogoda import (
API_URL_WEATHER,
fetch_weather,
format_weather_for_message,
)
@ -40,7 +39,7 @@ class MorningData:
async def gather_morning() -> MorningData:
"""Собрать все данные для утреннего дайджеста параллельно."""
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(API_URL_WEATHER),
fetch_weather(),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),

View File

@ -1,75 +1,76 @@
"""Погода через Яндекс Погоду API.
Яндекс Погода: актуальные данные для городов России и мира,
текущие условия + прогноз. Требуется API-ключ в YANDEX_WEATHER_API_KEY.
https://yandex.ru/dev/weather/
"""
import asyncio
import logging
import os
from typing import Optional
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
from utils.rate_limiter import weather_limiter, open_meteo_limiter
from utils.rate_limiter import yandex_weather_limiter
logger = logging.getLogger(__name__)
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
# Координаты Магнитогорска
_LATITUDE: float = 53.40716
_LONGITUDE: float = 58.980289
_session = requests.Session()
def _get_api_key() -> str:
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
key = os.getenv("YANDEX_WEATHER_API_KEY")
if not key:
raise EnvironmentError(
"YANDEX_WEATHER_API_KEY не найден в переменных окружения"
)
return key
async def fetch_weather(
api_url: str, timeout: int = 10, max_retries: int = 3
lat: float = _LATITUDE,
lon: float = _LONGITUDE,
timeout: int = 10,
max_retries: int = 3,
) -> Optional[dict]:
"""Получить данные о погоде с retry."""
await weather_limiter.acquire()
"""Получить текущую погоду через Яндекс Погоду API.
Возвращает dict в унифицированном формате для форматирования:
{"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]}
"""
await yandex_weather_limiter.acquire()
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
headers = {"X-Yandex-API-Key": _get_api_key()}
for attempt in range(max_retries):
try:
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
response.raise_for_status()
return response.json()
except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1:
delay = 2**attempt
logger.warning(
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
)
await asyncio.sleep(delay)
continue
break
except requests.RequestException as e:
logger.error("Ошибка при получении данных: %s", e)
break
logger.warning("Все попытки wttr.in не удались, переход на Open-Meteo")
return await fetch_open_meteo()
async def fetch_open_meteo(
lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3
) -> Optional[dict]:
"""Fallback на Open-Meteo API."""
await open_meteo_limiter.acquire()
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}&current=temperature,"
f"apparent_temperature,weather_code,wind_speed_10m,"
f"relative_humidity_2m,pressure_msl&timezone=Asia/Chelyabinsk"
)
for attempt in range(max_retries):
try:
response = await asyncio.to_thread(_session.get, url, timeout=timeout)
response = await asyncio.to_thread(
_session.get, url, headers=headers, timeout=timeout
)
response.raise_for_status()
data = response.json()
current = data.get("current", {})
# weather_code WMO код -> перевод (https://open-meteo.com/en/docs)
weather_code = current.get("weather_code", None)
desc = wmo_to_russian(weather_code)
fact = data.get("fact", {})
return {
"current_condition": [
{
"temp_C": current.get("temperature", ""),
"FeelsLikeC": current.get("apparent_temperature", ""),
"weatherDesc": [{"value": desc}],
"humidity": current.get("relative_humidity_2m", ""),
"windspeedKmph": current.get("wind_speed_10m", ""),
"pressure": current.get("pressure_msl", ""),
"temp_C": fact.get("temp"),
"FeelsLikeC": fact.get("feels_like"),
"weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}],
"humidity": fact.get("humidity"),
"windspeedKmph": fact.get("wind_speed"),
"wind_gust": fact.get("wind_gust"),
"wind_dir": fact.get("wind_dir"),
"pressure": fact.get("pressure_mm"),
"pressure_pa": fact.get("pressure_pa"),
}
]
}
@ -83,20 +84,171 @@ async def fetch_open_meteo(
continue
break
except requests.RequestException as e:
logger.error("Ошибка при получении данных: %s", e)
return None
logger.error("Ошибка Яндекс Погоды: %s", e)
break
logger.warning("Все попытки Open-Meteo не удались")
logger.warning("Все попытки Яндекс Погоды не удались")
return None
# Яндекс condition → русский перевод
_YANDEX_CONDITION_MAPPING: dict[str, str] = {
"clear": "Ясно",
"partly_cloudy": "Переменная облачность",
"cloudy": "Облачно",
"overcast": "Пасмурно",
"light_rain": "Небольшой дождь",
"rain": "Дождь",
"heavy_rain": "Сильный дождь",
"drizzle": "Морось",
"heavy_showers": "Сильные осадки",
"thunderstorm": "Гроза",
"thunderstorm_with_rain": "Гроза с дождём",
"thunderstorm_with_heavy_rain": "Сильная гроза с дождём",
"thunderstorm_with_hail": "Гроза с градом",
"snow_showers": "Снежные осадки",
"light_snow": "Небольшой снег",
"snow": "Снег",
"heavy_snow": "Сильный снег",
"snowstorm": "Метель",
"blizzard": "Буран",
"fog": "Туман",
}
def yandex_condition_to_russian(condition: Optional[str]) -> str:
"""Перевод Яндекс condition в русский."""
if condition is None:
return "Неизвестно"
return _YANDEX_CONDITION_MAPPING.get(condition, "Неизвестно")
def get_weather_description(current: dict) -> str:
"""Извлечь описание погоды из weatherDesc."""
return (current.get("weatherDesc") or [{}])[0].get("value") or ""
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
"""
Форматировать погодные данные для консольного вывода.
:param data: Ответ от API (dict)
:return: Строки с отформатированной погодой
"""
if data is None:
return None
current_condition_list = data.get("current_condition", [])
if not current_condition_list or not current_condition_list[0]:
return None
current = current_condition_list[0]
temp = current.get("temp_C")
if temp is None:
temp = ""
feels_like = current.get("FeelsLikeC")
if feels_like is None:
feels_like = ""
description = get_weather_description(current)
humidity = current.get("humidity")
if humidity is None:
humidity = ""
# Яндекс возвращает ветер в м/с, но для совместимости с унифицированным
# форматом WindspeedKmph содержит м/с — конвертируем обратно в м/с для вывода
wind_mps = current.get("windspeedKmph")
if wind_mps is None:
wind = ""
else:
try:
# windspeedKmph на самом деле хранит м/с от Яндекса
wind = round(float(wind_mps), 1)
except (ValueError, TypeError):
wind = ""
# Порывы ветра (м/с)
wind_gust = current.get("wind_gust")
if wind_gust is not None:
try:
wind = f"{wind} м/с (порывы {round(float(wind_gust), 1)} м/с)"
except (ValueError, TypeError):
pass
# Направление ветра
wind_dir = current.get("wind_dir")
if wind_dir is not None:
wind_dir_ru = _wind_dir_to_russian(wind_dir)
if wind != "":
wind = f"{wind}, {wind_dir_ru}"
# Давление — Яндекс уже возвращает в мм рт. ст.
pressure_mm = current.get("pressure")
if pressure_mm is None:
pressure_mm = ""
elif pressure_mm != "":
try:
pressure_mm = round(float(pressure_mm), 1)
except (ValueError, TypeError):
pressure_mm = ""
lines = [
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
f"Описание: {description}",
f"Влажность: {humidity}%",
f"Ветер: {wind} м/с" if not wind_dir or wind_dir in ("", None) else f"Ветер: {wind}",
f"Давление: {pressure_mm} мм рт. ст.",
]
return lines
def _wind_dir_to_russian(direction: str) -> str:
"""Перевод направления ветра из кода в русский."""
mapping = {
"n": "северный",
"ne": "северо-восточный",
"e": "восточный",
"se": "юго-восточный",
"s": "южный",
"sw": "юго-западный",
"w": "западный",
"nw": "северо-западный",
}
return mapping.get(direction, direction)
def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
"""Форматировать погоду для plain text сообщения (с заголовком)."""
if data is None:
return None
lines = format_weather_data_for_console(data)
if not lines:
return None
return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
"""Конвертировать давление из гПа/мб в мм рт. ст.
Для совместимости с существующими тестами.
Яндекс Погода уже возвращает давление в мм рт. ст.,
но функция оставлена для обратной совместимости.
"""
if mb == "" or mb is None or mb == "":
return ""
try:
return round(float(mb) * 0.750062, 1)
except (ValueError, TypeError):
return ""
# WMO mapping оставлен для обратной совместимости (используется в тестах)
_WMO_MAPPING: dict[int, str] = {
0: "Ясно",
1: "Ясно",
1: "Преимущественно ясно",
2: "Переменная облачность",
3: "Пасмурно",
45: "Туман",
48: "Туман",
48: "Изморозь",
51: "Лёгкая морось",
53: "Морось",
55: "Сильная морось",
@ -123,137 +275,8 @@ _WMO_MAPPING: dict[int, str] = {
def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
"""Перевод WMO weather code в русский.
Оставлен для обратной совместимости с тестами.
"""
return _WMO_MAPPING.get(code, "Неизвестно")
_WEATHER_MAPPING = [
# Отсортировано по убыванию длины ключа для корректного substring-matching:
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
# раньше "Light rain shower".
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("Moderate or heavy sleet in area", "Слякоть"),
("Moderate or heavy rain at times", "Дождь"),
("Moderate or heavy snow at times", "Снег"),
("Moderate or heavy snow in area", "Снег"),
("Moderate or heavy rain in area", "Дождь"),
("Thundery outbreaks in nearby", "Гроза вблизи"),
("Moderate or light sleet", "Слякоть"),
("Moderate rain at times", "Умеренный дождь"),
("Patchy light drizzle", "Местами лёгкая морось"),
("Heavy freezing rain", "Сильный ледяной дождь"),
("Light freezing rain", "Лёгкий ледяной дождь"),
("Patchy sleet nearby", "Местами слякоть"),
("Heavy rain at times", "Сильный дождь"),
("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"),
("Patchy light snow", "Местами лёгкий снег"),
("Light rain shower", "Небольшой дождь"),
("Heavy rain shower", "Сильный дождь"),
("Moderate rain", "Умеренный дождь"),
("Moderate snow", "Умеренный снег"),
("Partly cloudy", "Переменная облачность"),
("Blowing snow", "Метель"),
("Light rain", "Небольшой дождь"),
("Heavy rain", "Сильный дождь"),
("Light snow", "Небольшой снег"),
("Heavy snow", "Сильный снег"),
("Overcast", "Пасмурно"),
("Cloudy", "Облачно"),
("Foggy", "Туманно"),
("Clear", "Ясно"),
("Sunny", "Ясно"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Fog", "Туман"),
]
def translate_weather(en: Optional[str]) -> str:
if not en or not en.strip():
return ""
en_stripped = en.strip()
en_lower = en_stripped.lower()
# Сначала проверяем точное совпадение (приоритет над substring)
for key, value in _WEATHER_MAPPING:
if key.lower() == en_lower:
return value
# Fallback: substring-matching (отсортировано по убыванию длины ключа)
for key, value in _WEATHER_MAPPING:
if key.lower() in en_lower:
return value
return en
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
"""
Форматировать погодные данные для консольного вывода.
:param data: Ответ от API (dict)
:return: Строки с отформатированной погодой
"""
if data is None:
return None
current_condition_list = data.get("current_condition", [])
if not current_condition_list:
return None
current = current_condition_list[0]
if not current:
return None
# dict.get() возвращает None если ключ существует со значением null;
# используем проверку на None для корректного fallback
temp = current.get("temp_C")
if temp is None:
temp = ""
feels_like = current.get("FeelsLikeC")
if feels_like is None:
feels_like = ""
weather_desc = current.get("weatherDesc", [{}])[0].get("value")
if weather_desc is None:
weather_desc = ""
description = translate_weather(weather_desc)
humidity = current.get("humidity")
if humidity is None:
humidity = ""
wind_kmh = current.get("windspeedKmph")
if wind_kmh is None:
wind_kmh = ""
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else ""
except (ValueError, TypeError):
wind = ""
pressure_mb = current.get("pressure", "")
pressure_mm = pressure_to_mmhg(pressure_mb)
return [
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
f"Описание: {description}",
f"Влажность: {humidity}%",
f"Ветер: {wind} м/с",
f"Давление: {pressure_mm} мм рт. ст.",
]
def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
"""Форматировать погоду для plain text сообщения (с заголовком)."""
if data is None:
return None
lines = format_weather_data_for_console(data)
if not lines:
return None
return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
if mb == "" or mb is None or mb == "":
return ""
try:
return round(float(mb) * 0.750062, 1)
except (ValueError, TypeError):
return ""

View File

@ -66,13 +66,9 @@ class RateLimiter:
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
# wttr.in: без ключа, 1 req/sec, burst 3
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1"))
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3"))
# Open-Meteo: fallback, 2 req/sec, burst 5
_OPEN_METEO_RATE: Final[float] = float(os.getenv("OPEN_METEO_API_RATE", "2"))
_OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5"))
# Яндекс Погода: 1 req/sec, burst 3
_YANDEX_WEATHER_RATE: Final[float] = float(os.getenv("YANDEX_WEATHER_API_RATE", "1"))
_YANDEX_WEATHER_BURST: Final[int] = int(os.getenv("YANDEX_WEATHER_API_BURST", "3"))
# Habr RSS: 1 req/sec, burst 2
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
@ -84,14 +80,9 @@ def make_cat_limiter() -> RateLimiter:
return RateLimiter(_CAT_RATE, _CAT_BURST)
def make_weather_limiter() -> RateLimiter:
"""Создать лимитер для wttr.in."""
return RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
def make_open_meteo_limiter() -> RateLimiter:
"""Создать лимитер для Open-Meteo."""
return RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
def make_yandex_weather_limiter() -> RateLimiter:
"""Создать лимитер для Яндекс Погоды."""
return RateLimiter(_YANDEX_WEATHER_RATE, _YANDEX_WEATHER_BURST)
def make_habr_rss_limiter() -> RateLimiter:
@ -104,6 +95,5 @@ def make_habr_rss_limiter() -> RateLimiter:
# Factory-функции (make_*_limiter) используются для создания
# изолированных экземпляров в тестах с контролируемым временем.
cat_limiter: RateLimiter = make_cat_limiter()
weather_limiter: RateLimiter = make_weather_limiter()
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
yandex_weather_limiter: RateLimiter = make_yandex_weather_limiter()
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()