Сборщики событий · спецификация · 21 сентября 2026 · версия 1.0

Самопоиск Telegram-каналов, глубина чтения и повторные чтения

Для разработчика: что построить поверх существующего слоя Telegram (docs/37, 38) по решениям владельца от 21.09.2026 (разведка docs/39). Источник документа в репозитории — docs/40-telegram-discovery-rereads-engineer-spec-2026-09-21.md; при расхождении с кодом правится спецификация, правила Т-n нумеруются постоянно.

Версия 1.0, 21.09.2026. Основание — разведка docs/39 и семь решений владельца (21.09.2026, все — по рекомендации). Слой Telegram описан в docs/37 (Т-1…Т-43), страница канала — в docs/38, ADR — docs/adr/0003-telegram-layer.md.

0. Как читать, источники правды, решения владельца

Решения владельца 21.09.2026 (обязательны):

№ Вопрос Решение
Р-1 Источники кандидатов на старте Только бесплатные: сайты-каталоги, репосты и упоминания в читаемых каналах, угадывание имён. Яндекс, TGStat, аккаунт Telegram — не подключать
Р-2 Кто включает найденный канал Только оператор, из списка «Предложенные каналы»
Р-3 Частота поиска Раз в неделю на каждый город + кнопка «Найти каналы»
Р-4 Порядок Общие городские афиши; узкие темы — через репосты и упоминания найденных каналов
Р-5 Глубина первого чтения 45 дней; кнопка «Прочитать глубже» — до 90 дней; дальше никогда
Р-6 Пост удалён или «отменено» Карточку снять с ленты автоматически и показать оператору с пометкой
Р-7 Перепроверка живых карточек Раз в сутки для событий в ближайшие 14 дней, раз в 3 дня для остальных

1. Цель и границы

Цель. Оператор перестаёт искать каналы руками: система сама приносит кандидатов по городу, проверяет их и предлагает; принятые каналы читаются как сейчас. Чтение канала ограничено по дате, а не по числу страниц; повторные чтения добавляют новое и обновляют уже собранное (правки, удаления, отмены, переносы).

Делаем: таблица кандидатов и их автопроверка; три бесплатных источника кандидатов; блок «Предложенные каналы» в консоли; еженедельный запуск и кнопка; глубина первого чтения 45/90 дней; сверка уже прочитанных постов; ежедневная проверка живых карточек одиночным запросом; обработка удалений, отмен и переносов; счётчики.

Не делаем (Р-1, Р-2 и границы слоя): платные и аккаунтные источники (Яндекс API, TGStat API, Telethon); автоматическое включение канала; чаты и закрытые каналы; модель; правки продакшена cloud.ru; перенос архитектуры реестра (он остаётся данными в базе сборщиков, консоль — проброс через Django).

Города: те, где есть включённые каналы или заданы источники кандидатов; на старте msk, spb, vrn, kzn (слаги каталогов — §4.2).


2. Что уже есть (и что менять нельзя)

