Compare commits

...

8 Commits

16 changed files with 378 additions and 145 deletions

View File

@ -1,18 +1,13 @@
# 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`.
@ -25,40 +20,51 @@ 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` |---------|----------|---------------|
2. Добавить импорт в `commands/__init__.py` | `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление |
3. Добавить класс в `ALL_COMMANDS` | `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости |
| `!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) ### Погода (!pogoda, !morning)
- **Основной**: `wttr.in` (бесплатный, без ключа) - **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **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": "..."}]`
- Используется для случайных изображений котиков
## Конфигурация ## Конфигурация
| Переменная | Описание | Где взять | | Переменная | Описание | Где взять |
@ -75,13 +81,24 @@ requests>=2.31.0
## Структура данных погоды ## Структура данных погоды
Команда `!pogoda` возвращает: Команда `!pogoda` возвращает:
``` ```
[TEMP] Температура: X°C (ощущается как Y°C) Температура: X°C (ощущается как Y°C)
[DESC] Описание: Z Описание: Z
[HUMID] Влажность: X% Влажность: X%
[WIND] Ветер: X м/с Ветер: X м/с
[PRESS] Давление: X мм рт. ст. Давление: X мм рт. ст.
``` ```
## Консольные команды
Запускаются через терминал при старте бота. Список выводится при запуске.
| Команда | Описание |
|---------|----------|
| `pogoda` | Вывести погоду в Магнитогорск |
| `news` | Вывести топ-5 статей AI с Habr |
| `morning` | Вывести погоду, топ-5 статей и котик |
| `cat` | Заглушка |
| `stop` | Остановить бота |
## Безопасность ## Безопасность
- `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий. - `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий.
- Используй `.env.example` как шаблон. - Используй `.env.example` как шаблон.

View File

