Compare commits

...

8 Commits

16 changed files with 378 additions and 145 deletions

View File

@ -1,18 +1,13 @@
# AGENTS.md
## Проект
Discord-бот на Python (discord.py) для Магнитогорска.
- Команды Discord: `!msg` (повтор текста), `!pogoda` (прогноз погоды)
- Консольные команды: `stop` (остановка бота)
Discord-бот на Python (discord.py)
## Запуск
```bash
pip install -r requirements.txt
python bot.py
```
Введите `stop` в терминал для остановки бота.
## Токен
Токен бота хранится в `.env` (переменная `DISCORD_TOKEN`). Получи токен на [Discord Developer Portal](https://discord.com/developers/applications). Шаблон в `.env.example`.
@ -25,40 +20,51 @@ python bot.py
- **Все 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`
3. Добавить класс в `ALL_COMMANDS`
| Команда | Описание | Формат вывода |
|---------|----------|---------------|
| `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление |
| `!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 и внешние сервисы
### Погода (!pogoda)
- **Основной**: `wttr.in` (бесплатный, без ключа)
### Погода (!pogoda, !morning)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
- Fallback срабатывает автоматически при неуспешных попытках
- WMO weather codes → русский перевод в `_wmo_to_russian()`
- WMO weather codes → русский перевод в `wmo_to_russian()`
### Конвертации
- Давление: hPa → мм рт. ст. (`* 0.750062`)
- Ветер: км/ч → м/с (`/ 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` возвращает:
```
[TEMP] Температура: X°C (ощущается как Y°C)
[DESC] Описание: Z
[HUMID] Влажность: X%
[WIND] Ветер: X м/с
[PRESS] Давление: X мм рт. ст.
Температура: X°C (ощущается как Y°C)
Описание: Z
Влажность: X%
Ветер: X м/с
Давление: X мм рт. ст.
```
## Консольные команды
Запускаются через терминал при старте бота. Список выводится при запуске.
| Команда | Описание |
|---------|----------|
| `pogoda` | Вывести погоду в Магнитогорск |
| `news` | Вывести топ-5 статей AI с Habr |
| `morning` | Вывести погоду, топ-5 статей и котик |
| `cat` | Заглушка |
| `stop` | Остановить бота |
## Безопасность
- `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий.
- Используй `.env.example` как шаблон.

View File

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

View File

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

35
bot.py
View File

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

View File

@ -1,5 +1,6 @@
from .pogoda import Pogoda
from .news import News
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
from discord.ext import commands
import asyncio
import requests
from utils.cat import fetch_cat
class Cat(commands.Cog):
@ -10,21 +10,14 @@ class Cat(commands.Cog):
@commands.command(name="cat")
async def cat(self, ctx):
"""Получить случайного котика"""
try:
response = await asyncio.to_thread(
requests.get,
"https://api.thecatapi.com/v1/images/search",
timeout=10
)
response.raise_for_status()
data = response.json()
url = data[0]["url"]
url = await fetch_cat()
if url is None:
await ctx.send("Не удалось получить котика. Попробуйте позже.")
return
embed = discord.Embed(
title="🐱 Котик для тебя!",
title="Котик для тебя!",
color=discord.Color.orange()
)
embed.set_image(url=url)
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")
async def news(self, ctx):
"""Топ-5 свежих статей по AI с Habr"""
"""Топ-5 свежих статей и новостей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None:
await ctx.send("Не удалось получить новости. Попробуйте позже.")
@ -18,14 +18,43 @@ class News(commands.Cog):
await ctx.send("Новостей пока нет.")
return
lines = format_articles(articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
articles_text = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
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()
await ctx.send(message, allowed_mentions=discord.AllowedMentions.none())
embed = discord.Embed(
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)
await ctx.send(
f"[TEMP] Температура: {temp}°C (ощущается как {feels_like}°C)\n"
f"[DESC] Описание: {description}\n"
f"[HUMID] Влажность: {humidity}%\n"
f"[WIND] Ветер: {wind} м/с\n"
f"[PRESS] Давление: {pressure_mm} мм рт. ст."
f"Температура: {temp}°C (ощущается как {feels_like}°C)\n"
f"Описание: {description}\n"
f"Влажность: {humidity}%\n"
f"Ветер: {wind} м/с\n"
f"Давление: {pressure_mm} мм рт. ст."
)

View File

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

View File

@ -1,3 +1,3 @@
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_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} мм рт. ст.")
print(f"Температура: {temp}°C (ощущается как {feels_like}°C)")
print(f"Описание: {description}")
print(f"Влажность: {humidity}%")
print(f"Ветер: {wind} м/с")
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_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
_session = requests.Session()
async def fetch_rss(url):
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
import asyncio
import requests
from xml.etree import ElementTree
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()
root = ElementTree.fromstring(response.content)
@ -58,13 +63,12 @@ async def fetch_rss(url):
"tags": tags,
})
return articles[:10]
except requests.RequestException:
except requests.exceptions.RequestException:
return None
def _parse_date(pub_date):
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
from datetime import datetime
if not pub_date:
return ""
try:

View File

@ -2,12 +2,14 @@ import asyncio
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
_session = requests.Session()
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 = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
response.raise_for_status()
return response.json()
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):
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()
data = response.json()
current = data.get("current", {})