Что Где Замечания
Реестр каналов telegram_channels ingestion/db/models.py:TelegramChannel, collectors/telegram/registry.py, миграция alembic/versions/e1f2a3b4c567_telegram_channels.py note_check пишет счётчики последнего чтения (Т-43)
Сборщик collectors/telegram/collector.py _walk_channel: первая страница + ?before= до last_post_id; у нового канала history_pages=5; посты ≤ last_post_id не разбираются; known_digests отсекает неизменившийся текст
Клиент collectors/telegram/http_client.py:TelegramWebClient get_channel_page(username, before=, after=), get_image; прокси/реле (TG_PROXY_URL, TG_RELAY_URL); пауза min_interval_sec; CHANNEL_MARK/CONTACT_MARK
Разбор и правила parse.py (Post, parse_channel_page, channel_title, looks_like_no_preview), rules.py (classify, extract_event, category_hints, REMIND), normalize.py (normalize_post) правила v3; reminder не карточка (Т-9), Т-20 не реализовано
Страница канала collectors/telegram/review.py, GET /api/export/telegram/channels/<канал>/events _item() — форма строки; текст поста из raw_payloads
Ручки сервиса ingestion/ui/export.py (/telegram/channels GET/POST, /{username} PATCH/DELETE, /{username}/read POST, /{username}/events GET) _start_channel_read запускает прогон через JobManager с channels=<канал>
Django-проброс adminpanel/views/ingestion.py (AdminIngestTelegram*View), adminpanel/urls.py, adminpanel/rbac.py, events/catalog_client.py права ingest.policy (реестр) и ingest.run (чтение)
Консоль backend/admin-panel/assets/sections/ingest.js (ingestTelegramHtml, bindIngestTelegram), ingest-telegram.js (страница канала, ingestRoute, channelHash) адрес #events/collectors/telegram/<канал>
Каталог и стенд orchestrator/persist.py (volatile_extras, EventSourceLink.preview_hash, last_seen_at), экспорт карточек export.py (feed_ready, content_hash), импорт стенда events/catalog_import.py (_apply_card: operator_overrides, publish_locked) стенд импортирует горизонт 45 дней; неизменённый content_hash → строка не переписывается
Расписание docker/crontab-collect (17 */12 * * *), docker/collect-all.sh, schedule_status.CRON_SOURCES (telegram последним) ночная волна с --refresh-all
Тесты tests/test_telegram_{collector,parse,rules,registry_db,review_db}.py, фикстура tests/fixtures/telegram_channel_page.html база для *_db — TEST_DATABASE_URL (conftest)

Не менять: контракт CatalogUpsert/CollectContext; ключ поста <канал>:<id>; путь в Telegram только через клиент (Т-3, Т-39); политика auto_publish=False для telegram на стенде; поведение _apply_card на стенде (правки оператора поверх, замок публикации) — на него опираемся, а не правим.


3. Архитектура изменений в одну страницу

                         ┌──────────────────────── раз в неделю (cron) / кнопка «Найти каналы» ─────────────────────┐
                         │                                                                                          │
 каталоги telegid/telno ─┤                                                                                          │
 упоминания и репосты ───┼─► candidates (telegram_candidates) ─► автопроверка (1 страница t.me/s/) ─► verdict ──┐   │
 шаблоны имён ───────────┘        found_via, city_code                метрики §4.3, пороги Т-44…Т-48           │   │
                                                                                                              ▼   │
                                                                      консоль: «Предложенные каналы» ─ «Читать» ──┼─► telegram_channels (реестр) ─► чтение (как сейчас)
                                                                                                «Не подходит» ──┘         │
                                                                                                                          ▼
 первое чтение: назад по дате ≤ 45 дн (≤15 стр) / «глубже» ≤ 90 дн (≤40 стр)  ── Т-53 ──────────────── collector._walk_channel
 повторное (каждые 12 ч): новые посты + сверка digest всех постов на прочитанных страницах ── Т-54(1,2) ─ collector.collect
 ежедневно: живые карточки → t.me/<канал>/<id>?embed=1 → unchanged / changed / gone ── Т-54(3), Т-59 ── live.py (CLI telegram-live-check)
 посты «отменено/перенос» → карточка того же канала → cancelled / дата → оператор ── Т-56 ─────────── collector + live.py
                                                                                                                          │
   event_source_links.source_state ∈ {ok, gone, cancelled}, source_checked_at; events.extras.source_changed (volatile)    ▼
   export: feed_ready=False при gone/cancelled, source_state в content_hash ─► стенд снимает с ленты (_apply_card) ─► страница канала: пометки

Три части независимы по коду и выкатываются по очереди (§9): A поиск (новая таблица, модуль discovery/, ручки, консоль), B глубина (правка _walk_channel, параметры, кнопка), C повторные чтения (правка цикла collect, модуль live.py, поля у ссылок источника, экспорт).


4. Часть A — поиск каналов-кандидатов

4.1 Хранение: таблица telegram_candidates

Миграция alembic/versions/<rev>_telegram_candidates.py, down_revision — текущая голова (alembic heads; на 21.09.2026 — e1f2a3b4c567). Модель TelegramCandidate в ingestion/db/models.py рядом с TelegramChannel.

