openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs

This commit is contained in:
kpa39l
2026-09-16 17:01:00 +00:00
parent 584582a48c
commit 771f6a8276
88 changed files with 1632 additions and 3 deletions
+31
View File
@@ -0,0 +1,31 @@
# classifier Specification
## Purpose
Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется
приоритетом «своего контента» и поддержкой мультинаправлений для fan-out.
## Requirements
### Requirement: Приоритет своего контента
Классификатор MUST обрабатывать посты с `is_own=1` как «сильные кандидаты»: при наличии
направления по словарю — relevance=critical, classified=True — даже если LLM недоступна
(фолбэк без LLM, пост не теряется). Для внешних постов поведение без изменений.
#### Scenario: Свой пост, LLM недоступна
- **WHEN** пост is_own=1, словарь дал направление, но Ollama недоступна
- **THEN** пост получает direction (словарь), relevance=critical, classified=True,
method='dict-own' (не требует LLM)
#### Scenario: Свой пост без направления по словарю
- **WHEN** пост is_own=1, словарь не дал направление
- **THEN** классификации нет (classified=False) до LLM; пост не теряется (остаётся в очереди)
### Requirement: Мультинаправления
Классификатор MUST уметь возвращать несколько направлений для поста (для маппинга fan-out);
основное направление хранится в posts.direction, дополнительные — в classifications
(таблица уже позволяет несколько классификаций на пост).
#### Scenario: Пост про Linux + AI
- **WHEN** пост упоминает и линукс, и нейросети
- **THEN** в classifications может быть несколько записей (linux, ai); fan-out использует
оба при подтверждении
+66
View File
@@ -0,0 +1,66 @@
# crawler-queue Specification
## Purpose
Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают
кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры
(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница
`/crawlers` показывает статус источников и размер очереди.
## Requirements
### Requirement: Параллельная фаза 3 (воркеры)
Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`,
где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1
WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama,
trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary,
classified=1). При исключении посте MUST возвращаться в очередь (classified=0).
#### Scenario: Параллельная обработка очереди
- **GIVEN** 100 постов со `classified=0`
- **WHEN** `python -m crawler.worker --workers 8 --limit 200`
- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз)
#### Scenario: Сбой воркера
- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна)
- **WHEN** воркер обрабатывает пост
- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает
### Requirement: Очередь на основе posts (без новой таблицы)
«Размер очереди» MUST вычисляться из существующей таблицы `posts`:
`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending),
`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today).
Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`.
#### Scenario: Размер очереди
- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1)
- **WHEN** страница /crawlers запрашивает размер очереди
- **THEN** pending=10, processing=2, done today=0
### Requirement: Страница /crawlers
Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников
(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count,
priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди
(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST
устанавливать sources.status='alive', error_count=0, last_error=NULL для всех
источников со status='dead'.
#### Scenario: Просмотр статуса
- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди
- **WHEN** GET /crawlers
- **THEN** страница показывает lwn с статусом alive, размер очереди 15
#### Scenario: Сброс dead-источников
- **GIVEN** источник со status='dead', error_count=7
- **WHEN** POST /crawlers/reset
- **THEN** источник становится alive, error_count=0, last_error=NULL
### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка)
Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех
включённых источников (telegram_crawler + rss_crawler) без классификации;
новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3)
запускается отдельно (`crawler/worker.py`).
#### Scenario: Сбор без классификации
- **GIVEN** включённые источники telegram и rss
- **WHEN** `python -m crawler.crawl_sources --all`
- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена
+22
View File
@@ -0,0 +1,22 @@
# news-store Specification
## Purpose
Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения
своего контента и ссылкой на оригинал.
## Requirements
### Requirement: Атрибуция в бандле
Бандл поста с is_own=1 MUST содержать в frontmatter `origin: own`, ссылку на оригинал
(`source_url` = t.me/dedinit/<id>) и имя автора («Дед в АйТи»). Бандл внешнего поста —
как раньше (origin: external, source_url=url источника).
#### Scenario: Бандл своего поста
- **WHEN** create_bundle вызывается для поста is_own=1
- **THEN** frontmatter содержит origin: own, source: dedinit, source_url:
https://t.me/dedinit/<tg_post_id>, author: Дед в АйТи
#### Scenario: Бандл внешнего поста в нескольких направлениях
- **WHEN** пост (свой или внешний) опубликован в несколько направлений
- **THEN** бандл создаётся по каждому направлению (bundles/<dir>/<YYYY-MM>/<slug>.md),
обе записи ссылаются на один и тот же original post_id
+92
View File
@@ -0,0 +1,92 @@
# sources-admin Specification
## Purpose
Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение,
удаление, пауза и смена приоритета источников без ручной правки YAML.
`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД.
## Requirements
### Requirement: Просмотр списка источников
Страница /sources показывает таблицу всех источников с их полями и статусом.
#### Scenario: просмотр списка источников
- **Given** веб запущен, пользователь авторизован
- **When** он открывает `/sources`
- **Then** страница показывает таблицу всех источников: slug, name, crawler,
direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error)
### Requirement: Добавление нового источника
Форма «Добавить» создаёт запись в sources.yaml и БД.
#### Scenario: добавление нового источника
- **Given** страница /sources
- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1)
- **And** отправлена
- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД
- **And** страница показывает новый источник в таблице
#### Scenario: валидация добавления
- **Given** форма добавления с невалидным slug (`my source!`)
- **When** отправлена
- **Then** добавление отклонено, показана ошибка (flash), ничего не записано
- **And** slug принимается только `[a-z0-9_-]+`
### Requirement: Изменение полей источника
Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД.
#### Scenario: переименование / изменение полей
- **Given** существующий источник `lwn`
- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2
- **Then** изменения применяются в yaml и БД, таблица обновляется
### Requirement: Пауза и снятие с паузы
Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет.
#### Scenario: пауза и снятие с паузы
- **Given** источник `lwn` (enabled=true)
- **When** пользователь нажимает «Пауза»
- **Then** `enabled=false` в yaml и БД
- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает)
- **When** пользователь нажимает «Снять с паузы»
- **Then** `enabled=true`, сбор возобновляется
### Requirement: Удаление источника
Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты.
#### Scenario: удаление источника
- **Given** источник `opennet` с постами в БД
- **When** пользователь подтверждает удаление (confirm)
- **Then** запись удалена из yaml и sources БД
- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется)
- **And** rss_state для него удалён
#### Scenario: удаление без подтверждения
- **Given** форма удаления
- **When** confirm отклонён (Cancel)
- **Then** ничего не удалено, данные не меняются
### Requirement: Доступ без авторизации
Страница /sources защищена паролем, как остальные админ-страницы.
#### Scenario: доступ без авторизации
- **Given** пользователь не авторизован
- **When** он открывает `/sources`
- **Then** происходит редирект на /login (303)
### Requirement: Приоритет источника
Приоритет P0–P3 влияет на порядок обработки источников.
#### Scenario: приоритет
- **Given** источники с priority P0–P3
- **When** страница /sources загружена
- **Then** приоритеты отображаются и доступны для изменения (P0–P3)
- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета
### Requirement: Навигация
Ссылка «Источники» присутствует в навбаре.
#### Scenario: навигация
- **Given** любая страница веб (base.html)
- **When** открыт навбар
- **Then** есть ссылка «Источники» на `/sources`
+33
View File
@@ -0,0 +1,33 @@
# tg-crawler Specification
## Purpose
Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками
популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя.
## Requirements
### Requirement: Источники своего контента
Краулер MUST распознавать источники с `own: true` в sources.yaml и для их постов
проставлять `is_own=1`, `is_own_canonical=1`. Для таких источников медиа MUST
скачиваться (контент принадлежит пользователю).
#### Scenario: Краулинг своего канала
- **WHEN** источник slug=dedinit имеет own: true
- **THEN** посты сохраняются с is_own=1 и is_own_canonical=1; медиа скачивается;
инкрементальный обход и дедуп работают как обычно
#### Scenario: Чужой форвард в своём канале
- **WHEN** пост в своём канале — форвард из чужого канала
- **THEN** пост сохраняется с is_own=1 (это пост пользователя, он его переслал) и с
fwd_from_channel_id/fwd_from_post_id; медиа не скачивается (содержимое чужое),
атрибуция оригинала сохраняется в fwd-полях
### Requirement: Канонический экземпляр своего контента
Если пост пользователя (is_own=1) позже встречается во внешнем канале (тот же sha256/url),
внешний экземпляр MUST сохраняться как «упоминание» с is_own=0; каноническим остаётся
первичный (is_own_canonical=1).
#### Scenario: Свой пост запостили в чужой канал
- **WHEN** краулер находит в чужом канале пост с текстом, совпадающим с is_own-постом
- **THEN** создаётся запись is_own=0 (упоминание) без дублирования контента; веб видит
оба экземпляра, но кандидатом на публикацию считается канонический (is_own_canonical)
@@ -0,0 +1,65 @@
# tg-publisher-service Specification
## Purpose
Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API.
Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер
публикует во все каналы, в которые добавлен администратором. Telegram доступен только
через SOCKS5-прокси (127.0.0.1:1080).
## Requirements
### Requirement: Изолированный сервис публикации
Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба.
Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и
`POST /api/v1/publish` (публикация карточки).
#### Scenario: Healthcheck
- **GIVEN** сервис запущен
- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}`
и healthcheck в docker-compose (`curl -f`) проходит
#### Scenario: Публикация карточки
- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]`
- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает
`{"ok":true,"results":{"@dedinit_vesti":{"message_id":<int>,"views":0}}}`
### Requirement: Один бот, несколько каналов (конфиг)
Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую).
`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором —
публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403).
#### Scenario: Несколько каналов
- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту)
- **WHEN** POST /api/v1/publish без поля channels
- **THEN** карточка публикуется в оба канала; results содержит оба message_id
#### Scenario: Бот не админ канала
- **GIVEN** канал, в котором бот не администратор
- **WHEN** публикация в него
- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden)
### Requirement: Прокси SOCKS5 для Bot API
Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`.
Если прокси недоступен — сервис MUST вернуть 502 (не падать).
#### Scenario: Прокси недоступен
- **GIVEN** прокси 127.0.0.1:1080 выключен
- **WHEN** POST /api/v1/publish
- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш)
### Requirement: Сбор views
Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений
(через Bot API getMessage), чтобы веб показывал метрики.
#### Scenario: Views после публикации
- **GIVEN** сообщение опубликовано (message_id получен)
- **WHEN** запрос views для этого message_id
- **THEN** возвращается число просмотров (0 если ещё нет)
### Requirement: Режим подтверждения
Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба
после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ.
#### Scenario: Нет автопостинга
- **GIVEN** сервис запущен без входящих запросов
- **THEN** ничего не публикуется (процесс только слушает HTTP)
+44
View File
@@ -0,0 +1,44 @@
# tg-publisher Specification
## Purpose
Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется
автораспространением своего контента (fan-out) по нескольким направлениям.
## Requirements
### Requirement: Автораспространение (fan-out)
Подтверждение СВОЕГО поста (is_own=1) MUST публиковать карточку во ВСЕ тематические
каналы @dedinit_vesti_<direction>_<lang>_bot, соответствующие выбранным направлениям
(по умолчанию — все направления классификации поста). Каждая карточка MUST содержать
атрибуцию «Дед в АйТи» (@dedinit) и ссылку на оригинал t.me/dedinit/<post_id>.
#### Scenario: Мульти-публикация
- **WHEN** подтверждается свой пост с направлениями [linux, ai]
- **THEN** карточка отправляется в @dedinit_vesti_linux_ru_bot и @dedinit_vesti_ai_ru_bot;
обе содержат ссылку на оригинал
#### Scenario: Чужой пост — без fan-out
- **WHEN** подтверждается внешний пост (is_own=0)
- **THEN** публикуется только в канал своего направления, без атрибуции автора
### Requirement: Публикация по списку направлений
Публикатор MUST поддерживать `publish_multi(directions)` — публикацию карточки по списку
направлений, возвращающую map {direction: message_id}. При пустом списке направлений
MUST публиковать в направление по умолчанию (direction поста).
#### Scenario: Список направлений рассылки
- **WHEN** publish_multi вызывается с directions=[linux, ai]
- **THEN** возвращается {linux: message_id1, ai: message_id2}; каждая публикация
записывается в published с distributed_dirs
#### Scenario: Пустой список направлений
- **WHEN** publish_multi вызывается без directions
- **THEN** публикация идёт в канал направления поста (direction по умолчанию), без fan-out
### Requirement: Метрики по каждому направлению
Для опубликованных карточек MUST собираться views через Bot API по каждому направлению
(каждому message_id), чтобы веб показывал эффективность рассылки по лентам.
#### Scenario: Метрики fan-out
- **WHEN** пост разослан в [linux, ai] и каналы набирают просмотры
- **THEN** get_views вызывается для каждого message_id; views хранятся по направлению
+29
View File
@@ -0,0 +1,29 @@
# vesti-web Specification
## Purpose
Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором
направлений рассылки для своего контента.
## Requirements
### Requirement: Фильтр «Свои»
Веб MUST давать фильтр постов по `is_own` (все/только свои/только внешние) и показывать
бейдж «СВОЙ» у постов is_own=1. Для своего поста при подтверждении MUST отображаться
выбор направлений рассылки (по умолчанию — все направления классификации поста).
#### Scenario: Фильтр своих постов
- **WHEN** админ выбирает фильтр «Свои»
- **THEN** показываются только посты is_own=1 с бейджем «СВОЙ» и чекбоксами направлений
#### Scenario: Подтверждение своего поста
- **WHEN** админ подтверждает свой пост с выбранными направлениями [linux, ai]
- **THEN** публикация идёт в оба канала (fan-out), результат виден в опубликованных с
distributed_dirs
### Requirement: Список распространения
Веб MUST показывать для опубликованного поста, в какие направления/каналы он был
разослан (distributed_dirs) и метрики (views) по каждому каналу.
#### Scenario: Просмотр распространения
- **WHEN** админ открывает опубликованный пост (свой)
- **THEN** видит список @dedinit_vesti_<dir>_<lang>_bot с views по каждому