Files
vesti/openspec/changes/archive/2026-09-16-own-content-hub/design.md
T

6.3 KiB

Design: own-content-hub

Approach

Канал @dedinit — обычный источник в реестре, но с флагом own: true. Вся общая логика (краулер, дедуп, классификатор, банк) работает как для любого TG-канала; различие — семантика: посты своего канала считаются СВОИМ контентом (is_own=1) и становятся «сильными кандидатами» на автораспространение по всем тематическим лентам.

Ключевая идея — fan-out вместо single-out: один пост пользователя при подтверждении уходит сразу во все тематические каналы @dedinit_vesti___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

# Изменяемые файлы
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

# 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

# Отключить источник (не удалять данные)
# 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.
  • Право на медиа: медиа своего канала скачивается (контент пользователя) — ок.