Колонка Тип Смысл
id int PK
username varchar(64) unique нижний регистр, registry.normalize_channel
city_code varchar(32) город, для которого искали
found_via varchar(64) catalog:telegid, catalog:telno, snowball:<канал>, guess
found_from varchar(1024) адрес страницы каталога или поста, где встретился
first_seen_at, last_seen_at timestamptz первое/последнее попадание в источниках
last_checked_at timestamptz null последняя автопроверка
check_status varchar(32) ok, no_preview, throttled, error, new
title varchar(255) из channel_title
about varchar(1000) og:description
subscribers int null из счётчика страницы (§11)
posts_on_page int постов на первой странице
posts_per_week numeric(6,1) §4.3
last_post_at timestamptz null
event_share, city_share, promo_share numeric(4,2) §4.3
categories json топ-3 category_hints с числами
verdict varchar(32) proposed, few_data, rejected_auto, rejected_by_operator, accepted
verdict_reason varchar(255) человеческая причина («не писал 212 дней», «анонсов 5 %»)
decided_by, decided_at varchar(128), timestamptz оператор
snooze_until timestamptz null до когда не предлагать и не перепроверять
updated_at timestamptz

Индексы: (city_code, verdict), (snooze_until). Кандидат, который уже есть в telegram_channels, в таблицу не попадает (или получает accepted без проверки).

4.2 Источники кандидатов (Р-1, Р-4)

Модуль ingestion/collectors/telegram/discovery/ (пакет): catalogs.py, snowball.py, guess.py, check.py, run.py, __init__.py. Все внешние запросы — через fetch_text из общего HTTP-слоя с паузой ≥ 1,5 с и обычным User-Agent; каталоги — напрямую (открываются из РФ, проверено 21.09), t.me — только через TelegramWebClient.

A. Сайты-каталоги (catalogs.py), только адреса без параметров запроса у telegid (его robots: Disallow: *?*), у telno — страницы ?page=N разрешены:

Город telegid.me telno.ru
msk /catalog/russia/moskva /city/Moscow (+?page=2..5)
spb /catalog/russia/sankt-peterburg /city/Saint-Petersburg
vrn /catalog/russia/voronezh /city/Voronezh
kzn /catalog/russia/kazan, /collections/afisha-kazani /city/Kazan

Подборки afisha-<город> есть не у всех городов: раз в запуск читать telegid.me/collections и брать ссылки /collections/afisha-*, чей слаг соответствует городу (таблица соответствий в коде: kazani, moskvy, peterburga/sankt-peterburga, voronezha; отсутствие — не ошибка). Имена — из href="https://t.me/<name>" (telegid) и href="/channel/@<name>" (telno), см. §11.3; служебные имена (telegid, links_telegid, boost, joinchat, share, addstickers, proxy, iv, s) отбрасываются; t.me/+… — закрытые, отбрасываются. Не чаще раза в неделю на город (Т-51); недоступность каталога — предупреждение в статистике, не ошибка запуска.

B. Снежный ком (snowball.py): имена из уже читаемых каналов города — Post.forwarded_url и Post.links вида t.me/<name> (кроме t.me/<name>/<id> на собственный канал, t.me/+…, ботов *bot). Два пути: (1) сборщик в collect() собирает упоминания за проход в stats["mentions"] и пишет их в telegram_candidates через discovery.note_mentions(session, city, channel, names) (без новых запросов); (2) при запуске поиска — по raw_payloads карточек источника telegram за 90 дней (links, forwarded_url в payload). found_via = snowball:<канал>.

C. Шаблоны имён (guess.py): для города — слаги (kzn: kazan, kzn; vrn: vrn, voronezh; spb: spb, piter, peterburg; msk: msk, moscow, mos, moskva) × шаблоны (afisha_{c}, {c}_afisha, afisha{c}, {c}afisha, {c}_events, events_{c}, kuda{c}, kuda_{c}, {c}_go, go_{c}, {c}dvizh, {c}_dvizh, {c}_meropriyatiya), не больше 30 имён на город; промах → check_status=no_preview, snooze_until = +180 дней. Проба 21.09: из 20 имён существуют 6, живых 2 — источник вспомогательный.

