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 MORNING_CHANNEL_ID=channel_id
LOG_LEVEL=INFO LOG_LEVEL=INFO
CAT_API_KEY=your_cat_api_key_here CAT_API_KEY=your_cat_api_key_here
YANDEX_WEATHER_API_KEY=your_yandex_weather_api_key_here
CAT_API_RATE=1 CAT_API_RATE=1
CAT_API_BURST=3 CAT_API_BURST=3
WEATHER_API_RATE=1 YANDEX_WEATHER_API_RATE=1
WEATHER_API_BURST=3 YANDEX_WEATHER_API_BURST=3
OPEN_METEO_API_RATE=2
OPEN_METEO_API_BURST=5
HABR_RSS_RATE=1 HABR_RSS_RATE=1
HABR_RSS_BURST=2 HABR_RSS_BURST=2

5
bot.py
View File

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

View File

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

View File

@ -7,18 +7,14 @@ from commands.pg import Pg
class TestPgInit: class TestPgInit:
"""Тесты инициализации Cog Pg.""" """Тесты инициализации Cog Pg."""
def test_uses_api_url_constant(self) -> None: def test_cog_initialized(self) -> None:
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной).""" """Cog инициализируется без ошибок."""
from utils.pogoda import API_URL_WEATHER
cog = Pg() cog = Pg()
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую assert cog is not None
assert not hasattr(cog, "api_url")
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
class TestPgCommand: class TestPgCommand:
"""Тесты команды !pogoda.""" """Тесты команды !pg."""
def _make_cog(self): def _make_cog(self):
return Pg() return Pg()
@ -29,16 +25,25 @@ class TestPgCommand:
return ctx return ctx
def _make_weather_data(self, **extra): def _make_weather_data(self, **extra):
"""Создать mock weather data с дефолтными полями.""" """Создать mock weather data (Яндекс Погода формат).
windspeedKmph м/с от Яндекса.
wind_gust порывы ветра в м/с.
wind_dir направление ветра.
pressure уже в мм рт. ст.
weatherDesc на русском (из yandex_condition_to_russian).
"""
defaults = { defaults = {
"current_condition": [ "current_condition": [
{ {
"temp_C": "22", "temp_C": 22,
"FeelsLikeC": "24", "FeelsLikeC": 24,
"weatherDesc": [{"value": "Clear"}], "weatherDesc": [{"value": "Ясно"}],
"humidity": "45", "humidity": 45,
"windspeedKmph": "18", "windspeedKmph": 5.0, # м/с
"pressure": "1013", "wind_gust": 8.0,
"wind_dir": "n",
"pressure": 735.0,
} }
] ]
} }
@ -47,7 +52,7 @@ class TestPgCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_success(self) -> None: async def test_pg_success(self) -> None:
"""Успешный запрос погоды должен отправить embed с данными.""" """Успешный запрос погоды должен отправить сообщение с данными."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data() weather = self._make_weather_data()
@ -62,7 +67,7 @@ class TestPgCommand:
assert "Описание: Ясно" in args assert "Описание: Ясно" in args
assert "Влажность: 45%" in args assert "Влажность: 45%" in args
assert "Ветер: 5.0 м/с" in args assert "Ветер: 5.0 м/с" in args
assert "Давление: 759.8 мм рт. ст." in args assert "Давление: 735.0 мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_fetch_returns_none(self) -> None: async def test_pg_fetch_returns_none(self) -> None:
@ -130,7 +135,7 @@ class TestPgCommand:
"""windspeedKmph = 0 — wind должен быть 0.0.""" """windspeedKmph = 0 — wind должен быть 0.0."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
@ -162,22 +167,9 @@ class TestPgCommand:
assert "Влажность: —%" in args assert "Влажность: —%" in args
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 @pytest.mark.asyncio
async def test_pg_russian_weather_description(self) -> None: async def test_pg_russian_weather_description(self) -> None:
"""Описание погоды на русском должно корректно переводиться.""" """Описание погоды на русском должно корректно отображаться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data( weather = self._make_weather_data(
@ -191,27 +183,58 @@ class TestPgCommand:
assert "Описание: Переменная облачность" in args assert "Описание: Переменная облачность" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_negative_pressure(self) -> None: async def test_pg_with_wind_gust(self) -> None:
"""Отрицательное давление должно конвертироваться.""" """Порывы ветра должны отображаться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Давление: -37.5 мм рт. ст." in args assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_high_wind(self) -> None: async def test_pg_with_wind_direction(self) -> None:
"""Большая скорость ветра должна корректно округляться.""" """Направление ветра должно переводиться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] 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 import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from utils.pogoda import fetch_weather, fetch_open_meteo from utils.pogoda import fetch_weather
class TestFetchWeather: 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") @patch("utils.pogoda._session.get")
async def test_fetch_weather_success(self, mock_get) -> None: 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 = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
"current": { "fact": {
"temperature": 15, "temp": 15,
"apparent_temperature": 12, "feels_like": 12,
"weather_code": 3, "condition": "cloudy",
"wind_speed_10m": 5.5, "wind_speed": 5.5,
"relative_humidity_2m": 65, "wind_gust": 8.0,
"pressure_msl": 1013, "wind_dir": "n",
"humidity": 65,
"pressure_mm": 735.0,
"pressure_pa": 980,
} }
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None assert result is not None
assert "current_condition" in result assert "current_condition" in result
assert result["current_condition"][0]["temp_C"] == 15 assert result["current_condition"][0]["temp_C"] == 15
assert result["current_condition"][0]["FeelsLikeC"] == 12 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]["humidity"] == 65
assert result["current_condition"][0]["pressure"] == 1013 assert result["current_condition"][0]["pressure"] == 735.0
@patch("utils.pogoda._session.get") @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.""" """Кастомные координаты должны быть в URL."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
"current": { "fact": {
"temperature": 25, "temp": 25,
"apparent_temperature": 22, "feels_like": 22,
"weather_code": 0, "condition": "clear",
"wind_speed_10m": 3, "wind_speed": 3,
"relative_humidity_2m": 50, "wind_gust": 5,
"pressure_msl": 1020, "wind_dir": "s",
"humidity": 50,
"pressure_mm": 760.0,
"pressure_pa": 1013,
} }
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response 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 assert result is not None
mock_get.assert_called_once() mock_get.assert_called_once()
call_url = mock_get.call_args[0][0] call_url = mock_get.call_args[0][0]
@ -127,117 +73,215 @@ class TestFetchOpenMeteo:
assert "37.6173" in call_url assert "37.6173" in call_url
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_missing_weather_code(self, mock_get) -> None: async def test_fetch_weather_missing_condition(self, mock_get) -> None:
"""Отсутствующий weather_code'Неизвестно'.""" """Отсутствующий condition'Неизвестно'."""
mock_response = MagicMock() 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_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None assert result is not None
assert result["current_condition"][0]["weatherDesc"] == [ assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
{"value": "Неизвестно"}
]
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None: async def test_fetch_weather_ssl_error_retry(self, mock_get) -> None:
"""SSLError → вернуть None.""" """SSLError на первой попытке → retry → успех."""
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: первая попытка падает, вторая успешна."""
success_response = MagicMock() success_response = MagicMock()
success_response.json.return_value = { success_response.json.return_value = {
"current": { "fact": {
"temperature": 20, "temp": 20,
"apparent_temperature": 18, "feels_like": 18,
"weather_code": 1, "condition": "partly_cloudy",
"wind_speed_10m": 4, "wind_speed": 4,
"relative_humidity_2m": 60, "wind_gust": 6,
"pressure_msl": 1015, "wind_dir": "w",
"humidity": 60,
"pressure_mm": 740.0,
"pressure_pa": 987,
} }
} }
success_response.raise_for_status = MagicMock() success_response.raise_for_status = MagicMock()
mock_get.side_effect = [ConnectionError("fail"), success_response] mock_get.side_effect = [SSLError("SSL Error"), success_response]
result = await fetch_open_meteo(max_retries=2)
result = await fetch_weather(max_retries=2)
assert result is not None assert result is not None
assert mock_get.call_count == 2 assert mock_get.call_count == 2
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None: async def test_fetch_weather_connection_error_retry(self, mock_get) -> None:
"""Все попытки неудачны → 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 = [ mock_get.side_effect = [
ConnectionError("fail"), ConnectionError("fail"),
ConnectionError("fail"), 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 result is None
assert mock_get.call_count == 3 assert mock_get.call_count == 3
@patch("utils.pogoda._session.get") @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.""" """HTTP 404 → raise_for_status бросит исключение → None."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404") mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather(max_retries=1)
assert result is None assert result is None
@patch("utils.pogoda._session.get") @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 = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
"current": { "fact": {
"temperature": 0, "temp": 0,
"apparent_temperature": -2, "feels_like": -2,
"weather_code": 45, "condition": "fog",
"wind_speed_10m": 0, "wind_speed": 0,
"relative_humidity_2m": 95, "wind_gust": 0,
"pressure_msl": 1000, "wind_dir": "n",
"humidity": 95,
"pressure_mm": 750.0,
"pressure_pa": 1000,
} }
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo()
result = await fetch_weather()
assert result is not None assert result is not None
assert result["current_condition"][0]["windspeedKmph"] == 0 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 import pytest
from utils.pogoda import ( from utils.pogoda import (
translate_weather,
pressure_to_mmhg, pressure_to_mmhg,
wmo_to_russian, wmo_to_russian,
yandex_condition_to_russian,
format_weather_data_for_console, format_weather_data_for_console,
get_weather_description,
) )
@ -15,12 +16,14 @@ class TestFormatWeatherDataForConsole:
data = { data = {
"current_condition": [ "current_condition": [
{ {
"temp_C": "25", "temp_C": 25,
"FeelsLikeC": "26", "FeelsLikeC": 26,
"weatherDesc": [{"value": "Clear"}], "weatherDesc": [{"value": "Ясно"}],
"humidity": "45", "humidity": 45,
"windspeedKmph": "10", "windspeedKmph": 5.0, # м/с от Яндекса
"pressure": "1013", "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 "Температура: 25°C (ощущается как 26°C)" in result[0]
assert "Описание: Ясно" in result[1] assert "Описание: Ясно" in result[1]
assert "Влажность: 45%" in result[2] assert "Влажность: 45%" in result[2]
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8 assert "Ветер: 5.0 м/с (порывы 8.0 м/с), северный" in result[3]
assert "Давление: 759.8 мм рт. ст." in result[4] assert "Давление: 735.0 мм рт. ст." in result[4]
def test_format_empty_data(self) -> None: def test_format_empty_data(self) -> None:
"""Пустые данные должны возвращать None.""" """Пустые данные должны возвращать None."""
@ -52,16 +55,16 @@ class TestFormatWeatherDataForConsole:
assert result is None, "Отсутствие current_condition должно вернуть None" assert result is None, "Отсутствие current_condition должно вернуть None"
def test_format_with_dashes(self) -> None: def test_format_with_dashes(self) -> None:
"""Неизвестные значения должны отображаться как ''.""" """None значения должны отображаться как ''."""
data = { data = {
"current_condition": [ "current_condition": [
{ {
"temp_C": "", "temp_C": None,
"FeelsLikeC": "", "FeelsLikeC": None,
"weatherDesc": [{"value": ""}], "weatherDesc": [{"value": "Неизвестно"}],
"humidity": "", "humidity": None,
"windspeedKmph": "", "windspeedKmph": None,
"pressure": "", "pressure": None,
} }
] ]
} }
@ -70,41 +73,24 @@ class TestFormatWeatherDataForConsole:
assert isinstance(result, list), "Результат должен быть списком строк" assert isinstance(result, list), "Результат должен быть списком строк"
assert "Температура: —°C (ощущается как —°C)" in result[0] assert "Температура: —°C (ощущается как —°C)" in result[0]
assert "Описание: " in result[1] assert "Описание: Неизвестно" in result[1]
assert "Влажность: —%" in result[2] assert "Влажность: —%" in result[2]
assert "Ветер: — м/с" in result[3] assert "Ветер: — м/с" in result[3]
assert "Давление: — мм рт. ст." in result[4] 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: def test_format_negative_temperature(self) -> None:
"""Отрицательная температура должна отображаться корректно.""" """Отрицательная температура должна отображаться корректно."""
data = { data = {
"current_condition": [ "current_condition": [
{ {
"temp_C": "-5", "temp_C": -5,
"FeelsLikeC": "-10", "FeelsLikeC": -10,
"weatherDesc": [{"value": "Snow"}], "weatherDesc": [{"value": "Снег"}],
"humidity": "80", "humidity": 80,
"windspeedKmph": "20", "windspeedKmph": 5,
"pressure": "980", "wind_gust": 10,
"wind_dir": "n",
"pressure": 720.0,
} }
] ]
} }
@ -114,88 +100,54 @@ class TestFormatWeatherDataForConsole:
assert isinstance(result, list), "Результат должен быть списком строк" assert isinstance(result, list), "Результат должен быть списком строк"
assert "Температура: -5°C (ощущается как -10°C)" in result[0] 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: result = format_weather_data_for_console(data)
@pytest.mark.parametrize( assert "Температура: 17.3°C" in result[0]
"english, expected", assert "Ветер: 2.1 м/с" in result[3]
[
("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
@pytest.mark.parametrize( def test_format_all_wind_directions(self) -> None:
"input_value, expected", """Все направления ветра должны переводиться корректно."""
[ directions = [
("", ""), ("n", "северный"),
(None, ""), ("ne", "северо-восточный"),
(" ", ""), # строка из пробелов тоже считается пустой ("e", "восточный"),
], ("se", "юго-восточный"),
) ("s", "южный"),
def test_translate_empty(self, input_value, expected) -> None: ("sw", "юго-западный"),
"""Пустой, None или только пробелы должен возвращать ''.""" ("w", "западный"),
assert translate_weather(input_value) == expected ("nw", "северо-западный"),
]
def test_translate_unknown_returns_original(self) -> None: for code, expected in directions:
"""Неизвестный перевод должен возвращать оригинальный текст.""" data = {
unknown_text = "Unknown weather condition XYZ" "current_condition": [
assert translate_weather(unknown_text) == unknown_text {
"temp_C": 20,
def test_translate_partial_match(self) -> None: "FeelsLikeC": 18,
"""Частичное совпадение ключа в тексте должно сработать.""" "weatherDesc": [{"value": "Ясно"}],
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area" "humidity": 50,
text_with_prefix = "Light Moderate or heavy rain in area" "windspeedKmph": 3.0,
assert translate_weather(text_with_prefix) == "Дождь" "wind_dir": code,
"pressure": 750.0,
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" result = format_weather_data_for_console(data)
assert translate_weather(text) == "Дождь" assert expected in result[3], f"Направление {code}{expected} не найдено"
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 ") == "Ясно"
class TestPressureToMMHG: class TestPressureToMMHG:
@ -260,17 +212,20 @@ class TestPressureToMMHG:
class TestWmoToRussian: class TestWmoToRussian:
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды.""" """Тесты функции wmo_to_russian() — перевод WMO кодов погоды.
Оставлен для обратной совместимости.
"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"code, expected", "code, expected",
[ [
(0, "Ясно"), (0, "Ясно"),
(1, "Ясно"), (1, "Преимущественно ясно"),
(2, "Переменная облачность"), (2, "Переменная облачность"),
(3, "Пасмурно"), (3, "Пасмурно"),
(45, "Туман"), (45, "Туман"),
(48, "Туман"), (48, "Изморозь"),
(51, "Лёгкая морось"), (51, "Лёгкая морось"),
(53, "Морось"), (53, "Морось"),
(55, "Сильная морось"), (55, "Сильная морось"),
@ -318,3 +273,68 @@ class TestWmoToRussian:
def test_wmo_float_code(self) -> None: def test_wmo_float_code(self) -> None:
"""Дробный код — не найдётся в mapping.""" """Дробный код — не найдётся в mapping."""
assert wmo_to_russian(1.5) == "Неизвестно" 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 ( from .pogoda import (
API_URL_WEATHER,
_session as _weather_session, _session as _weather_session,
fetch_weather, fetch_weather,
fetch_open_meteo,
format_weather_data_for_console, format_weather_data_for_console,
format_weather_for_message, format_weather_for_message,
pressure_to_mmhg, pressure_to_mmhg,
translate_weather,
wmo_to_russian, wmo_to_russian,
yandex_condition_to_russian,
) )
from .news import ( # noqa: E402 from .news import ( # noqa: E402
_session as _news_session, _session as _news_session,
@ -24,14 +22,12 @@ from .cat import ( # noqa: E402
__all__ = [ __all__ = [
# Погода # Погода
"API_URL_WEATHER",
"fetch_weather", "fetch_weather",
"fetch_open_meteo",
"format_weather_data_for_console", "format_weather_data_for_console",
"format_weather_for_message", "format_weather_for_message",
"pressure_to_mmhg", "pressure_to_mmhg",
"translate_weather",
"wmo_to_russian", "wmo_to_russian",
"yandex_condition_to_russian",
# Новости # Новости
"RSS_URL_ARTICLES", "RSS_URL_ARTICLES",
"RSS_URL_POSTS", "RSS_URL_POSTS",
@ -56,4 +52,3 @@ def close_all_sessions() -> None:
session.close() session.close()
except Exception: except Exception:
pass # Cleanup — игнорируем ошибки pass # Cleanup — игнорируем ошибки

View File

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

View File

@ -1,75 +1,76 @@
"""Погода через Яндекс Погоду API.
Яндекс Погода: актуальные данные для городов России и мира,
текущие условия + прогноз. Требуется API-ключ в YANDEX_WEATHER_API_KEY.
https://yandex.ru/dev/weather/
"""
import asyncio import asyncio
import logging import logging
import os
from typing import Optional from typing import Optional
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError 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__) 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() _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( 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]: ) -> Optional[dict]:
"""Получить данные о погоде с retry.""" """Получить текущую погоду через Яндекс Погоду API.
await weather_limiter.acquire()
Возвращает 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): for attempt in range(max_retries):
try: try:
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout) response = await asyncio.to_thread(
response.raise_for_status() _session.get, url, headers=headers, timeout=timeout
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.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
current = data.get("current", {}) fact = data.get("fact", {})
# weather_code WMO код -> перевод (https://open-meteo.com/en/docs)
weather_code = current.get("weather_code", None)
desc = wmo_to_russian(weather_code)
return { return {
"current_condition": [ "current_condition": [
{ {
"temp_C": current.get("temperature", ""), "temp_C": fact.get("temp"),
"FeelsLikeC": current.get("apparent_temperature", ""), "FeelsLikeC": fact.get("feels_like"),
"weatherDesc": [{"value": desc}], "weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}],
"humidity": current.get("relative_humidity_2m", ""), "humidity": fact.get("humidity"),
"windspeedKmph": current.get("wind_speed_10m", ""), "windspeedKmph": fact.get("wind_speed"),
"pressure": current.get("pressure_msl", ""), "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 continue
break break
except requests.RequestException as e: except requests.RequestException as e:
logger.error("Ошибка при получении данных: %s", e) logger.error("Ошибка Яндекс Погоды: %s", e)
return None break
logger.warning("Все попытки Open-Meteo не удались") logger.warning("Все попытки Яндекс Погоды не удались")
return None 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] = { _WMO_MAPPING: dict[int, str] = {
0: "Ясно", 0: "Ясно",
1: "Ясно", 1: "Преимущественно ясно",
2: "Переменная облачность", 2: "Переменная облачность",
3: "Пасмурно", 3: "Пасмурно",
45: "Туман", 45: "Туман",
48: "Туман", 48: "Изморозь",
51: "Лёгкая морось", 51: "Лёгкая морось",
53: "Морось", 53: "Морось",
55: "Сильная морось", 55: "Сильная морось",
@ -123,137 +275,8 @@ _WMO_MAPPING: dict[int, str] = {
def wmo_to_russian(code: Optional[int]) -> str: def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский.""" """Перевод WMO weather code в русский.
Оставлен для обратной совместимости с тестами.
"""
return _WMO_MAPPING.get(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_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3")) _CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
# wttr.in: без ключа, 1 req/sec, burst 3 # Яндекс Погода: 1 req/sec, burst 3
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1")) _YANDEX_WEATHER_RATE: Final[float] = float(os.getenv("YANDEX_WEATHER_API_RATE", "1"))
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3")) _YANDEX_WEATHER_BURST: Final[int] = int(os.getenv("YANDEX_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"))
# Habr RSS: 1 req/sec, burst 2 # Habr RSS: 1 req/sec, burst 2
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1")) _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) return RateLimiter(_CAT_RATE, _CAT_BURST)
def make_weather_limiter() -> RateLimiter: def make_yandex_weather_limiter() -> RateLimiter:
"""Создать лимитер для wttr.in.""" """Создать лимитер для Яндекс Погоды."""
return RateLimiter(_WEATHER_RATE, _WEATHER_BURST) return RateLimiter(_YANDEX_WEATHER_RATE, _YANDEX_WEATHER_BURST)
def make_open_meteo_limiter() -> RateLimiter:
"""Создать лимитер для Open-Meteo."""
return RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
def make_habr_rss_limiter() -> RateLimiter: def make_habr_rss_limiter() -> RateLimiter:
@ -104,6 +95,5 @@ def make_habr_rss_limiter() -> RateLimiter:
# Factory-функции (make_*_limiter) используются для создания # Factory-функции (make_*_limiter) используются для создания
# изолированных экземпляров в тестах с контролируемым временем. # изолированных экземпляров в тестах с контролируемым временем.
cat_limiter: RateLimiter = make_cat_limiter() cat_limiter: RateLimiter = make_cat_limiter()
weather_limiter: RateLimiter = make_weather_limiter() yandex_weather_limiter: RateLimiter = make_yandex_weather_limiter()
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
habr_rss_limiter: RateLimiter = make_habr_rss_limiter() habr_rss_limiter: RateLimiter = make_habr_rss_limiter()