From 48e64c2bc8259ad3a5cc07cb39ab4962627935fc Mon Sep 17 00:00:00 2001 From: deadzilla Date: Mon, 20 Jul 2026 23:31:11 +0500 Subject: [PATCH] Replace wttr.in/Open-Meteo with Yandex Weather API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 7 +- bot.py | 5 + commands/pg.py | 4 +- tests/test_commands_pg.py | 103 ++++++---- tests/test_fetch_weather.py | 376 +++++++++++++++++++--------------- tests/test_pogoda.py | 268 +++++++++++++----------- utils/__init__.py | 9 +- utils/morning_runner.py | 3 +- utils/pogoda.py | 397 +++++++++++++++++++----------------- utils/rate_limiter.py | 24 +-- 10 files changed, 647 insertions(+), 549 deletions(-) diff --git a/.env.example b/.env.example index f0b307e..ba68dcc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/bot.py b/bot.py index a3bbf42..c33a769 100644 --- a/bot.py +++ b/bot.py @@ -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(":")) diff --git a/commands/pg.py b/commands/pg.py index a25752a..a9d462c 100644 --- a/commands/pg.py +++ b/commands/pg.py @@ -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 diff --git a/tests/test_commands_pg.py b/tests/test_commands_pg.py index add64f6..5894850 100644 --- a/tests/test_commands_pg.py +++ b/tests/test_commands_pg.py @@ -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 diff --git a/tests/test_fetch_weather.py b/tests/test_fetch_weather.py index d014d12..0422ea9 100644 --- a/tests/test_fetch_weather.py +++ b/tests/test_fetch_weather.py @@ -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" diff --git a/tests/test_pogoda.py b/tests/test_pogoda.py index 86c1b5a..28f24be 100644 --- a/tests/test_pogoda.py +++ b/tests/test_pogoda.py @@ -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) == "—" diff --git a/utils/__init__.py b/utils/__init__.py index 4d68d36..005e231 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -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 — игнорируем ошибки - diff --git a/utils/morning_runner.py b/utils/morning_runner.py index 256ef7f..77e78f5 100644 --- a/utils/morning_runner.py +++ b/utils/morning_runner.py @@ -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(), diff --git a/utils/pogoda.py b/utils/pogoda.py index 8439db9..1a19a8b 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -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}¤t=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 "—" diff --git a/utils/rate_limiter.py b/utils/rate_limiter.py index 9dbb94a..9950c64 100644 --- a/utils/rate_limiter.py +++ b/utils/rate_limiter.py @@ -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()