4.3 Автопроверка кандидата (check.py)

Один запрос get_channel_page(username) (Т-45). Из HTML:

Метрика Как считать
лента есть нет ChannelUnavailable; parse_channel_page дал ≥ 1 пост
title, about channel_title(html), <meta property="og:description" content="…">
subscribers <span class="counter_value">X</span> <span class="counter_type">subscribers (X = 1.2K, 2.5M, 635), §11.2
posts_on_page число постов
last_post_at max published_at
posts_per_week posts / max(1, (max − min published_at в днях)) × 7; при одном посте — posts_on_page
event_share доля постов с classify(text, published_at).kind ∈ {announce, digest}
promo_share доля kind == promo
city_share доля постов, где CITY_WORDS[city_code] (regex: казан; воронеж; петербург\|питер\|спб; москв\|мск)
categories сумма category_hints(text) по постам, топ-3

Вердикт (пороги — константы модуля с именами правил, настраиваемые через params):

Пороги подтверждены пробой 21.09 (docs/39 §3): событийные каналы 0,45–1,0, новостные/«подслушано»/ресторанные 0,00–0,06; порог темпа 1,5 (а не 2) — чтобы не терять «Бизнес-мероприятия Казань» (1,8/нед, 0,8 анонсов).

4.4 Запуск (run.py) — раз в неделю и по кнопке (Р-3)

discover(city_code, *, sources=("catalogs","snowball","guess"), limit_checks=60, dry_run=False, should_stop=None) -> dict:

  1. Собрать имена из выбранных источников; для каждого — upsert в telegram_candidates (новое имя → verdict=new... хранить как check_status=new, verdict=few_data до проверки; известное — обновить last_seen_at, found_from не затирать).
  2. Отобрать к проверке: check_status=new, либо snooze_until истёк, либо proposed старше 30 дней. Не больше limit_checks (Т-45: ≤ 60 за проход), порядок: новые → просроченные proposed → остальные; имена из telegram_channels пропускаются.
  3. Проверить (check.py), записать вердикты. Между запросами пауза клиента; «Contact»-страница два раза подряд → остановить проход по городу (stats["throttled"]=True), остаток — на следующий запуск.
  4. Вернуть статистику: names_found по источникам, checked, proposed, rejected_auto, few_data, throttled, errors.

CLI: python -m ingestion telegram-discover --city kzn|all [--sources …] [--limit N] [--dry-run] (в ingestion/cli.py, коды выхода как у run). Cron: docker/crontab-collect — строка 41 16 * * 1 root env COLLECT_TRIGGER=cron /app/docker/telegram-discover.sh >> /app/logs/telegram_discover.log 2>&1 (понедельник 16:41 МСК — после дневной волны; скрипт берёт неблокирующий flock на /app/state/telegram-discover.lock, при занятом collect-all.lock не ждёт, а выходит с кодом 0 и строкой в лог — следующий запуск через неделю или кнопкой). Кнопка «Найти каналы» — §7.2/§7.4: запуск в фоне внутри сервиса сборщиков (discovery_jobs.py: один поток, модульный замок, статус idle|running|done|error с временем и статистикой; JobManager не используется — он проверяет, что код источника — сборщик).

4.5 Принятие и отказ (Р-2)


5. Часть B — глубина первого чтения (Р-5)


6. Часть C — повторные чтения (Р-6, Р-7)

6.1 Сверка уже прочитанных страниц — Т-54 (1), (2)

В _walk_channel убрать фильтр pid > channel.last_post_id из результата: возвращать все посты прочитанных страниц. В collect() неизменившиеся отсекает known_digests (posts_unchanged), изменившиеся идут по обычному пути (classify → extract_event → normalize_post → upsert) — это и есть Т-8 без лишних запросов. Новая статистика posts_changed (digest известен и отличается). last_post_id по-прежнему max(post_id).

