refactor: вынести логику погоды в utils/pogoda.py (проблема 3)

- Создать utils/pogoda.py с общими функциями:
  - fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg
- Обновить commands/pogoda.py: убрать дубликаты, импортировать из utils
- Обновить console_commands/pogoda.py: убрать дубликаты, импортировать из utils
- Сделать console_commands/pogoda.py async (требует fetch_weather)
- Обновить AGENTS.md и ISSUES.md (проблема 7 решена)
This commit is contained in:
deadzilla 2026-05-26 12:32:15 +05:00
parent 7a8db07862
commit 04b0a9b82f
6 changed files with 159 additions and 293 deletions

View File

@ -21,6 +21,7 @@ python bot.py
- Пиши комментарии на русском. - Пиши комментарии на русском.
- Обработка ошибок: try/except для всех внешних вызовов (API, БД, файловая система). - Обработка ошибок: try/except для всех внешних вызовов (API, БД, файловая система).
- **Никогда не используй эмодзи в тексте или выводах.** - **Никогда не используй эмодзи в тексте или выводах.**
- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.**
## Архитектура ## Архитектура
``` ```

View File

@ -43,10 +43,11 @@
- **Проблема:** Ошибки команд просто печатаются в `stdout`. Пользователь в чате не видит, что команда выполнилась с ошибкой. - **Проблема:** Ошибки команд просто печатаются в `stdout`. Пользователь в чате не видит, что команда выполнилась с ошибкой.
- **Решение:** Добавить `await ctx.send("Произошла ошибка при выполнении команды.")` или отправить embed с деталями (если `ctx` не None). - **Решение:** Добавить `await ctx.send("Произошла ошибка при выполнении команды.")` или отправить embed с деталями (если `ctx` не None).
### 7. Нет `.gitignore` ### 7. Нет `.gitignore` ✅ РЕШЕНО
- **Где:** проект (файл отсутствует) - **Где:** проект
- **Проблема:** Нет явного `.gitignore`. `.env` может случайно попасть в репозиторий, хотя AGENTS.md говорит "`.env` в `.gitignore`". - **Проблема:** Отсутствовал `.gitignore`, `.env` мог случайно попасть в репозиторий.
- **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`). - **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`), логов, временных файлов, кэша ОС и IDE.
- **Статус:** Исправлено. `.gitignore` создан и обновлён.
### 8. `requests` без Session — нет переиспользования соединений ### 8. `requests` без Session — нет переиспользования соединений
- **Где:** все файлы (`pogoda.py`, `news.py`) - **Где:** все файлы (`pogoda.py`, `news.py`)

View File

@ -1,8 +1,6 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
import requests from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg
import asyncio
from requests.exceptions import ConnectionError, Timeout, SSLError
class Pogoda(commands.Cog): class Pogoda(commands.Cog):
@ -13,7 +11,7 @@ class Pogoda(commands.Cog):
@commands.command(name="pogoda") @commands.command(name="pogoda")
async def pogoda(self, ctx): async def pogoda(self, ctx):
data = await self._fetch_weather(ctx) data = await fetch_weather(self.api_url)
if data is None: if data is None:
return return
@ -24,7 +22,7 @@ class Pogoda(commands.Cog):
temp = current.get("temp_C", "") temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "") 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", "") humidity = current.get("humidity", "")
wind_kmh = current.get("windspeedKmph", "") wind_kmh = current.get("windspeedKmph", "")
try: try:
@ -33,7 +31,7 @@ class Pogoda(commands.Cog):
wind = "" wind = ""
pressure_mb = current.get("pressure", "") pressure_mb = current.get("pressure", "")
pressure_mm = self._pressure_to_mmhg(pressure_mb) pressure_mm = pressure_to_mmhg(pressure_mb)
await ctx.send( await ctx.send(
f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)\n" f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)\n"
@ -42,140 +40,3 @@ class Pogoda(commands.Cog):
f"[WIND] Ветер: {wind} м/с\n" f"[WIND] Ветер: {wind} м/с\n"
f"[PRESS] Давление: {pressure_mm} мм рт. ст." 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&current=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 ""

View File

@ -1,12 +1,10 @@
import requests from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg
import time
from requests.exceptions import ConnectionError, Timeout, SSLError
def pogoda(stop_event, bot): async def pogoda(stop_event, bot):
"""Вывести прогноз погоды для Магнитогорска""" """Вывести прогноз погоды для Магнитогорска"""
api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" 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: if data is None:
print("Не удалось получить данные о погоде.") print("Не удалось получить данные о погоде.")
@ -19,7 +17,7 @@ def pogoda(stop_event, bot):
temp = current.get("temp_C", "") temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "") 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", "") humidity = current.get("humidity", "")
wind_kmh = current.get("windspeedKmph", "") wind_kmh = current.get("windspeedKmph", "")
try: try:
@ -27,149 +25,10 @@ def pogoda(stop_event, bot):
except (ValueError, TypeError): except (ValueError, TypeError):
wind = "" wind = ""
pressure_mb = current.get("pressure", "") 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"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)")
print(f"[DESC] Описание: {description}") print(f"[DESC] Описание: {description}")
print(f"[HUMID] Влажность: {humidity}%") print(f"[HUMID] Влажность: {humidity}%")
print(f"[WIND] Ветер: {wind} м/с") print(f"[WIND] Ветер: {wind} м/с")
print(f"[PRESS] Давление: {pressure_mm} мм рт. ст.") 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&current=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 ""

1
utils/__init__.py Normal file
View File

@ -0,0 +1 @@

143
utils/pogoda.py Normal file
View File

@ -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}&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(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 ""