- utils/news.py: добавлен logger, логирование ошибок RSS - utils/cat.py: добавлен logger, логирование ошибок TheCatAPI - utils/pogoda.py: улучшены логи fallback/warning при ошибках API - utils/rate_limiter.py: debug-логи при ожидании токенов - commands/pg.py, news.py, cat.py, morning.py, status.py, stats.py: logger + логи ошибок и успешного выполнения команд - console_commands/pogoda.py, news.py, cat.py, morning.py, status.py, stats.py: logger + логи выполнения - bot.py: логи запуска/остановки, проверки конфигурации, маршрутизации консольных команд, f-строки -> %s формат - ISSUES.md: снят флаг задачи по логированию - все 243 теста пройдены
200 lines
8.3 KiB
Python
200 lines
8.3 KiB
Python
import asyncio
|
||
import logging
|
||
import requests
|
||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||
|
||
from utils.rate_limiter import weather_limiter, open_meteo_limiter
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||
|
||
_session = requests.Session()
|
||
|
||
|
||
async def fetch_weather(api_url, timeout=10, max_retries=3):
|
||
"""Получить данные о погоде с retry."""
|
||
await weather_limiter.acquire()
|
||
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=53.4069, lon=58.9797, timeout=10, max_retries=3):
|
||
"""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.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)
|
||
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", "—"),
|
||
}]
|
||
}
|
||
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)
|
||
return None
|
||
|
||
logger.warning("Все попытки Open-Meteo не удались")
|
||
return None
|
||
|
||
|
||
def wmo_to_russian(code):
|
||
"""Перевод WMO weather code в русский."""
|
||
mapping = {
|
||
0: "Ясно",
|
||
1: "Ясно", 2: "Переменная облачность",
|
||
3: "Пасмурно",
|
||
45: "Туман", 48: "Туман",
|
||
51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
|
||
56: "Ледяная морось", 57: "Сильная ледяная морось",
|
||
61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
|
||
66: "Ледяной дождь", 67: "Сильный ледяной дождь",
|
||
71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
|
||
77: "Снежная крупа",
|
||
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
|
||
85: "Снежный ливень", 86: "Сильный снежный ливень",
|
||
95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
|
||
}
|
||
return mapping.get(code, "Неизвестно")
|
||
|
||
|
||
def translate_weather(en):
|
||
if not en:
|
||
return "—"
|
||
mapping = {
|
||
"Moderate or heavy freezing rain in area": "Ледяной дождь",
|
||
"Moderate or heavy sleet in area": "Слякоть",
|
||
"Moderate or heavy snow in area": "Снег",
|
||
"Moderate or heavy rain in area": "Дождь",
|
||
"Thundery outbreaks in nearby": "Гроза вблизи",
|
||
"Patchy rain nearby": "Местами дождь",
|
||
"Patchy snow nearby": "Местами снег",
|
||
"Patchy sleet nearby": "Местами слякоть",
|
||
"Heavy freezing rain": "Сильный ледяной дождь",
|
||
"Heavy snow": "Сильный снег",
|
||
"Heavy rain": "Сильный дождь",
|
||
"Moderate or heavy rain at times": "Дождь",
|
||
"Moderate or heavy snow at times": "Снег",
|
||
"Blowing snow": "Метель",
|
||
"Patchy light drizzle": "Местами лёгкая морось",
|
||
"Moderate or heavy freezing rain at a distance": "Ледяной дождь",
|
||
"Moderate or heavy sleet at a distance": "Слякоть",
|
||
"Light rain shower": "Небольшой дождь",
|
||
"Heavy rain shower": "Сильный дождь",
|
||
"Moderate rain": "Умеренный дождь",
|
||
"Light rain": "Небольшой дождь",
|
||
"Moderate rain at times": "Умеренный дождь",
|
||
"Heavy rain at times": "Сильный дождь",
|
||
"Light snow": "Небольшой снег",
|
||
"Moderate snow": "Умеренный снег",
|
||
"Patchy light snow": "Местами лёгкий снег",
|
||
"Partly cloudy": "Переменная облачность",
|
||
"Moderate or light sleet": "Слякоть",
|
||
"Light freezing rain": "Лёгкий ледяной дождь",
|
||
"Foggy": "Туманно",
|
||
"Fog": "Туман",
|
||
"Mist": "Туман",
|
||
"Haze": "Дымка",
|
||
"Overcast": "Пасмурно",
|
||
"Cloudy": "Облачно",
|
||
"Clear": "Ясно",
|
||
"Sunny": "Ясно",
|
||
}
|
||
for key, value in mapping.items():
|
||
if key.lower() in en.lower():
|
||
return value
|
||
return en
|
||
|
||
|
||
def format_weather_data_for_console(data):
|
||
"""
|
||
Форматировать погодные данные для консольного вывода.
|
||
|
||
:param data: Ответ от API (dict)
|
||
:return: Строки с отформатированной погодой
|
||
"""
|
||
if data is None:
|
||
return None
|
||
current = data.get("current_condition", [{}])[0]
|
||
if not current:
|
||
return None
|
||
|
||
temp = current.get("temp_C", "—")
|
||
feels_like = current.get("FeelsLikeC", "—")
|
||
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—"))
|
||
humidity = current.get("humidity", "—")
|
||
wind_kmh = current.get("windspeedKmph", "—")
|
||
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_embed(data):
|
||
"""Форматировать погоду для Discord embed (с заголовком)."""
|
||
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):
|
||
if mb == "—" or not mb:
|
||
return "—"
|
||
try:
|
||
return round(float(mb) * 0.750062, 1)
|
||
except (ValueError, TypeError):
|
||
return "—"
|