Правки поста, у которого карточка уже есть: upsert обновляет карточку по ключу <канал>:<id> (persist), стенд перепишет поля поверх которых лежат правки оператора (operator_overrides) — это существующее поведение. Дополнительно (Т-57): если у существующей карточки изменились starts_at первого сеанса, venue.name/address или title, сборщик кладёт в extras (volatile_extras) source_changed = {"at": iso, "fields": ["date", "venue", "title"], "from": {...}, "to": {...}}; консоль показывает пометку «источник изменился» (§7.4). Пометка живёт до следующего изменения или 14 дней (сборщик чистит при неизменном digest старше 14 дней — просто не переписывает ключ: volatile-ключи без значения удаляются, см. persist.py).

Пост, который перестал быть событием после правки (kind ∉ CARD_KINDS) — трактовать как отмену (§6.3, source_state=cancelled, причина «пост изменён: больше не анонс»).

6.2 Живые карточки — Т-54 (3), Т-58, Т-59

Новый модуль ingestion/collectors/telegram/live.py, CLI python -m ingestion telegram-live-check [--city X|all] [--limit-per-channel 20] [--dry-run], cron 23 5 * * * root env COLLECT_TRIGGER=cron /app/docker/telegram-live-check.sh >> /app/logs/telegram_live.log 2>&1 (ежедневно 05:23 МСК, вне волн 00:17/12:17; свой flock).

Отбор (SQL в live.py): ссылки источника telegram (event_source_links × sources.code='telegram'), у события status <> 'archived', есть сеанс с starts_at >= now − 1 день или extras.is_ongoing, source_state ∈ {ok, NULL}, и (source_checked_at IS NULL или source_checked_at < now − 24 ч при ближайшем сеансе ≤ 14 дней, иначе < now − 72 ч) — Р-7. Группировка по каналу, внутри канала — по давности проверки; не больше limit_per_channel (20) за запуск (Т-58), остаток — завтра.

Запрос (Т-59): TelegramWebClient.get_post(username, post_id) -> PostCheck по адресу https://t.me/<канал>/<id>?embed=1 через тот же прокси/реле и с той же паузой. Разметка §11.4:

Ответ Признак Итог
пост есть class="tgme_widget_message …" с data-post="<канал>/<id>", текст в js-message_text, time datetime= PostCheck(exists=True, text, published_at, photos)
пост удалён tgme_widget_message_error с текстом Post not found exists=False
ни того ни другого (заглушка, ошибка сети) — SourceUnavailableError/ChannelUnavailable → карточку не трогать, source_checked_at не двигать, stats.errors

Текст поста собирать тем же text_from_html, что и у ленты, чтобы digest совпадал с preview_hash ссылки (иначе каждый пост будет «изменившимся»); тест на равенство digest «лента vs embed» обязателен (§8).

Исходы:

Оптимизация (не обязательна в первой версии): https://t.me/s/<канал>/<id> отдаёт ленту вокруг поста (≈ 20 постов, §11.5) — одним запросом можно сверить несколько живых карточек с близкими id; удаление в этом режиме — отсутствие data-post при наличии соседей с меньшим и большим id.

6.3 Отмены и переносы — Т-56 (реализация Т-20)

В collect() посты с kind == reminder, где REMIND сработал на отмен|перенос| переносится (вынести в rules.py: cancel_signal(text) -> "cancel" | "move" | None), не выбрасываются, а сопоставляются с карточкой того же канала:

  1. Кандидаты — карточки источника telegram с тегом tg:<канал> за последние 60 дней публикации и с сеансом не раньше now − 1 день.
  2. Сходство названий: нормализованные токены (normalize_key из interest_map, стоп-слова, ≥ 3 символов), Jaccard ≥ 0.5 или difflib.SequenceMatcher(None, a, b).ratio() ≥ 0.6; при нескольких — с ближайшей датой к датам из текста напоминания (find_dates), если дат нет — самая близкая по времени публикации.
  3. cancel → source_state=cancelled, extras.source_note="отменено: <ссылка на пост>", экспорт снимает с ленты (§6.4). move с датой (find_dates нашёл явную будущую дату) → обновить сеансы карточки (sessions=[новая], time_known по тексту), Т-57 source_changed.fields=["date"], пометка «перенос» оператору; move без даты → только пометка source_note="перенос, дата не указана", с ленты не снимать.
  4. Нет подходящей карточки → stats.filter_reasons.reminder_unmatched, ничего не делать (как сейчас).

