Compare commits

...

3 Commits

Author SHA1 Message Date
deadzilla
311cfa7513 fix: добавить WEATHER_CITY в docker-compose, убрать conftest.py из Docker
- WEATHER_CITY и WEATHER_CACHE_TTL добавлены в docker-compose.yml
  с дефолтными значениями
- conftest.py удалён из Dockerfile — тестовый файл не нужен в production

Файлы: Dockerfile, docker-compose.yml
2026-07-22 22:10:59 +05:00
deadzilla
5e01cc6d8f refactor: вынести дублирующую логику RSS в _format_feed_section()
Блоки обработки articles и posts в !nw были почти идентичны.
Вынесено в _format_feed_section() с параметризацией лейблов,
сообщений об ошибках и заголовков RSS-источников.

Файлы: commands/news.py
2026-07-22 22:07:17 +05:00
deadzilla
ecff77ef66 feat: вынести город в WEATHER_CITY env-переменную
Город больше не захардкожен в коде. Переопределяется через
WEATHER_CITY (по умолчанию "Магнитогорск"). Шаблон изменён на
"Погода: {city}:" для обхода проблемы падежей в русском языке.

Файлы: utils/pogoda.py, .env.example, tests/test_morning_runner.py
2026-07-22 22:05:03 +05:00
7 changed files with 54 additions and 37 deletions

View File

@ -10,4 +10,5 @@ YANDEX_WEATHER_API_RATE=1
YANDEX_WEATHER_API_BURST=3 YANDEX_WEATHER_API_BURST=3
HABR_RSS_RATE=1 HABR_RSS_RATE=1
HABR_RSS_BURST=2 HABR_RSS_BURST=2
WEATHER_CITY=Магнитогорск
WEATHER_CACHE_TTL=3600 WEATHER_CACHE_TTL=3600

View File

@ -16,7 +16,6 @@ RUN pip install --no-cache-dir -r requirements.txt && \
# Копируем только нужные файлы (оптимизация размера образа) # Копируем только нужные файлы (оптимизация размера образа)
COPY bot.py . COPY bot.py .
COPY conftest.py .
COPY commands/ commands/ COPY commands/ commands/
COPY utils/ utils/ COPY utils/ utils/
# .env передаётся через docker-compose environment: # .env передаётся через docker-compose environment:

View File

@ -35,7 +35,7 @@
- [x] **`pressure_to_mmhg(mb: Any)`** — заменён на `float | int | str | None` (`utils/pogoda.py`) - [x] **`pressure_to_mmhg(mb: Any)`** — заменён на `float | int | str | None` (`utils/pogoda.py`)
- [x] **`conftest.py` и `bot.py` дублируют monkey-patch** — вынесен в `utils/compat.py`, оба файла импортируют оттуда - [x] **`conftest.py` и `bot.py` дублируют monkey-patch** — вынесен в `utils/compat.py`, оба файла импортируют оттуда
- [x] **`RateLimiter` тесты зависят от реального времени** — добавлен `_time_func` параметр, все тесты используют контролируемую функцию времени - [x] **`RateLimiter` тесты зависят от реального времени** — добавлен `_time_func` параметр, все тесты используют контролируемую функцию времени
- [x] **`Dockerfile` не копирует `conftest.py`** — добавлен `COPY conftest.py .` - [x] ~~**`Dockerfile` не копирует `conftest.py`**~~`conftest.py` убран из Dockerfile (тестовый файл не нужен в production)
- [x] **`requirements.txt` без пиннинга версий** — `>=` заменён на `~=` (compatible release) - [x] **`requirements.txt` без пиннинга версий** — `>=` заменён на `~=` (compatible release)
- [x] **Закомментированный тест `pressure_to_mmhg(0)`** — удалена устаревшая закомментированная строка - [x] **Закомментированный тест `pressure_to_mmhg(0)`** — удалена устаревшая закомментированная строка
- [x] **`format_articles(None)` бросает `TypeError`** — добавлена валидация `None`, graceful fallback с сообщением - [x] **`format_articles(None)` бросает `TypeError`** — добавлена валидация `None`, graceful fallback с сообщением
@ -81,9 +81,9 @@
- [ ] **Отсутствует `pyproject.toml`** — проект использует `requirements.txt` + `requirements-dev.txt` без единого файла конфигурации. Рекомендуется `pyproject.toml` с `[project]`, настройками ruff и pytest - [ ] **Отсутствует `pyproject.toml`** — проект использует `requirements.txt` + `requirements-dev.txt` без единого файла конфигурации. Рекомендуется `pyproject.toml` с `[project]`, настройками ruff и pytest
- [ ] **`commands/news.py` — дублирование кода для статей и постов** — блоки обработки `articles` и `posts` почти идентичны (проверка `None`, форматирование, fallback). Вынести в вспомогательную функцию - [x] ~~**`commands/news.py` — дублирование кода для статей и постов**~~ — вынесено в `_format_feed_section()` (`commands/news.py`)
- [ ] **Город захардкожен в `API_URL_WEATHER`**`Magnitogorsk` в URL `wttr.in`. При необходимости поддержки других городов потребуется рефакторинг. Минимум — задокументировать ограничение и вынести в env-переменную `WEATHER_CITY` - [x] ~~**Город захардкожен в `API_URL_WEATHER`**~~ — вынесен в `WEATHER_CITY` env-переменную, шаблон `"Погода: {city}:"` (`utils/pogoda.py`, `.env.example`)
- [ ] **`Scheduler._task` без `add_done_callback`** — если `_scheduler_loop` завершится unexpectedly (не через `CancelledError`), это не будет залогировано. Добавить callback для логирования нештатного завершения task - [ ] **`Scheduler._task` без `add_done_callback`** — если `_scheduler_loop` завершится unexpectedly (не через `CancelledError`), это не будет залогировано. Добавить callback для логирования нештатного завершения task

