Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)

This commit is contained in:
kpa39l
2026-09-13 15:58:32 +00:00
commit c3f59f7b7a
113 changed files with 7065 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-08
+106
View File
@@ -0,0 +1,106 @@
# Design: own-content-hub
## Approach
Канал @dedinit — обычный источник в реестре, но с флагом `own: true`. Вся общая логика
(краулер, дедуп, классификатор, банк) работает как для любого TG-канала; различие —
семантика: посты своего канала считаются СВОИМ контентом (is_own=1) и становятся
«сильными кандидатами» на автораспространение по всем тематическим лентам.
Ключевая идея — **fan-out вместо single-out**: один пост пользователя при подтверждении
уходит сразу во все тематические каналы @dedinit_vesti_<direction>_<lang>_bot, под
которые он подходит (направления из классификации). Так контент «инъецируется» в
новостные ленты и собирает аудиторию на всех площадках, а сам канал-источник остаётся
первоисточником (атрибуция везде).
Поток данных:
```
sources.yaml: @dedinit (own: true)
│
▼
telegram_crawler.py → posts.is_own=1 (дедуп как обычно; медиа скачивается — свой контент)
▼
classifier.py → направление(я) + relevance; is_own + критичность → «сильный кандидат»
▼
vesti-web (фильтр «Свои», бейдж; подтверждение с выбором направлений рассылки)
▼
tg-publisher.py → fan-out: карточка в каждый @dedinit_vesti_<dir>_<lang>_bot
▼
news-store → бандл bundles/<dir>/<YYYY-MM>/<slug>.md (origin=own, ссылка на оригинал)
```
## Files
```bash
# Изменяемые файлы
sources/sources.yaml # + источник dedinit (own: true)
db/schema.sql # + posts.is_own, posts.is_own_canonical, sources.own,
# published.distributed_dirs (миграция ALTER TABLE)
crawler/telegram_crawler.py # + определение own-источника, проставление is_own,
# is_own_canonical (первый экземпляр = канал), медиа скачивается
classifier/classify.py # + is_own → «сильный кандидат» (relevance critical, classified=True)
publisher/bot.py # + fan-out publish_multi(directions)
publisher/card.py # + атрибуция «Дед в АйТи» + ссылка на оригинал
web/app.py # + фильтр is_own, бейдж, выбор направлений рассылки при approve
web/templates/candidates.html # + бейдж СВОЙ, чекбоксы направлений
web/store.py # + frontmatter origin: own + ссылка на оригинал
```
## Commands
```bash
# 1) Миграция схемы (идиемпотентно)
cd /opt/vesti && .venv/bin/python - <<'PY'
import sqlite3
c = sqlite3.connect('db/vesti.db')
for ddl in [
"ALTER TABLE posts ADD COLUMN is_own INTEGER DEFAULT 0",
"ALTER TABLE posts ADD COLUMN is_own_canonical INTEGER DEFAULT 0",
"ALTER TABLE sources ADD COLUMN own INTEGER DEFAULT 0",
"ALTER TABLE published ADD COLUMN distributed_dirs TEXT",
]:
try: c.execute(ddl)
except sqlite3.OperationalError: pass # уже есть
c.commit(); c.close()
PY
# 2) Добавить источник в sources.yaml (own: true), синк
.venv/bin/python -c "from sources.sources import sync_sources_to_db, load_sources_yaml; sync_sources_to_db(load_sources_yaml())"
# 3) Краулер по своему каналу (бэкфилл ~1039 постов; медиа скачивается)
.venv/bin/python -m crawler.telegram_crawler --source dedinit
# 4) Классификатор по своему каналу
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --direction linux # + нужные направления
# 5) Веб
VESTI_WEB_PASSWORD=<пароль> .venv/bin/uvicorn web.app:app --host 127.0.0.1 --port 8400
# 6) Проверка
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login # 200
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1" # >0
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1 AND is_own_canonical=1"
```
## Rollback
```bash
# Отключить источник (не удалять данные)
# sources.yaml: dedinit → enabled: false, own: false
# Перезапустить синк; посты остаются в БД, новые не приходят.
# Поля is_own в данных можно оставить (безвредно); удаление данных — по согласованию.
```
Существующие внешние источники и их посты не затрагиваются: is_own=0 по умолчанию.
## Risks
- **Бэкфилл 1039 постов** — первый прогон долгий (медиа ~623M). Ограничить
MAX_POSTS_PER_CHANNEL или сначала только текст, медиа докачать позже (бэкфилл-флаг).
- **Дубликаты с внешними источниками**: пост пользователя, который запостили в чужой
канал, попадёт и как is_own (свой), и как внешний. Для своих постов `is_own_canonical=1`
(первичный экземпляр), внешние остаются как «упоминания» (is_own=0).
- **Fan-out = спам**: всегда режим подтверждения; веб показывает направления заранее;
лимит 4096 символов сохраняется; атрибуция не даёт путаницы с чужим контентом.
- **qwen3:8b think:false** — уже учтено в classify.py.
- **Право на медиа**: медиа своего канала скачивается (контент пользователя) — ок.
@@ -0,0 +1,65 @@
## Why
У пользователя есть собственные ресурсы (канал Telegram «Дед в АйТи» @dedinit, сайт dedinit.ru,
далее — феды/видео), но они живут разрозненно: контент, опубликованный в одном месте, не
попадает в другие. Цель — **собственный контент-хаб**: единая точка сбора ВСЕХ постов
пользователя из разных платформ, один банк своего контента, и **автораспространение**
этого контента по тематическим новостным ботам VESTI (и в перспективе — по другим
платформам), чтобы органически росла аудитория на всех площадках.
Задача НЕ «сделать копию канала»: канал @dedinit — полноценный источник данных в общей
логике проекта (как любой TG-канал): краулер → классификатор → кандидат → подтверждение →
публикация в тематические каналы → банк статей. Отличие от внешних источников — это
СВОЙ контент (is_own=1): он всегда кандидат на публикацию («инъекция» в ленту),
не блокируется политикой чужих форвардов и помечается атрибуцией автора.
## What Changes
- Добавить канал @dedinit (id 1150165846) в sources.yaml как источник `own: true`
(свой контент пользователя), направление определяется классификатором (канал
разносторонний: Linux, IT, AI, игры...).
- Краулер: посты своего канала помечаются `is_own=1`, для них продолжает работать
дедуп (sha256/url); политика форвардов для своего канала — как обычно (чужие
форварды → fwd-поля без медиа).
- Классификатор: свой контент с relevance critical/high → «сильные кандидаты»
(самокатегоризация; приоритет в ленте подтверждения).
- **Автораспространение (distribution)**: подтверждённый пост рассылается НЕ только
в один канал @dedinit_vesti_<dir>_<lang>_bot, а во ВСЕ тематические боты/каналы,
соответствующие направлениям поста (один пост может попасть в несколько лент).
Режим — по-прежнему «черновик на подтверждение», но подтверждение ведёт к
множественной публикации (fan-out).
- Веб: фильтр «Свои» (только is_own посты), отображение бейджа «СВОЙ», выбор
направлений для рассылки при подтверждении.
- Расширить таблицы: posts.is_own, posts.is_own_canonical (первичный экземпляр),
sources.own, published.distributed_dirs (какие направления розданы).
## Capabilities
### New Capabilities
- `own-content`: Свой контент-хаб: пометка постов пользователя (is_own), приоритет
в кандидатах, сквозная ссылка на оригинал во всех публикациях.
### Modified Capabilities
- `tg-crawler`: пометка is_own по источникам с own: true; политика форвардов для
своих каналов не отличается от внешних (dedup + fwd-поля).
- `classifier`: свой контент → «сильный кандидат» (relevance critical/high при
словарном попадании; classified=True даже без LLM-подтверждения, если есть
направление).
- `tg-publisher`: fan-out по нескольким направлениям; атрибуция «Дед в АйТи» +
ссылка на оригинал.
- `vesti-web`: фильтр «Свои», бейдж, выбор направлений рассылки при подтверждении.
- `news-store`: бандл своего поста помечается origin=own + ссылка на оригинал в
frontmatter.
## Impact
- Затронутые сервисы/порты: без новых портов; краулер (cron), классификатор,
tg-publisher, веб 127.0.0.1:8400 — те же.
- Файлы: sources/sources.yaml (новый источник), crawler/telegram_crawler.py,
classifier/classify.py, publisher/bot.py, publisher/card.py, web/app.py,
db/schema.sql (миграции ADD COLUMN is_own и др.).
- Данные: 1039 постов канала @dedinit появятся как is_own посты; медиа своего канала
скачивается (это контент пользователя — можно).
- Секреты: не требуются новые (та же Telethon-сессия, токены ботов в .env).
- Rollback: отключение источника `own: false` / удаление поля is_own; существующие
внешние посты не затрагиваются; данные не удаляются (правило пользователя).
@@ -0,0 +1,30 @@
## Purpose
Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется
приоритетом «своего контента» и поддержкой мультинаправлений для fan-out.
## ADDED 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 использует
оба при подтверждении
@@ -0,0 +1,21 @@
## Purpose
Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения
своего контента и ссылкой на оригинал.
## ADDED 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
@@ -0,0 +1,32 @@
## Purpose
Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками
популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя.
## ADDED 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,43 @@
## Purpose
Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется
автораспространением своего контента (fan-out) по нескольким направлениям.
## ADDED 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 хранятся по направлению
@@ -0,0 +1,28 @@
## Purpose
Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором
направлений рассылки для своего контента.
## ADDED 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 по каждому
+60
View File
@@ -0,0 +1,60 @@
# Tasks: own-content-hub
## 1. Схема БД и реестр источников
- [x] 1.1 Миграция схемы: ALTER TABLE posts ADD COLUMN is_own INTEGER DEFAULT 0, is_own_canonical INTEGER DEFAULT 0; sources ADD COLUMN own INTEGER DEFAULT 0; published ADD COLUMN distributed_dirs TEXT
Проверка: `sqlite3 db/vesti.db "PRAGMA table_info(posts)"` показывает is_own/is_own_canonical; sources.own; published.distributed_dirs
- [x] 1.2 Добавить источник dedinit в sources.yaml (channel: dedinit, own: true, direction: null, lang: ru)
Проверка: `grep -A4 "slug: dedinit" sources/sources.yaml` → есть own: true
- [x] 1.3 Синк реестра в БД (sources.own=1 для dedinit)
Проверка: `sqlite3 db/vesti.db "SELECT slug, own FROM sources WHERE slug='dedinit'"` → dedinit|1
## 2. Краулер
- [x] 2.1 telegram_crawler.py: загрузка own-флага источников; проставление is_own/is_own_canonical для own-источников; медиа скачивается
Проверка: код реализован (store_posts/is_own_source, py_compile OK); интеграционная проверка ждёт бэкфилла 2.3
- [x] 2.2 Форварды в своём канале: чужой форвард сохраняется с is_own=1 + fwd-полями, медиа НЕ скачивается
Проверка: код реализован (skip_media для форвардов); интеграционная проверка после бэкфилла
- [ ] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг)
Проверка: `sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1"` > 0 (до ~1039)
- [x] 2.4 Дедуп: тот же sha256/url во внешнем канале → is_own=0 (упоминание), канонический остаётся is_own_canonical=1
Проверка: код реализован (канон vs упоминание); проверка на реальных данных после бэкфилла
## 3. Классификатор
- [x] 3.1 classify.py: is_own=1 + направление по словарю → relevance=critical, classified=True, method='dict-own' (без LLM при недоступности)
Проверка: тестовый свой пост со словарным попаданием при выключенной Ollama → classified=1 relevance=critical
- [x] 3.2 Поддержка мультинаправлений: классификатор может писать несколько записей classifications (fan-out)
Проверка: пост linux+ai имеет 2 записи classifications
## 4. Публикатор (fan-out)
- [x] 4.1 publisher/bot.py: publish_multi(directions) → публикация карточки в каждый @dedinit_vesti_<dir>_<lang>_bot; возвращает {dir: message_id}
Проверка: dry-run с токеном → map направлений; без токена → dry_run=True (проверено ['linux','ai'])
- [x] 4.2 Атрибуция в карточке (publisher/card.py): для is_own-постов строка «Дед в АйТи (@dedinit)» + ссылка на оригинал, для внешних — без
Проверка: make_card(is_own пост) содержит t.me/dedinit/ и «Дед в АйТи» (проверено); make_card(внешний) — нет
- [x] 4.3 Запись distributed_dirs + tg_message_ids в published при fan-out
Проверка: после approve (TestClient, dry-run) distributed_dirs=JSON([linux, ai]) в published + views
## 5. Веб
- [x] 5.1 Фильтр «Свои» (is_own) в /candidates: параметр own=1|0, бейдж «СВОЙ»
Проверка: TestClient GET /candidates?own=1 → только is_own посты с бейджем «⭐ СВОЙ» (проверено)
- [x] 5.2 Выбор направлений рассылки при approve: чекбоксы (по умолчанию — направления классификации); approve → publish_multi
Проверка: TestClient POST /posts/{id}/approve с dirs=linux,ai → 302 /published, distributed_dirs=[linux,ai] (dry-run) (проверено)
- [x] 5.3 Опубликованные: показ distributed_dirs (в какие каналы разослан) и метрики по каждому
Проверка: /published содержит «Разослан в» с @dedinit_vesti_linux_ru_bot и ai (проверено TestClient)
## 6. Банк статей (news-store)
- [x] 6.1 store.py: frontmatter origin: own + source_url + author для is_own-постов; origin: external для внешних
Проверка: бандл содержит `origin: own` и `source_url: https://t.me/dedinit/<id>`, author «Дед в АйТи» (проверено)
- [x] 6.2 Бандлы по каждому направлению fan-out (bundles/<dir>/<YYYY-MM>/<slug>.md)
Проверка: create_bundle(['linux','ai']) → 2 файла в bundles/linux и bundles/ai (проверено)
## 7. Проверка интеграции и документация
- [ ] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию)
Проверка: counts по is_own в БД, бандлы, /published, runs ok — ждёт бэкфилла 2.3 (реальная сеть)
- [x] 7.2 Обновить STATUS.md / TODO.md / WALKTHROUGH.md (что сделано, как запускать, питфолы)
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)