6.4 Хранение состояния источника, экспорт, стенд

6.5 Счётчики — Т-60

В telegram_channels: posts_changed, posts_gone, cancels_found, live_checked (последний запуск), last_live_check_at, deep_read_at, depth_reached_at (миграция общая с §4.1 или отдельная). note_check расширить именованными аргументами; channel_brief — отдать их; страница канала — показать (§7.4). В stats прогона: posts_changed, reminders_matched, cancels, moves; в telegram-live-check: checked, unchanged, changed, gone, cancelled, errors, skipped_budget.


7. Реестр, API сервиса сборщиков, Django-проброс, консоль

7.1 Сервис сборщиков (ingestion/ui/export.py, роутер /api/export)

Метод и путь Что делает Ответ / ошибки
GET /telegram/candidates?city=kzn&verdict=proposed список кандидатов города (по умолчанию proposed; verdict=all — все, включая отклонённые) {items:[…], generated_at}; поля §4.1 + last_posts (5 последних постов с kind и текстом ≤ 200 символов — берутся из последней проверки, хранить в categories-json как preview)
POST /telegram/candidates/{username}/accept {added_by} принять: в реестр + чтение (§4.5) 202 {ok, username, started, params}; 404 нет кандидата; 409 сборщик занят (канал всё равно добавлен — сказать об этом в detail)
POST /telegram/candidates/{username}/reject {decided_by, reason?} отклонить (§4.5) 200 {ok}; 404
POST /telegram/discovery/run {city_code, sources?} запустить поиск в фоне (§4.4) 202 {started:true, city}; 409 уже идёт
GET /telegram/discovery/status состояние последнего запуска {state, city, started_at, finished_at, stats, error}
POST /telegram/channels/{username}/read?deep=1 «Прочитать глубже» (§5) как у /read; 409 если глубже читали < 7 дней назад

Реестр (GET /telegram/channels) дополняется полями §6.5 и attention — числом карточек канала с source_state <> 'ok' или extras.source_changed (считать одним запросом на все каналы, как _telegram_cards_by_channel). Страница канала (review._item) — поля source_state, source_checked_at, source_note, source_changed.

7.2 Django (adminpanel/views/ingestion.py, urls.py, rbac.py, events/catalog_client.py)

Маршрут /admin-api/… View Право
GET /ingest/telegram/candidates?city= AdminIngestTelegramCandidatesView раздел ingest (чтение), read_audit=False
POST /ingest/telegram/candidates/<username>/accept AdminIngestTelegramCandidateAcceptView ingest.policy (как добавление канала); added_by=_actor(request)
POST /ingest/telegram/candidates/<username>/reject AdminIngestTelegramCandidateRejectView ingest.policy
POST /ingest/telegram/discovery/run AdminIngestTelegramDiscoveryRunView ingest.run (как «Собрать»)
GET /ingest/telegram/discovery/status AdminIngestTelegramDiscoveryStatusView раздел ingest
POST /ingest/telegram/channels/<username>/read?deep=1 расширить AdminIngestTelegramChannelReadView ingest.run

Проброс — как у существующих: catalog_client.telegram_candidates(city), accept_telegram_candidate(username, added_by=), reject_telegram_candidate(username, decided_by=, reason=), run_telegram_discovery(city_code), telegram_discovery_status(), read_telegram_channel(username, deep=False); ошибки 404/409 сервиса пробрасывать с текстом (образец — AdminIngestTelegramChannelReadView). read_audit=False у чтений, действия — в аудит (как у реестра).

7.3 Права и роли

Никаких новых прав: принятие/отказ = ingest.policy, запуск поиска и «глубже» = ingest.run. Проверить rbac.py на паритет с urls.py (есть тест паритета — test_rbac_routes, если он в репозитории; иначе добавить в §8).

7.4 Консоль (backend/admin-panel/assets/sections/ingest.js, ingest-telegram.js)

Блок «Предложенные каналы» — новая панель сразу под «Telegram-каналы» (та же страница «Сборщики», тот же стиль panel, префикс классов .tgc-):