View File

@ -1,5 +1,7 @@
import asyncio import asyncio
import logging import logging
from typing import Optional
from discord.ext import commands from discord.ext import commands
from utils.news import ( from utils.news import (
fetch_rss, fetch_rss,
@ -12,6 +14,29 @@ from utils.news import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _format_feed_section(
data: Optional[list[dict]],
author: str,
label_warning: str,
label_info: str,
msg_error: str,
msg_empty: str,
title: str,
link: str,
) -> str:
"""Форматировать один RSS-раздел (статьи или посты)."""
if data is None:
logger.warning(
"%s: !nw — не удалось получить %s (API вернул None)", author, label_warning
)
return msg_error
elif data:
return "\n".join(format_articles(data, title, link))
else:
logger.info("%s: !nw — %s нет в RSS", author, label_info)
return msg_empty
class News(commands.Cog): class News(commands.Cog):
"""Команда !news — свежие статьи и новости по AI с Habr""" """Команда !news — свежие статьи и новости по AI с Habr"""
@ -23,41 +48,28 @@ class News(commands.Cog):
fetch_rss(RSS_URL_POSTS), fetch_rss(RSS_URL_POSTS),
) )
parts: list[str] = [] parts: list[str] = [
_format_feed_section(
# --- Статьи ---
if articles is None:
logger.warning(
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
)
parts.append("Не удалось получить статьи.")
elif articles:
articles_text = format_articles(
articles, articles,
str(ctx.author),
"статьи",
"статей",
"Не удалось получить статьи.",
"Статей пока нет.",
"Лучшие статьи за сутки / Искусственный интеллект / Хабr", "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/", "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
) ),
parts.append("\n".join(articles_text)) _format_feed_section(
else:
logger.info("%s: !nw — статей нет в RSS", ctx.author)
parts.append("Статей пока нет.")
# --- Посты ---
if posts is None:
logger.warning(
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
)
parts.append("\nНе удалось получить новости.")
elif posts:
posts_text = format_articles(
posts, posts,
str(ctx.author),
"посты",
"постов",
"Не удалось получить новости.",
"Новостей пока нет.",
"Лучшие новости за сутки / Искусственный интеллект / Хабr", "Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/", "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
) ),
parts.append("\n" + "\n".join(posts_text)) ]
else:
logger.info("%s: !nw — постов нет в RSS", ctx.author)
parts.append("\nНовостей пока нет.")
message = truncate_message("\n".join(parts)) message = truncate_message("\n".join(parts))
await ctx.send(message) await ctx.send(message)

View File

@ -15,5 +15,7 @@ services:
- YANDEX_WEATHER_API_BURST=${YANDEX_WEATHER_API_BURST:-3} - YANDEX_WEATHER_API_BURST=${YANDEX_WEATHER_API_BURST:-3}
- HABR_RSS_RATE=${HABR_RSS_RATE:-1} - HABR_RSS_RATE=${HABR_RSS_RATE:-1}
- HABR_RSS_BURST=${HABR_RSS_BURST:-2} - HABR_RSS_BURST=${HABR_RSS_BURST:-2}
- WEATHER_CITY=${WEATHER_CITY:-Магнитогорск}
- WEATHER_CACHE_TTL=${WEATHER_CACHE_TTL:-3600}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- TZ=Asia/Yekaterinburg - TZ=Asia/Yekaterinburg

View File

@ -170,7 +170,7 @@ class TestRunMorning:
second_call = channel.send.call_args_list[1] second_call = channel.send.call_args_list[1]
message_text = second_call[0][0] message_text = second_call[0][0]
assert "Утренний дайджест" in message_text assert "Утренний дайджест" in message_text
assert "Погода в Магнитогорске" in message_text assert "Погода: Магнитогорск" in message_text
class TestRunMorningWithFallback: class TestRunMorningWithFallback:
@ -242,5 +242,5 @@ class TestRunMorningWithFallback:
message_text = call_args[0][0] message_text = call_args[0][0]
# Проверяем, что в тексте есть погода и нет fallback сообщения # Проверяем, что в тексте есть погода и нет fallback сообщения
assert "Погода в Магнитогорске" in message_text assert "Погода: Магнитогорск" in message_text
assert "Не удалось получить данные из внешних источников" not in message_text assert "Не удалось получить данные из внешних источников" not in message_text

View File

@ -20,7 +20,10 @@ from utils.rate_limiter import yandex_weather_limiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Координаты Магнитогорска # Город для погоды (переопределяется WEATHER_CITY)
_WEATHER_CITY: str = os.getenv("WEATHER_CITY", "Магнитогорск")
# Координаты города
_LATITUDE: float = 53.40716 _LATITUDE: float = 53.40716
_LONGITUDE: float = 58.980289 _LONGITUDE: float = 58.980289
@ -281,7 +284,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
lines = format_weather_data_for_console(data) lines = format_weather_data_for_console(data)
if not lines: if not lines:
return None return None
return "Погода в Магнитогорске:\n" + "\n".join(lines) return f"Погода: {_WEATHER_CITY}:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: float | int | str | None) -> float | str: def pressure_to_mmhg(mb: float | int | str | None) -> float | str: