Compare commits

..

No commits in common. "b752505177ec37a66e1b747aead1512692e4e966" and "c6bde31539e1b19499da89e36b21f74986bd8831" have entirely different histories.

8 changed files with 2 additions and 156 deletions

1
.gitignore vendored
View File

@ -22,7 +22,6 @@ env/
# OS files # OS files
Thumbs.db Thumbs.db
.DS_Store .DS_Store
nul
# IDE # IDE
.vscode/ .vscode/

View File

@ -2,7 +2,7 @@
## Средний приоритет ## Средний приоритет
- [x] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS) - [ ] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS)
- [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль - [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль
- [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py` - [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py`
- [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением - [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением

View File

@ -78,7 +78,6 @@ utils/ # Утилиты (API-клиенты, конвертац
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed() pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed()
news.py # fetch_rss(), format_articles(), truncate_title() news.py # fetch_rss(), format_articles(), truncate_title()
cat.py # fetch_cat() cat.py # fetch_cat()
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning() morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning()
tests/ # pytest-тесты tests/ # pytest-тесты
test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
@ -130,9 +129,8 @@ python -m pytest tests/ -v
| `test_morning_runner.py` | morning runner-а | 68 | | `test_morning_runner.py` | morning runner-а | 68 |
| `test_help_discord.py` | команда `!hp` | 2 | | `test_help_discord.py` | команда `!hp` | 2 |
| `test_help_console.py` | консольная `help` | 2 | | `test_help_console.py` | консольная `help` | 2 |
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
**Итого: 209 тестов.** **Итого: 204 теста.**
## Запуск в Docker ## Запуск в Docker
@ -162,7 +160,6 @@ DISCORD_TOKEN=ваш_токен docker-compose up
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
- Fallback срабатывает автоматически при неуспешных попытках - Fallback срабатывает автоматически при неуспешных попытках
- Rate-limiting: 1 req/sec, burst 3 (wttr.in); 2 req/sec, burst 5 (Open-Meteo). Настраивается через `.env`
- WMO weather codes → русский перевод в `wmo_to_russian()` - WMO weather codes → русский перевод в `wmo_to_russian()`
### Конвертации ### Конвертации
@ -175,12 +172,10 @@ DISCORD_TOKEN=ваш_токен docker-compose up
- **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru` - **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru`
- Парсинг RSS 2.0 и Atom форматов - Парсинг RSS 2.0 и Atom форматов
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>` - Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
- Rate-limiting: 1 req/sec, burst 2. Настраивается через `.env`
- Формат вывода: заголовок → дата → ссылка - Формат вывода: заголовок → дата → ссылка
### Котики (!cat, !morning) ### Котики (!cat, !morning)
- **API**: `https://api.thecatapi.com/v1/images/search` - **API**: `https://api.thecatapi.com/v1/images/search`
- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env`
- Картинка встраивается в Discord Embed - Картинка встраивается в Discord Embed
## Структура данных погоды ## Структура данных погоды
@ -206,14 +201,6 @@ DISCORD_TOKEN=ваш_токен docker-compose up
| `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) | | `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) |
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) | | `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
| `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID | | `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID |
| `CAT_API_RATE` | Rate-limit TheCatAPI (токенов/сек) | `.env`, по умолчанию `1` |
| `CAT_API_BURST` | Burst-бакет TheCatAPI | `.env`, по умолчанию `3` |
| `WEATHER_API_RATE` | Rate-limit wttr.in (токенов/сек) | `.env`, по умолчанию `1` |
| `WEATHER_API_BURST` | Burst-бакет wttr.in | `.env`, по умолчанию `3` |
| `OPEN_METEO_API_RATE` | Rate-limit Open-Meteo (токенов/сек) | `.env`, по умолчанию `2` |
| `OPEN_METEO_API_BURST` | Burst-бакет Open-Meteo | `.env`, по умолчанию `5` |
| `HABR_RSS_RATE` | Rate-limit Habr RSS (токенов/сек) | `.env`, по умолчанию `1` |
| `HABR_RSS_BURST` | Burst-бакет Habr RSS | `.env`, по умолчанию `2` |
## Зависимости ## Зависимости
@ -279,14 +266,3 @@ pytest-asyncio>=0.21.0
| `gather_morning()` | Параллельный сбор всех данных для дайджеста | | `gather_morning()` | Параллельный сбор всех данных для дайджеста |
| `run_morning()` | Формирование и отправка embed в канал Discord | | `run_morning()` | Формирование и отправка embed в канал Discord |
| `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) | | `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) |
### utils/rate_limiter.py
| Функция / Класс | Описание |
|----------------|----------|
| `RateLimiter` | Токен-бакет: `rate` (токенов/сек), `burst` (макс. бакет) |
| `RateLimiter.acquire()` | Асинхронно ждать освобождения токена перед запросом |
| `cat_limiter` | Лимитер для TheCatAPI (1/s, burst 3) |
| `weather_limiter` | Лимитер для wttr.in (1/s, burst 3) |
| `open_meteo_limiter` | Лимитер для Open-Meteo (2/s, burst 5) |
| `habr_rss_limiter` | Лимитер для Habr RSS (1/s, burst 2) |

View File

@ -1,45 +0,0 @@
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
import asyncio
import time
from utils.rate_limiter import RateLimiter
async def test_initial_tokens_full() -> None:
"""Бакет заполнен до burst при создании."""
limiter = RateLimiter(rate=2.0, burst=5)
assert limiter.tokens == 5.0
async def test_acquire_consumes_token() -> None:
"""acquire() уменьшает количество токенов."""
limiter = RateLimiter(rate=1.0, burst=3)
await limiter.acquire()
assert limiter.tokens == 2.0
async def test_acquire_waits_when_empty() -> None:
"""acquire() ждёт, когда токены закончились."""
limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек
await limiter.acquire() # бакет пуст
start = time.monotonic()
await limiter.acquire() # должен ждать ~0.1 сек
elapsed = time.monotonic() - start
assert elapsed >= 0.05 # допускаем погрешность
async def test_burst_cap() -> None:
"""Токены не превышают burst после долгого простоя."""
limiter = RateLimiter(rate=100.0, burst=3)
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 3.0
async def test_multiple_acquire() -> None:
"""Можно забрать несколько токенов за раз."""
limiter = RateLimiter(rate=1.0, burst=10)
await limiter.acquire(token=5)
assert limiter.tokens == 5.0

View File

@ -2,8 +2,6 @@ import asyncio
import requests import requests
from utils.rate_limiter import cat_limiter
CAT_API_URL = "https://api.thecatapi.com/v1/images/search" CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
_session = requests.Session() _session = requests.Session()
@ -11,7 +9,6 @@ _session = requests.Session()
async def fetch_cat() -> str | None: async def fetch_cat() -> str | None:
"""Получить URL случайного котика. Вернуть None при ошибке.""" """Получить URL случайного котика. Вернуть None при ошибке."""
await cat_limiter.acquire()
try: try:
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10) response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
response.raise_for_status() response.raise_for_status()

View File

@ -3,8 +3,6 @@ from datetime import datetime
import requests import requests
from utils.rate_limiter import habr_rss_limiter
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"
@ -13,7 +11,6 @@ _session = requests.Session()
async def fetch_rss(url): async def fetch_rss(url):
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom).""" """Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
await habr_rss_limiter.acquire()
from xml.etree import ElementTree from xml.etree import ElementTree
try: try:

View File

@ -3,8 +3,6 @@ import logging
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
from utils.rate_limiter import weather_limiter, open_meteo_limiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
@ -14,7 +12,6 @@ _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."""
await weather_limiter.acquire()
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(_session.get, api_url, timeout=timeout)
@ -37,7 +34,6 @@ async def fetch_weather(api_url, timeout=10, max_retries=3):
async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3): async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
"""Fallback на Open-Meteo API.""" """Fallback на Open-Meteo API."""
await open_meteo_limiter.acquire()
url = ( url = (
f"https://api.open-meteo.com/v1/forecast?" f"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}&current=temperature," f"latitude={lat}&longitude={lon}&current=temperature,"

View File

@ -1,74 +0,0 @@
"""
Rate-limiter на основе токен-бакета для внешних API-вызовов.
Каждый API получает свой RateLimiter с настройками rate (токенов/сек)
и burst (максимальный размер бакета). Перед каждым запросом вызывается
async acquire(), который ждёт освобождения токена при необходимости.
"""
import asyncio
import logging
import os
import time
from typing import Final
logger = logging.getLogger(__name__)
class RateLimiter:
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
def __init__(self, rate: float, burst: int) -> None:
"""
Args:
rate: Скорость пополнения токенов (токенов в секунду).
burst: Максимальный размер бакета.
"""
self.rate: float = rate
self.burst: int = burst
self.tokens: float = float(burst)
self.lock: asyncio.Lock = asyncio.Lock()
self._last_refill: float = time.monotonic()
def _refill(self) -> None:
"""Пополнить токены за прошедшее время."""
now: float = time.monotonic()
elapsed: float = now - self._last_refill
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self._last_refill = now
async def acquire(self, token: int = 1) -> None:
"""Забрать token токенов, ждать если их нет."""
while True:
async with self.lock:
self._refill()
if self.tokens >= token:
self.tokens -= token
return
# Ждём достаточно времени для восстановления 1 токена
await asyncio.sleep(token / self.rate)
# --- Готовые лимитеры по API ---
# TheCatAPI: бесплатно, 1 req/sec, burst 3
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
# wttr.in: без ключа, 1 req/sec, burst 3
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1"))
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3"))
# Open-Meteo: fallback, 2 req/sec, burst 5
_OPEN_METEO_RATE: Final[float] = float(os.getenv("OPEN_METEO_API_RATE", "2"))
_OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5"))
# Habr RSS: 1 req/sec, burst 2
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2"))
# Экземпляры лимитеров
cat_limiter: RateLimiter = RateLimiter(_CAT_RATE, _CAT_BURST)
weather_limiter: RateLimiter = RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
open_meteo_limiter: RateLimiter = RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
habr_rss_limiter: RateLimiter = RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)