Тексты — русские, без слов «digest», «upsert», «verdict»; состояния канала и карточек — словами, как уже сделано (tgStatusOf).


8. Тесты

Все — в tests/, стиль существующих test_telegram_*. Фикстуры HTML класть в tests/fixtures/ с префиксом telegram_:

Файл Что покрывает
test_telegram_discovery_catalogs.py разбор telegid_collection.html, telegid_city.html, telno_city.html (усечённые копии живых страниц 21.09): имена, отсев служебных и закрытых, пагинация telno, отсутствие подборки у города — не ошибка
test_telegram_discovery_check.py метрики из telegram_channel_page.html и трёх новых страниц (событийный, новостной, «Contact»); формулы posts_per_week/event_share/city_share; вердикты Т-44…Т-48 на границах (7 постов → few_data; 0,29 → отсев; 1,4/нед → отсев; 299 подписчиков → small, не отсев); throttled при известном канале
test_telegram_discovery_run_db.py discover() на подменном клиенте: новые/известные имена, snooze_until, лимит 60, остановка при двух «Contact» подряд, telegram_channels пропускаются; accept → строка в реестре + чтение запущено (мок _start_channel_read); reject → rejected_by_operator + 180 дней
test_telegram_collector.py (расширить) глубина по дате: страницы с датами 10/40/50/100 дней → останов на 45 (3 страницы), deep — на 90; потолок страниц; повторное чтение возвращает все посты страниц, posts_changed считается; source_changed при смене даты; reminder с «отменено» находит карточку по названию (Jaccard/ratio на границах) и не трогает чужую
test_telegram_parse.py (расширить) parse_post_embed: существующий пост (текст, дата, фото), Post not found, digest текста ленты == digest текста embed для одного и того же поста (фикстуры telegram_post_embed.html и telegram_channel_page.html с тем же постом)
test_telegram_live_db.py отбор по каденции 24/72 ч и по горизонту (прошедшие не берутся; is_ongoing берётся), лимит 20 на канал, исходы unchanged/changed/gone/cancelled, событие с двумя источниками не снимается, выключенный канал пропускается
test_export_cards.py (или существующий тест экспорта) feed_ready=False и source_state в карточке при gone/cancelled; content_hash меняется при смене source_state
test_telegram_registry_db.py (расширить) новые счётчики в note_check/channel_brief, deep_read_at и 409 «раньше 7 дней»
Django (backend/.../tests/) пять новых view: проброс, права (ingest.policy/ingest.run), 404/409 с текстом; паритет urls↔rbac
Консоль сценарий Playwright по образцу tgpage-check.js (scratchpad 17.09, описан в docs/38 §5): блок виден, «Найти каналы» → статус → таблица, «Читать» → канал в реестре, «Не подходит» → строка исчезает; снимки экрана в отчёт о выкате

Прогон: pytest tests/test_telegram_*.py на макмини (mac-mini-backend-test-env: postgis/redis, порт 54329, TEST_DATABASE_URL) — тяжёлые прогоны не на salam0nn.


9. Порядок работ, приёмка, выкат

Ветка feature/telegram-discovery от origin/main (не в feature/figma-parity); перед пушем — git log origin/main.. на чужие незапушенные коммиты. Коммиты по этапам, каждый этап — с тестами и строкой в docs/CHANGELOG.md.

Этап Содержание Готово, когда
1. Модель и проверка §4.1 миграция, discovery/check.py, guess.py, CLI telegram-discover --sources guess --dry-run тесты check; на стенде --dry-run по Казани печатает метрики 20 имён
2. Каталоги и снежный ком catalogs.py, snowball.py (+ stats["mentions"] в сборщике), run.py, cron-скрипт telegram-discover --city kzn даёт ≥ 5 proposed (проба 21.09: 7 из 63), запуск идемпотентен
3. Ручки и консоль §7.1–7.4 кроме «глубже» Playwright-сценарий проходит; оператор принял канал из списка, он читается
4. Глубина §5, кнопка «Глубже» тесты глубины; у нового канала на стенде pages ≤ 15 и oldest ≥ now−45d в статистике
5. Повторные чтения §6.1, §6.4 (миграция ссылок, экспорт), §6.5 правка поста на живом канале доезжает до стенда за одну волну; posts_changed в статистике
6. Живые карточки, отмены live.py, cron, §6.3 удалённый пост снимает карточку с ленты стенда (проверить руками: удалить пост в своём тестовом канале), «отменено» находит карточку

