Compare commits
No commits in common. "b752505177ec37a66e1b747aead1512692e4e966" and "c6bde31539e1b19499da89e36b21f74986bd8831" have entirely different histories.
b752505177
...
c6bde31539
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,7 +22,6 @@ env/
|
|||||||
# OS files
|
# OS files
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
nul
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@ -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`** — добавить явную проверку с информативным сообщением
|
||||||
|
|||||||
26
README.md
26
README.md
@ -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) |
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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}¤t=temperature,"
|
f"latitude={lat}&longitude={lon}¤t=temperature,"
|
||||||
|
|||||||
@ -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)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user