# 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___bot ▼ news-store → бандл bundles///.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. - **Право на медиа**: медиа своего канала скачивается (контент пользователя) — ок.