Compare commits

..

No commits in common. "a277865e33e7fb531ec813d85f098e07eae6f890" and "4b02fe8a35a3f82aa45a4258416e76371fbc9893" have entirely different histories.

16 changed files with 145 additions and 378 deletions

View File

@ -1,13 +1,18 @@
# AGENTS.md # AGENTS.md
## Проект ## Проект
Discord-бот на Python (discord.py) Discord-бот на Python (discord.py) для Магнитогорска.
- Команды Discord: `!msg` (повтор текста), `!pogoda` (прогноз погоды)
- Консольные команды: `stop` (остановка бота)
## Запуск ## Запуск
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
python bot.py python bot.py
``` ```
Введите `stop` в терминал для остановки бота.
## Токен ## Токен
Токен бота хранится в `.env` (переменная `DISCORD_TOKEN`). Получи токен на [Discord Developer Portal](https://discord.com/developers/applications). Шаблон в `.env.example`. Токен бота хранится в `.env` (переменная `DISCORD_TOKEN`). Получи токен на [Discord Developer Portal](https://discord.com/developers/applications). Шаблон в `.env.example`.
@ -20,51 +25,40 @@ python bot.py
- **Все git-коммиты согласовывать с пользователем перед созданием.** - **Все git-коммиты согласовывать с пользователем перед созданием.**
- **Все сообщения git-коммитов писать на русском языке.** - **Все сообщения git-коммитов писать на русском языке.**
## Команды ## Архитектура
```
bot.py # Точка входа, инициализация бота, console_input()
commands/ # Discord команды (cogs)
__init__.py # ALL_COMMANDS — явные импорты
pogoda.py # !pogoda — погода с retry + fallback (wttr.in → Open-Meteo)
console_commands/ # Консольные команды
__init__.py # ALL_CONSOLE_COMMANDS — явные импорты
stop.py # stop — остановка бота
```
| Команда | Описание | Формат вывода | ### Добавление Discord команды
|---------|----------|---------------| 1. Создать файл `commands/имя.py` с классом, наследующим `commands.Cog`
| `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление | 2. Добавить импорт в `commands/__init__.py`
| `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости | 3. Добавить класс в `ALL_COMMANDS`
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик | Embed: котик thumbnail, погода, статьи, новости |
| `!cat` | Случайный котик | Embed с изображением |
| `!msg <текст>` | Повторяет текст | Текст |
## Структура проекта ### Добавление консольной команды
1. Создать файл `console_commands/имя.py` с функцией `func(stop_event, bot)`
| Каталог | Назначение | 2. Добавить импорт в `console_commands/__init__.py`
|---------|------------| 3. Добавить функцию в `ALL_CONSOLE_COMMANDS`
| `commands/` | Discord-команды (Cogs) |
| `utils/` | Утилиты (API-клиенты, конвертации) |
| `console_commands/` | Консольные команды (для терминала) |
| `bot.py` | Точка входа, загрузка Cogs, обработка ошибок |
## API и внешние сервисы ## API и внешние сервисы
### Погода (!pogoda, !morning) ### Погода (!pogoda)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа) - **Основной**: `wttr.in` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
- Fallback срабатывает автоматически при неуспешных попытках - Fallback срабатывает автоматически при неуспешных попытках
- WMO weather codes → русский перевод в `wmo_to_russian()` - WMO weather codes → русский перевод в `_wmo_to_russian()`
### Конвертации ### Конвертации
- Давление: hPa → мм рт. ст. (`* 0.750062`) - Давление: hPa → мм рт. ст. (`* 0.750062`)
- Ветер: км/ч → м/с (`/ 3.6`) - Ветер: км/ч → м/с (`/ 3.6`)
- Погодные описания: английский → русский (`translate_weather()`) - Погодные описания: английский → русский (`_translate_weather()`)
### RSS-ленты Habr (!news, !morning)
| Тип | URL |
|-----|-----|
| Статьи | `https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru` |
| Новости | `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru` |
Парсятся RSS 2.0 / Atom, извлекаются title, pubDate, creator, category, guid.
### Cat API (!cat, !morning)
- **URL**: `https://api.thecatapi.com/v1/images/search`
- Возвращает JSON: `[{"url": "..."}]`
- Используется для случайных изображений котиков
## Конфигурация ## Конфигурация
| Переменная | Описание | Где взять | | Переменная | Описание | Где взять |
@ -81,24 +75,13 @@ requests>=2.31.0
## Структура данных погоды ## Структура данных погоды
Команда `!pogoda` возвращает: Команда `!pogoda` возвращает:
``` ```
Температура: X°C (ощущается как Y°C) [TEMP] Температура: X°C (ощущается как Y°C)
Описание: Z [DESC] Описание: Z
Влажность: X% [HUMID] Влажность: X%
Ветер: X м/с [WIND] Ветер: X м/с
Давление: X мм рт. ст. [PRESS] Давление: X мм рт. ст.
``` ```
## Консольные команды
Запускаются через терминал при старте бота. Список выводится при запуске.
| Команда | Описание |
|---------|----------|
| `pogoda` | Вывести погоду в Магнитогорск |
| `news` | Вывести топ-5 статей AI с Habr |
| `morning` | Вывести погоду, топ-5 статей и котик |
| `cat` | Заглушка |
| `stop` | Остановить бота |
## Безопасность ## Безопасность
- `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий. - `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий.
- Используй `.env.example` как шаблон. - Используй `.env.example` как шаблон.

View File

@ -1,4 +1,4 @@
# Tasks — список задач проекта # Issues — список проблем проекта
> Состояние на: 2026-05-26 > Состояние на: 2026-05-26
@ -6,41 +6,75 @@
## 🔴 Критические ## 🔴 Критические
### 1. Убрать эмодзи из `console_commands/cat.py` ### 1. Retry без задержки между попытками ✅ РЕШЕНО
- [x] Заменить `print("🐱 тут должен быть котик")` на `print("Заглушка: тут должен быть котик")` - **Где:** `commands/pogoda.py`, `console_commands/pogoda.py`
- **Проблема:** Цикл `for attempt in range(3)` не содержит `time.sleep()`. Все 3 попытки выполняются мгновенно, что бессмысленно при временных сетевых сбоях.
- **AGENTS.md обещает:** "Retry: 3 попытки с экспоненциальной задержкой" — но задержки нет.
- **Решение:** Добавить `time.sleep(2 ** attempt)` внутри цикла retry.
- **Статус:** Исправлено. Добавлена экспоненциальная задержка: 2 сек, 4 сек между попытками.
### 2. Блокировка event loop в async-методе ✅ РЕШЕНО
- **Где:** `commands/news.py`, `commands/cat.py`, `commands/pogoda.py`
- **Проблема:** Асинхронные методы вызывали синхронный `requests.get()`, блокируя весь event loop.
- **Решение:** Обернуть `requests.get()` в `await asyncio.to_thread()` и заменить `time.sleep()` на `await asyncio.sleep()`.
- **Статус:** Исправлено. В `news.py`, `cat.py`, `pogoda.py` все HTTP-запросы выполняются в отдельных потоках, а ожидания — неблокирующие.
--- ---
## 🟡 Средние ## 🟡 Средние
### 2. Убрать пустой embed в `!morning` при всех ошибках ### 3. Дублирование кода между Discord и console командами ✅ РЕШЕНО
- [ ] `commands/morning.py`: добавить fallback-сообщение в `description_lines`, если все API вернули `None` - **Где:** `console_commands/pogoda.py`, `console_commands/news.py` (и их аналоги в `commands/`)
- [ ] Проверить, что `embed.description` никогда не пустой - **Проблема:** Логика погоды и новостей полностью продублирована. Изменения нужно вносить в два места.
- **Решение:** Вынести общую логику в `utils/` (`utils/pogoda.py`, `utils/news.py`) и использовать её из обоих мест.
- **Статус:** Исправлено. Созданы `utils/pogoda.py` и `utils/news.py`. Оба файла используют единые функции без дублирования.
### 3. Убрать дублирование парсинга погоды ### 4. `from datetime import datetime` внутри метода
- [ ] `console_commands/pogoda.py`: вынести форматирование результата в `utils/pogoda.py` как `format_weather_data()` - **Где:** `commands/news.py``_format_and_send()`, строки 56 и 72
- [ ] `console_commands/morning.py`: использовать `format_weather_data()` вместо inline-парсинга - **Проблема:** Импорт внутри метода — антипаттерн. Замедляет выполнение, нарушает PEP 8.
- [ ] `commands/morning.py`: использовать `format_weather_data()` вместо inline-парсинга - **Решение:** Перенести `from datetime import datetime` в начало файла.
### 6. Вынести `self.api_url` в константу класса ### 5. Консольная команда `cat` — заглушка
- [ ] `commands/pogoda.py`: заменить `self.api_url = ...` на `API_URL = "..."` на уровне класса - **Где:** `console_commands/cat.py`
- [ ] `commands/morning.py`: аналогично - **Проблема:** Функция просто печатает `"🐱 тут должен быть котик"`. Заглушка видна пользователю.
- [ ] `console_commands/pogoda.py`: вынести URL в `utils/pogoda.py` как константу - **Решение:** Либо реализовать (вызвать API котиков), либо убрать из `ALL_CONSOLE_COMMANDS` и `README.md`.
### 6. `on_command_error` не сообщает пользователю об ошибке
- **Где:** `bot.py``on_command_error()`
- **Проблема:** Ошибки команд просто печатаются в `stdout`. Пользователь в чате не видит, что команда выполнилась с ошибкой.
- **Решение:** Добавить `await ctx.send("Произошла ошибка при выполнении команды.")` или отправить embed с деталями (если `ctx` не None).
### 7. Нет `.gitignore` ✅ РЕШЕНО
- **Где:** проект
- **Проблема:** Отсутствовал `.gitignore`, `.env` мог случайно попасть в репозиторий.
- **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`), логов, временных файлов, кэша ОС и IDE.
- **Статус:** Исправлено. `.gitignore` создан и обновлён.
### 8. `requests` без Session — нет переиспользования соединений
- **Где:** все файлы (`pogoda.py`, `news.py`)
- **Проблема:** Каждый `requests.get()` создаёт новое TCP-соединение. Для частых запросов это расточительно.
- **Решение:** Создать `requests.Session()` и использовать `session.get()`.
--- ---
## 🟢 Малые улучшения ## 🟢 Малые улучшения
### 4. Вынести `api_url` в константу ### 9. Linear search в `_translate_weather`
- [ ] `commands/pogoda.py`: заменить `self.api_url = ...` на `API_URL = "..."` на уровне класса - **Где:** `commands/pogoda.py`, `console_commands/pogoda.py`
- [ ] `commands/morning.py`: аналогично - **Проблема:** Метод `_translate_weather()` перебирает весь словарь `mapping` циклом `for key, value in mapping.items()`. При большом словаре — неэффективно.
- [ ] `console_commands/pogoda.py`: вынести URL в `utils/pogoda.py` как константу - **Решение:** Сортировать ключи по длине (убывание) и использовать `re` или `any(key.lower() in text.lower() for key in sorted_keys)`.
### 5. Добавить `__all__` в `__init__.py` ### 10. Нет `__all__` в `__init__.py`
- [ ] `commands/__init__.py`: `__all__ = ["ALL_COMMANDS"]` - **Где:** `commands/__init__.py`, `console_commands/__init__.py`
- [ ] `console_commands/__init__.py`: `__all__ = ["ALL_CONSOLE_COMMANDS"]` - **Проблема:** Нет явного экспорта публичного API модулей.
- [ ] `utils/__init__.py`: добавить `__all__` с основными функциями - **Решение:** Добавить `__all__ = ["ALL_COMMANDS"]` / `__all__ = ["ALL_CONSOLE_COMMANDS"]`.
### 6. Добавить `raise_exception=True` в `bot.run()` ### 11. Жёстко заданные URL в разных файлах
- [ ] `bot.py`: добавить `raise_exception=True` для видимого падения при ошибках токена/сети - **Где:** `commands/news.py` и `console_commands/news.py`
- [ ] Добавить логирование в `KeyboardInterrupt` блок - **Проблема:** Константы `RSS_URL_ARTICLES` и `RSS_URL_POSTS` продублированы.
- **Решение:** Вынести в `config.py` и импортировать оттуда.
### 12. Потенциальная гонка при остановке бота
- **Где:** `bot.py`
- **Проблема:** `stop_event.set()` и `bot.close()` вызываются из `console_input()`, `KeyboardInterrupt` и `console_commands/stop.py`. Возможна гонка.
- **Решение:** Добавить флаг `is_stopping` или использовать `asyncio.Lock`.

View File

@ -1,4 +1,4 @@
# Discord Bot # Discord Bot — Магнитогорск
Discord-бот для Магнитогорска. Команды погоды, новостей и развлечений. Discord-бот для Магнитогорска. Команды погоды, новостей и развлечений.

35
bot.py
View File

@ -1,6 +1,5 @@
import asyncio import asyncio
import inspect import inspect
import logging
import os import os
import sys import sys
import threading import threading
@ -13,8 +12,6 @@ from dotenv import load_dotenv
from commands import ALL_COMMANDS from commands import ALL_COMMANDS
from console_commands import ALL_CONSOLE_COMMANDS from console_commands import ALL_CONSOLE_COMMANDS
logger = logging.getLogger(__name__)
load_dotenv() load_dotenv()
intents = discord.Intents.default() intents = discord.Intents.default()
@ -40,21 +37,7 @@ async def on_ready():
async def on_command_error(ctx, error): async def on_command_error(ctx, error):
if isinstance(error, CommandNotFound): if isinstance(error, CommandNotFound):
return return
print(f"Ошибка команды: {error}")
# Терминал — детали для разработчика
logger.error(
f"Ошибка команды {ctx.command.name if ctx else '?'}: {error}",
exc_info=True,
)
# Discord — только если команда не ответила сама
if ctx and ctx.interaction and ctx.interaction.response.is_done():
return
try:
await ctx.send("Не удалось выполнить команду. Попробуйте позже.")
except (discord.NotFound, discord.Forbidden):
pass # Бот не может писать в канал — игнорируем
@bot.command(name="msg") @bot.command(name="msg")
@ -63,29 +46,22 @@ async def msg(ctx, *, text: str):
await ctx.send(text) await ctx.send(text)
def _print_commands(): def console_input():
"""Вывести список доступных консольных команд.""" bot_ready.wait()
print("Доступные команды:")
available = {k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop"} available = {k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop"}
print("\nДоступные команды:")
for idx, (name, func) in enumerate(available.items(), 1): for idx, (name, func) in enumerate(available.items(), 1):
print(f" {idx}. {name}") print(f" {idx}. {name}")
print(" 0. stop") print(" 0. stop")
def console_input():
bot_ready.wait()
_print_commands()
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
choice = input("\nВыберите команду (номер): ").strip() choice = input("\nВыберите команду (номер): ").strip()
if choice == "0": if choice == "0":
print("\nОстановка бота...") print("\nОстановка бота...")
stop_event.set() stop_event.set()
asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result(timeout=5)
break break
try: try:
available = {k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop"}
idx = int(choice) idx = int(choice)
if 0 < idx <= len(available): if 0 < idx <= len(available):
cmd_name = list(available.keys())[idx - 1] cmd_name = list(available.keys())[idx - 1]
@ -97,8 +73,7 @@ def console_input():
else: else:
print(f"Неизвестная команда: {choice}") print(f"Неизвестная команда: {choice}")
except (ValueError, IndexError): except (ValueError, IndexError):
print(f"Неверный формат: {choice}") print(f"Неизвестная команда: {choice}")
_print_commands()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
stop_event.set() stop_event.set()
try: try:

View File

@ -1,6 +1,5 @@
from .pogoda import Pogoda from .pogoda import Pogoda
from .news import News from .news import News
from .cat import Cat from .cat import Cat
from .morning import Morning
ALL_COMMANDS = [Pogoda, News, Cat, Morning] ALL_COMMANDS = [Pogoda, News, Cat]

View File

@ -1,7 +1,7 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
import asyncio
from utils.cat import fetch_cat import requests
class Cat(commands.Cog): class Cat(commands.Cog):
@ -10,14 +10,21 @@ class Cat(commands.Cog):
@commands.command(name="cat") @commands.command(name="cat")
async def cat(self, ctx): async def cat(self, ctx):
"""Получить случайного котика""" """Получить случайного котика"""
url = await fetch_cat() try:
if url is None: response = await asyncio.to_thread(
await ctx.send("Не удалось получить котика. Попробуйте позже.") requests.get,
return "https://api.thecatapi.com/v1/images/search",
timeout=10
)
response.raise_for_status()
data = response.json()
url = data[0]["url"]
embed = discord.Embed( embed = discord.Embed(
title="Котик для тебя!", title="🐱 Котик для тебя!",
color=discord.Color.orange() color=discord.Color.orange()
) )
embed.set_image(url=url) embed.set_image(url=url)
await ctx.send(embed=embed) await ctx.send(embed=embed)
except requests.RequestException:
await ctx.send("Не удалось получить котика. Попробуйте позже.")

View File

@ -1,95 +0,0 @@
import asyncio
import discord
from discord.ext import commands
from utils.pogoda import fetch_weather, pressure_to_mmhg, translate_weather
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
from utils.cat import fetch_cat
class Morning(commands.Cog):
"""Команда !morning — погода и новости утром"""
def __init__(self):
self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
@commands.command(name="morning")
async def morning(self, ctx):
"""Погода, лучшие статьи за сутки и котик"""
# Параллельный запрос погоды, новостей и котика
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(self.api_url),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),
)
# --- Формируем embed ---
embed = discord.Embed(title="Доброе утро!", color=0xF4A460)
# Котик как thumbnail (маленький в углу)
if cat_url:
embed.set_thumbnail(url=cat_url)
description_lines = []
# --- Погода ---
if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0]
if current:
temp = current.get("temp_C", "\u2014")
feels_like = current.get("FeelsLikeC", "\u2014")
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "\u2014"))
humidity = current.get("humidity", "\u2014")
wind_kmh = current.get("windspeedKmph", "\u2014")
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "\u2014" else "\u2014"
except (ValueError, TypeError):
wind = "\u2014"
pressure_mb = current.get("pressure", "\u2014")
pressure_mm = pressure_to_mmhg(pressure_mb)
description_lines.append(
f"**Погода в Магнитогорске:**\n"
f"Температура: {temp}\u00b0C (ощущается как {feels_like}\u00b0C)\n"
f"Описание: {description}\n"
f"Влажность: {humidity}%\n"
f"Ветер: {wind} м/с\n"
f"Давление: {pressure_mm} мм рт. ст."
)
else:
description_lines.append("Не удалось получить данные о погоде.")
else:
description_lines.append("Не удалось получить данные о погоде.")
description_lines.append("") # пустая строка-разделитель
# --- Новости: статьи ---
if articles is not None:
if articles:
lines = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
description_lines.append("") # пустая строка-разделитель
# --- Новости: посты ---
if posts is not None:
if posts:
lines = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
embed.description = "\n".join(description_lines)
await ctx.send(embed=embed)

View File

@ -8,7 +8,7 @@ class News(commands.Cog):
@commands.command(name="news") @commands.command(name="news")
async def news(self, ctx): async def news(self, ctx):
"""Топ-5 свежих статей и новостей по AI с Habr""" """Топ-5 свежих статей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES) articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None: if articles is None:
await ctx.send("Не удалось получить новости. Попробуйте позже.") await ctx.send("Не удалось получить новости. Попробуйте позже.")
@ -18,43 +18,14 @@ class News(commands.Cog):
await ctx.send("Новостей пока нет.") await ctx.send("Новостей пока нет.")
return return
articles_text = format_articles(articles, lines = format_articles(articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"Лучшие статьи за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
posts = await fetch_rss(RSS_URL_POSTS) posts = await fetch_rss(RSS_URL_POSTS)
if posts:
lines.append("")
lines.extend(format_articles(posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/"))
embed = discord.Embed( message = "\n".join(lines).rstrip()
title="Новости AI с Habr", await ctx.send(message, allowed_mentions=discord.AllowedMentions.none())
colour=discord.Color.orange(),
)
embed.add_field(
name="Статьи",
value="\n".join(articles_text),
inline=False,
)
if posts is None:
embed.add_field(
name="Новости",
value="Не удалось получить новости.",
inline=False,
)
elif posts:
posts_text = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
embed.add_field(
name="Новости",
value="\n".join(posts_text),
inline=False,
)
else:
embed.add_field(
name="Новости",
value="Новостей пока нет.",
inline=False,
)
await ctx.send(embed=embed)

View File

@ -34,9 +34,9 @@ class Pogoda(commands.Cog):
pressure_mm = pressure_to_mmhg(pressure_mb) pressure_mm = pressure_to_mmhg(pressure_mb)
await ctx.send( await ctx.send(
f"Температура: {temp}°C (ощущается как {feels_like}°C)\n" f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)\n"
f"Описание: {description}\n" f"[DESC] Описание: {description}\n"
f"Влажность: {humidity}%\n" f"[HUMID] Влажность: {humidity}%\n"
f"Ветер: {wind} м/с\n" f"[WIND] Ветер: {wind} м/с\n"
f"Давление: {pressure_mm} мм рт. ст." f"[PRESS] Давление: {pressure_mm} мм рт. ст."
) )

View File

@ -2,12 +2,10 @@ from .stop import stop
from .news import news from .news import news
from .cat import cat from .cat import cat
from .pogoda import pogoda from .pogoda import pogoda
from .morning import morning
ALL_CONSOLE_COMMANDS = { ALL_CONSOLE_COMMANDS = {
"stop": stop, "stop": stop,
"news": news, "news": news,
"cat": cat, "cat": cat,
"pogoda": pogoda, "pogoda": pogoda,
"morning": morning,
} }

View File

@ -1,3 +1,3 @@
def cat(stop_event, bot): def cat(stop_event, bot):
"""Заглушка: тут должен быть котик""" """Заглушка: тут должен быть котик"""
print("Заглушка: тут должен быть котик") print("🐱 тут должен быть котик")

View File

@ -1,81 +0,0 @@
import asyncio
from utils.pogoda import fetch_weather, pressure_to_mmhg, translate_weather
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
from utils.cat import fetch_cat
async def morning(stop_event, bot):
"""Вывести погоду, лучшие статьи за сутки и котика"""
api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
# Параллельный запрос погоды, новостей и котика
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(api_url),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),
)
print("Доброе утро!\n")
# --- Котик ---
if cat_url:
print(f"Котик: {cat_url}\n")
else:
print("Котика получить не удалось.\n")
# --- Погода ---
if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0]
if current:
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)
print(f"**Погода в Магнитогорске:**")
print(f"Температура: {temp}°C (ощущается как {feels_like}°C)")
print(f"Описание: {description}")
print(f"Влажность: {humidity}%")
print(f"Ветер: {wind} м/с")
print(f"Давление: {pressure_mm} мм рт. ст.")
else:
print("Не удалось получить данные о погоде.")
else:
print("Не удалось получить данные о погоде.")
print()
# --- Новости: статьи ---
if articles is not None:
if articles:
lines = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
print("\n".join(lines))
else:
print("Новостей пока нет.")
else:
print("Не удалось получить новости.")
print()
# --- Новости: посты ---
if posts is not None:
if posts:
lines = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
print("\n".join(lines))
else:
print("Новостей пока нет.")
else:
print("Не удалось получить новости.")

View File

@ -27,8 +27,8 @@ async def pogoda(stop_event, bot):
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}°C (ощущается как {feels_like}°C)") print(f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)")
print(f"Описание: {description}") print(f"[DESC] Описание: {description}")
print(f"Влажность: {humidity}%") print(f"[HUMID] Влажность: {humidity}%")
print(f"Ветер: {wind} м/с") print(f"[WIND] Ветер: {wind} м/с")
print(f"Давление: {pressure_mm} мм рт. ст.") print(f"[PRESS] Давление: {pressure_mm} мм рт. ст.")

View File

@ -1,18 +0,0 @@
import asyncio
import requests
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
_session = requests.Session()
async def fetch_cat() -> str | None:
"""Получить URL случайного котика. Вернуть None при ошибке."""
try:
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
response.raise_for_status()
data = response.json()
return data[0]["url"]
except requests.exceptions.RequestException:
return None

View File

@ -1,20 +1,15 @@
import asyncio
from datetime import datetime
import requests
RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru" RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru"
RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru" RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
_session = requests.Session()
async def fetch_rss(url): async def fetch_rss(url):
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom).""" """Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
import asyncio
import requests
from xml.etree import ElementTree from xml.etree import ElementTree
try: try:
response = await asyncio.to_thread(_session.get, url, timeout=10) response = await asyncio.to_thread(requests.get, url, timeout=10)
response.raise_for_status() response.raise_for_status()
root = ElementTree.fromstring(response.content) root = ElementTree.fromstring(response.content)
@ -63,12 +58,13 @@ async def fetch_rss(url):
"tags": tags, "tags": tags,
}) })
return articles[:10] return articles[:10]
except requests.exceptions.RequestException: except requests.RequestException:
return None return None
def _parse_date(pub_date): def _parse_date(pub_date):
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты.""" """Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
from datetime import datetime
if not pub_date: if not pub_date:
return "" return ""
try: try:

View File

@ -2,14 +2,12 @@ import asyncio
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
_session = requests.Session()
async def fetch_weather(api_url, timeout=10, max_retries=3): async def fetch_weather(api_url, timeout=10, max_retries=3):
"""Получить данные о погоде с retry.""" """Получить данные о погоде с retry."""
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(requests.get, api_url, timeout=timeout)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except (SSLError, ConnectionError, Timeout): except (SSLError, ConnectionError, Timeout):
@ -37,7 +35,7 @@ async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
) )
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
response = await asyncio.to_thread(_session.get, url, timeout=timeout) response = await asyncio.to_thread(requests.get, url, timeout=timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
current = data.get("current", {}) current = data.get("current", {})