@ -1,4 +1,4 @@
# Issues — список проблем проекта # Tasks — список задач проекта
> Состояние на: 2026-05-26 > Состояние на: 2026-05-26
@ -6,75 +6,41 @@
## 🔴 Критические ## 🔴 Критические
### 1. Retry без задержки между попытками ✅ РЕШЕНО ### 1. Убрать эмодзи из `console_commands/cat.py`
- **Где:** `commands/pogoda.py`, `console_commands/pogoda.py` - [x] Заменить `print("🐱 тут должен быть котик")` на `print("Заглушка: тут должен быть котик")`
- **Проблема:** Цикл `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-запросы выполняются в отдельных потоках, а ожидания — неблокирующие.
--- ---
## 🟡 Средние ## 🟡 Средние
### 3. Дублирование кода между Discord и console командами ✅ РЕШЕНО ### 2. Убрать пустой embed в `!morning` при всех ошибках
- **Где:** `console_commands/pogoda.py`, `console_commands/news.py` (и их аналоги в `commands/`) - [ ] `commands/morning.py`: добавить fallback-сообщение в `description_lines`, если все API вернули `None`
- **Проблема:** Логика погоды и новостей полностью продублирована. Изменения нужно вносить в два места. - [ ] Проверить, что `embed.description` никогда не пустой
- **Решение:** Вынести общую логику в `utils/` (`utils/pogoda.py`, `utils/news.py`) и использовать её из обоих мест.
- **Статус:** Исправлено. Созданы `utils/pogoda.py` и `utils/news.py`. Оба файла используют единые функции без дублирования.
### 4. `from datetime import datetime` внутри метода ### 3. Убрать дублирование парсинга погоды
- **Где:** `commands/news.py``_format_and_send()`, строки 56 и 72 - [ ] `console_commands/pogoda.py`: вынести форматирование результата в `utils/pogoda.py` как `format_weather_data()`
- **Проблема:** Импорт внутри метода — антипаттерн. Замедляет выполнение, нарушает PEP 8. - [ ] `console_commands/morning.py`: использовать `format_weather_data()` вместо inline-парсинга
- **Решение:** Перенести `from datetime import datetime` в начало файла. - [ ] `commands/morning.py`: использовать `format_weather_data()` вместо inline-парсинга
### 5. Консольная команда `cat` — заглушка ### 6. Вынести `self.api_url` в константу класса
- **Где:** `console_commands/cat.py` - [ ] `commands/pogoda.py`: заменить `self.api_url = ...` на `API_URL = "..."` на уровне класса
- **Проблема:** Функция просто печатает `"🐱 тут должен быть котик"`. Заглушка видна пользователю. - [ ] `commands/morning.py`: аналогично
- **Решение:** Либо реализовать (вызвать API котиков), либо убрать из `ALL_CONSOLE_COMMANDS` и `README.md`. - [ ] `console_commands/pogoda.py`: вынести URL в `utils/pogoda.py` как константу
### 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()`.
--- ---
## 🟢 Малые улучшения ## 🟢 Малые улучшения
### 9. Linear search в `_translate_weather` ### 4. Вынести `api_url` в константу
- **Где:** `commands/pogoda.py`, `console_commands/pogoda.py` - [ ] `commands/pogoda.py`: заменить `self.api_url = ...` на `API_URL = "..."` на уровне класса
- **Проблема:** Метод `_translate_weather()` перебирает весь словарь `mapping` циклом `for key, value in mapping.items()`. При большом словаре — неэффективно. - [ ] `commands/morning.py`: аналогично
- **Решение:** Сортировать ключи по длине (убывание) и использовать `re` или `any(key.lower() in text.lower() for key in sorted_keys)`. - [ ] `console_commands/pogoda.py`: вынести URL в `utils/pogoda.py` как константу
### 10. Нет `__all__` в `__init__.py` ### 5. Добавить `__all__` в `__init__.py`
- **Где:** `commands/__init__.py`, `console_commands/__init__.py` - [ ] `commands/__init__.py`: `__all__ = ["ALL_COMMANDS"]`
- **Проблема:** Нет явного экспорта публичного API модулей. - [ ] `console_commands/__init__.py`: `__all__ = ["ALL_CONSOLE_COMMANDS"]`
- **Решение:** Добавить `__all__ = ["ALL_COMMANDS"]` / `__all__ = ["ALL_CONSOLE_COMMANDS"]`. - [ ] `utils/__init__.py`: добавить `__all__` с основными функциями
### 11. Жёстко заданные URL в разных файлах ### 6. Добавить `raise_exception=True` в `bot.run()`
- **Где:** `commands/news.py` и `console_commands/news.py` - [ ] `bot.py`: добавить `raise_exception=True` для видимого падения при ошибках токена/сети
- **Проблема:** Константы `RSS_URL_ARTICLES` и `RSS_URL_POSTS` продублированы. - [ ] Добавить логирование в `KeyboardInterrupt` блок
- **Решение:** Вынести в `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,5 +1,6 @@
import asyncio import asyncio
import inspect import inspect
import logging
import os import os
import sys import sys
import threading import threading
@ -12,6 +13,8 @@ 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()
@ -37,7 +40,21 @@ 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")
@ -46,22 +63,29 @@ async def msg(ctx, *, text: str):
await ctx.send(text) await ctx.send(text)
def console_input(): def _print_commands():
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]
@ -73,7 +97,8 @@ 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,5 +1,6 @@
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] ALL_COMMANDS = [Pogoda, News, Cat, Morning]

View File

@ -1,7 +1,7 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
import asyncio
import requests from utils.cat import fetch_cat
class Cat(commands.Cog): class Cat(commands.Cog):
@ -10,21 +10,14 @@ class Cat(commands.Cog):
@commands.command(name="cat") @commands.command(name="cat")
async def cat(self, ctx): async def cat(self, ctx):
"""Получить случайного котика""" """Получить случайного котика"""
try: url = await fetch_cat()
response = await asyncio.to_thread( if url is None:
requests.get, await ctx.send("Не удалось получить котика. Попробуйте позже.")
"https://api.thecatapi.com/v1/images/search", return
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("Не удалось получить котика. Попробуйте позже.")

95
commands/morning.py Normal file
View File

@ -0,0 +1,95 @@
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,14 +18,43 @@ class News(commands.Cog):
await ctx.send("Новостей пока нет.") await ctx.send("Новостей пока нет.")
return return
lines = format_articles(articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr", articles_text = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хаб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/"))
message = "\n".join(lines).rstrip() embed = discord.Embed(
await ctx.send(message, allowed_mentions=discord.AllowedMentions.none()) title="Новости AI с Habr",
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] Температура: {temp}°C (ощущается как {feels_like}°C)\n" f"Температура: {temp}°C (ощущается как {feels_like}°C)\n"
f"[DESC] Описание: {description}\n" f"Описание: {description}\n"
f"[HUMID] Влажность: {humidity}%\n" f"Влажность: {humidity}%\n"
f"[WIND] Ветер: {wind} м/с\n" f"Ветер: {wind} м/с\n"
f"[PRESS] Давление: {pressure_mm} мм рт. ст." f"Давление: {pressure_mm} мм рт. ст."
) )

View File

@ -2,10 +2,12 @@ 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

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

18
utils/cat.py Normal file
View File

@ -0,0 +1,18 @@
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,15 +1,20 @@
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(requests.get, url, timeout=10) response = await asyncio.to_thread(_session.get, url, timeout=10)
response.raise_for_status() response.raise_for_status()
root = ElementTree.fromstring(response.content) root = ElementTree.fromstring(response.content)
@ -58,13 +63,12 @@ async def fetch_rss(url):
"tags": tags, "tags": tags,
}) })
return articles[:10] return articles[:10]
except requests.RequestException: except requests.exceptions.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,12 +2,14 @@ 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(requests.get, api_url, timeout=timeout) response = await asyncio.to_thread(_session.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):
@ -35,7 +37,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(requests.get, url, timeout=timeout) 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", {}) current = data.get("current", {})