Выкат на стенд dusha (рецепт docs/internal/07, память telegram-collector-deployed): ingestion/ — rsync в /opt/dusha/ingestion, docker compose … build app && up -d app (миграции в entrypoint); cron — docker/crontab-collect и новые скрипты docker/telegram-discover.sh, docker/telegram-live-check.sh в образ; Django — только чистые файлы (git diff HEAD перед docker cp), manage.py check, docker restart; консоль — rsync assets/ в volume demo-admin-1. После выката: telegram-discover --city kzn вручную, принять один канал, дождаться чтения, проверить страницу канала и ленту стенда. Отчёт о выкате — артефакт на salam0nn.ru/artifacts (тема «Сборщики событий»).

Приёмка владельцем: страница «Сборщики» → «Найти каналы» для Казани → список с метриками → «Читать» → карточки на странице канала → карточка на стенде; на странице канала видны пометки «снята: пост удалён» после удаления тестового поста.


10. Открытые вопросы (не блокируют; дефолты в скобках)

  1. Порог темпа 1,5 поста/нед и «мало данных» < 8 постов — оставить константами или вынести в настройки источника в консоли? (константы, менять кодом.)
  2. Снежный ком по постам, которые не стали карточками (они не хранятся, П-1 в docs/38) — оставить только путь (1) через stats["mentions"]? (да.)
  3. Перенос с новой датой: обновлять сеанс автоматически или только помечать? (обновлять + пометка, как в §6.3; владелец решил «снять/сдвинуть + оператору».)
  4. Событие с двумя источниками, у которого умер только telegram-пост, — снимать пометку «источник удалён» с карточки на стенде? (не снимать с ленты, пометку показать только на странице канала.)
  5. Кнопка «Найти каналы» для всех городов сразу (city=all)? (нет, один город за раз; cron идёт по всем.)

11. Приложение: разметка страниц t.me и каталогов (снято 21.09.2026)

11.1 Лента https://t.me/s/<канал>[?before=<id>|?after=<id>]

Пост — <div class="tgme_widget_message_wrap …"><div class="tgme_widget_message …" data-post="<канал>/<id>" …>; текст tgme_widget_message_text js-message_text; время <time datetime="2026-09-21T08:43:05+00:00"> (UTC); репост — tgme_widget_message_forwarded_from_name" href="https://t.me/<канал>[/<id>]"; CHANNEL_MARK/CONTACT_MARK — как в http_client.py. Страница без tgme_widget_message_wrap и с приглашением — looks_like_no_preview. Альбомы считаются одним постом, поэтому на странице бывает 12–18 «постов», а не 20: останавливать листание по датам или по отсутствию новых id, а не по < 20 (грабля пробы 21.09).

11.2 Счётчики и описание канала

<div class="tgme_channel_info_counters">
  <div class="tgme_channel_info_counter"><span class="counter_value">635</span> <span class="counter_type">subscribers</span></div>
  <div class="tgme_channel_info_counter"><span class="counter_value">1.2K</span> <span class="counter_type">photos</span></div> …
<meta property="og:description" content="Казань | Куда пойти? - Самые свежие обзоры …">
<meta property="og:title" content="Казань. Куда пойти?">

counter_value: 635, 2.86K, 1.2M → число (K=10³, M=10⁶).

11.3 Каталоги

11.4 Одиночный пост https://t.me/<канал>/<id>?embed=1

11.5 Лента, привязанная к посту https://t.me/s/<канал>/<id>

Отдаёт обычную ленту (≈ 14–20 постов) с этим постом внутри и соседями с обеих сторон (moscowafishi/17185 → посты 17175…17193); разметка как в §11.1. Годится для проверки нескольких живых карточек одним запросом (§6.2, оптимизация).