diff --git a/AGENTS.md b/AGENTS.md index 21afff2..f2d7f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ python bot.py - Пиши комментарии на русском. - Обработка ошибок: try/except для всех внешних вызовов (API, БД, файловая система). - **Никогда не используй эмодзи в тексте или выводах.** +- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.** ## Архитектура ``` diff --git a/ISSUES.md b/ISSUES.md index a547bff..7fc1c17 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -43,10 +43,11 @@ - **Проблема:** Ошибки команд просто печатаются в `stdout`. Пользователь в чате не видит, что команда выполнилась с ошибкой. - **Решение:** Добавить `await ctx.send("Произошла ошибка при выполнении команды.")` или отправить embed с деталями (если `ctx` не None). -### 7. Нет `.gitignore` -- **Где:** проект (файл отсутствует) -- **Проблема:** Нет явного `.gitignore`. `.env` может случайно попасть в репозиторий, хотя AGENTS.md говорит "`.env` в `.gitignore`". -- **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`). +### 7. Нет `.gitignore` ✅ РЕШЕНО +- **Где:** проект +- **Проблема:** Отсутствовал `.gitignore`, `.env` мог случайно попасть в репозиторий. +- **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`), логов, временных файлов, кэша ОС и IDE. +- **Статус:** Исправлено. `.gitignore` создан и обновлён. ### 8. `requests` без Session — нет переиспользования соединений - **Где:** все файлы (`pogoda.py`, `news.py`) diff --git a/commands/pogoda.py b/commands/pogoda.py index ee9042f..fc48559 100644 --- a/commands/pogoda.py +++ b/commands/pogoda.py @@ -1,8 +1,6 @@ import discord from discord.ext import commands -import requests -import asyncio -from requests.exceptions import ConnectionError, Timeout, SSLError +from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg class Pogoda(commands.Cog): @@ -13,7 +11,7 @@ class Pogoda(commands.Cog): @commands.command(name="pogoda") async def pogoda(self, ctx): - data = await self._fetch_weather(ctx) + data = await fetch_weather(self.api_url) if data is None: return @@ -24,7 +22,7 @@ class Pogoda(commands.Cog): temp = current.get("temp_C", "—") feels_like = current.get("FeelsLikeC", "—") - description = self._translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) + description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) humidity = current.get("humidity", "—") wind_kmh = current.get("windspeedKmph", "—") try: @@ -33,7 +31,7 @@ class Pogoda(commands.Cog): wind = "—" pressure_mb = current.get("pressure", "—") - pressure_mm = self._pressure_to_mmhg(pressure_mb) + pressure_mm = pressure_to_mmhg(pressure_mb) await ctx.send( f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)\n" @@ -42,140 +40,3 @@ class Pogoda(commands.Cog): f"[WIND] Ветер: {wind} м/с\n" f"[PRESS] Давление: {pressure_mm} мм рт. ст." ) - - async def _fetch_weather(self, ctx): - """Получить данные о погоде с retry и fallback.""" - # Пробуем wttr.in с retry - for attempt in range(3): - try: - response = await asyncio.to_thread(requests.get, self.api_url, timeout=10) - response.raise_for_status() - return response.json() - except (SSLError, ConnectionError, Timeout): - if attempt < 2: - delay = 2 ** attempt - print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") - await asyncio.sleep(delay) - continue - break - except requests.RequestException as e: - await ctx.send(f"Ошибка при получении данных: {e}") - return None - - # Fallback: Open-Meteo API (без ключа, HTTPS) - return await self._fetch_open_meteo(ctx) - - async def _fetch_open_meteo(self, ctx): - """Fallback на Open-Meteo API.""" - url = ( - "https://api.open-meteo.com/v1/forecast?" - "latitude=53.4069&longitude=58.9797¤t=temperature," - "apparent_temperature,weather_code,wind_speed_10m," - "relative_humidity_2m,pressure_msl&timezone=Asia/Chelyabinsk" - ) - for attempt in range(3): - try: - response = await asyncio.to_thread(requests.get, url, timeout=10) - 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 = self._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 < 2: - delay = 2 ** attempt - print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") - await asyncio.sleep(delay) - continue - break - except requests.RequestException as e: - await ctx.send(f"Ошибка при получении данных: {e}") - return None - - await ctx.send("Не удалось получить данные о погоде. Попробуйте позже.") - return None - - def _wmo_to_russian(self, 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(self, 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 _pressure_to_mmhg(self, mb): - if mb == "—" or not mb: - return "—" - try: - return round(float(mb) * 0.750062, 1) - except (ValueError, TypeError): - return "—" diff --git a/console_commands/pogoda.py b/console_commands/pogoda.py index 3a5ddb9..fa71da9 100644 --- a/console_commands/pogoda.py +++ b/console_commands/pogoda.py @@ -1,12 +1,10 @@ -import requests -import time -from requests.exceptions import ConnectionError, Timeout, SSLError +from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg -def pogoda(stop_event, bot): +async def pogoda(stop_event, bot): """Вывести прогноз погоды для Магнитогорска""" api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" - data = _fetch_weather(api_url) + data = await fetch_weather(api_url) if data is None: print("Не удалось получить данные о погоде.") @@ -19,7 +17,7 @@ def pogoda(stop_event, bot): temp = current.get("temp_C", "—") feels_like = current.get("FeelsLikeC", "—") - description = _translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) + description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) humidity = current.get("humidity", "—") wind_kmh = current.get("windspeedKmph", "—") try: @@ -27,149 +25,10 @@ def pogoda(stop_event, bot): except (ValueError, TypeError): wind = "—" pressure_mb = current.get("pressure", "—") - pressure_mm = _pressure_to_mmhg(pressure_mb) + pressure_mm = pressure_to_mmhg(pressure_mb) print(f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)") print(f"[DESC] Описание: {description}") print(f"[HUMID] Влажность: {humidity}%") print(f"[WIND] Ветер: {wind} м/с") print(f"[PRESS] Давление: {pressure_mm} мм рт. ст.") - - -def _fetch_weather(url): - """Получить данные о погоде с retry и fallback.""" - # Пробуем wttr.in с retry - for attempt in range(3): - try: - response = requests.get(url, timeout=10) - response.raise_for_status() - return response.json() - except (SSLError, ConnectionError, Timeout): - if attempt < 2: - delay = 2 ** attempt - print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") - time.sleep(delay) - continue - break - except requests.RequestException: - return None - - # Fallback: Open-Meteo API (без ключа, HTTPS) - return _fetch_open_meteo() - - -def _fetch_open_meteo(): - """Fallback на Open-Meteo API.""" - url = ( - "https://api.open-meteo.com/v1/forecast?" - "latitude=53.4069&longitude=58.9797¤t=temperature," - "apparent_temperature,weather_code,wind_speed_10m," - "relative_humidity_2m,pressure_msl&timezone=Asia/Chelyabinsk" - ) - for attempt in range(3): - try: - response = requests.get(url, timeout=10) - 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 < 2: - delay = 2 ** attempt - print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") - time.sleep(delay) - continue - break - except requests.RequestException: - return None - - 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 _pressure_to_mmhg(mb): - if mb == "—" or not mb: - return "—" - try: - return round(float(mb) * 0.750062, 1) - except (ValueError, TypeError): - return "—" diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/utils/pogoda.py b/utils/pogoda.py new file mode 100644 index 0000000..46c413a --- /dev/null +++ b/utils/pogoda.py @@ -0,0 +1,143 @@ +import asyncio +import requests +from requests.exceptions import ConnectionError, Timeout, SSLError + + +async def fetch_weather(api_url, timeout=10, max_retries=3): + """Получить данные о погоде с retry.""" + for attempt in range(max_retries): + try: + response = await asyncio.to_thread(requests.get, api_url, timeout=timeout) + response.raise_for_status() + return response.json() + except (SSLError, ConnectionError, Timeout): + if attempt < max_retries - 1: + delay = 2 ** attempt + print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") + await asyncio.sleep(delay) + continue + break + except requests.RequestException as e: + print(f"Ошибка при получении данных: {e}") + return None + + # Fallback: Open-Meteo API (без ключа, HTTPS) + 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.""" + 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(requests.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 + print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...") + await asyncio.sleep(delay) + continue + break + except requests.RequestException as e: + print(f"Ошибка при получении данных: {e}") + return None + + 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 pressure_to_mmhg(mb): + if mb == "—" or not mb: + return "—" + try: + return round(float(mb) * 0.750062, 1) + except (ValueError, TypeError): + return "—"