Версия 1.0, 21.09.2026. Основание — разведка docs/39 и семь решений владельца
(21.09.2026, все — по рекомендации). Слой Telegram описан в docs/37 (Т-1…Т-43),
страница канала — в docs/38, ADR — docs/adr/0003-telegram-layer.md.
docs/internal/00-index.md, 02-ingestion-core.md
(§7 CLI, §8 расписание), 03-ingestion-collectors.md (раздел telegram),
09-data-flow.md, 11-gotchas.md (разделы «Дедупликация», «Сеть», «Django»,
«Импорт каталога в стенд»).docs/37 §5) действуют. Новые правила этой спецификации —
Т-44…Т-60 (черновики в docs/39 §6, §9.3 здесь уточнены и обязательны).# Т-53).Решения владельца 21.09.2026 (обязательны):
| № | Вопрос | Решение |
|---|---|---|
| Р-1 | Источники кандидатов на старте | Только бесплатные: сайты-каталоги, репосты и упоминания в читаемых каналах, угадывание имён. Яндекс, TGStat, аккаунт Telegram — не подключать |
| Р-2 | Кто включает найденный канал | Только оператор, из списка «Предложенные каналы» |
| Р-3 | Частота поиска | Раз в неделю на каждый город + кнопка «Найти каналы» |
| Р-4 | Порядок | Общие городские афиши; узкие темы — через репосты и упоминания найденных каналов |
| Р-5 | Глубина первого чтения | 45 дней; кнопка «Прочитать глубже» — до 90 дней; дальше никогда |
| Р-6 | Пост удалён или «отменено» | Карточку снять с ленты автоматически и показать оператору с пометкой |
| Р-7 | Перепроверка живых карточек | Раз в сутки для событий в ближайшие 14 дней, раз в 3 дня для остальных |
Цель. Оператор перестаёт искать каналы руками: система сама приносит кандидатов по городу, проверяет их и предлагает; принятые каналы читаются как сейчас. Чтение канала ограничено по дате, а не по числу страниц; повторные чтения добавляют новое и обновляют уже собранное (правки, удаления, отмены, переносы).
Делаем: таблица кандидатов и их автопроверка; три бесплатных источника кандидатов; блок «Предложенные каналы» в консоли; еженедельный запуск и кнопка; глубина первого чтения 45/90 дней; сверка уже прочитанных постов; ежедневная проверка живых карточек одиночным запросом; обработка удалений, отмен и переносов; счётчики.
Не делаем (Р-1, Р-2 и границы слоя): платные и аккаунтные источники (Яндекс API, TGStat API, Telethon); автоматическое включение канала; чаты и закрытые каналы; модель; правки продакшена cloud.ru; перенос архитектуры реестра (он остаётся данными в базе сборщиков, консоль — проброс через Django).
Города: те, где есть включённые каналы или заданы источники кандидатов;
на старте msk, spb, vrn, kzn (слаги каталогов — §4.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 на стенде
(правки оператора поверх, замок публикации) — на него опираемся, а не правим.
┌──────────────────────── раз в неделю (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, поля у ссылок источника, экспорт).
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 без проверки).
Модуль 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 — источник вспомогательный.
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):
check_status=no_preview, verdict=rejected_auto, причина «нет открытой ленты»,
snooze_until=+60 дней. «Contact»-страница при том, что канал ранее отвечал
(check_status был ok) → throttled, повтор через 6 ч, вердикт не меняется.posts_on_page < 8 → few_data, повтор через 30 дней.
event_share < 0.30 → rejected_auto («анонсов N %»).city_share < 0.20 и город не встречается в title/about →
rejected_auto («другой город»).last_post_at старше 30 дней → rejected_auto («не писал N дней»);
posts_per_week < 1.5 → rejected_auto («пишет редко»); subscribers < 300
→ не отсев, пометка small=true в categories-json (показывается оператору).verdict=proposed, verdict_reason — сводка («анонсов 86 %, 3,6
поста/нед, 1 170 подписчиков»).rejected_auto перепроверяется не раньше snooze_until (60 дней);
rejected_by_operator — 180 дней; proposed, не решённые оператором 30 дней,
перепроверяются на следующем запуске (свежесть могла уйти). Каждая проверка
переписывает метрики; история не хранится (достаточно updated_at).Пороги подтверждены пробой 21.09 (docs/39 §3): событийные каналы 0,45–1,0,
новостные/«подслушано»/ресторанные 0,00–0,06; порог темпа 1,5 (а не 2) — чтобы
не терять «Бизнес-мероприятия Казань» (1,8/нед, 0,8 анонсов).
run.py) — раз в неделю и по кнопке (Р-3)discover(city_code, *, sources=("catalogs","snowball","guess"), limit_checks=60,
dry_run=False, should_stop=None) -> dict:
upsert в
telegram_candidates (новое имя → verdict=new... хранить как check_status=new,
verdict=few_data до проверки; известное — обновить last_seen_at, found_from
не затирать).check_status=new, либо snooze_until истёк, либо
proposed старше 30 дней. Не больше limit_checks (Т-45: ≤ 60 за проход),
порядок: новые → просроченные proposed → остальные; имена из telegram_channels
пропускаются.check.py), записать вердикты. Между запросами пауза клиента;
«Contact»-страница два раза подряд → остановить проход по городу
(stats["throttled"]=True), остаток — на следующий запуск.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 не используется — он проверяет, что код источника — сборщик).
POST /api/export/telegram/candidates/{username}/accept
(added_by — оператор): registry.add_channel(session, username,
city_code=cand.city_code, added_by=…, note=f"из предложенных: {found_via}"),
затем _start_channel_read(username, city) (существующий), кандидат →
verdict=accepted. Проба _probe_channel при этом не нужна — метрики свежие,
но если last_checked_at старше 7 дней, выполнить пробу как при ручном
добавлении (Т-2).POST …/reject с необязательной причиной (≤ 255):
rejected_by_operator, snooze_until=+180 дней, decided_by/at.add_channel из поиска, даже при идеальных
метриках.collect(): depth_days (по умолчанию DEFAULT_DEPTH_DAYS = 45),
max_depth_pages (по умолчанию 15; при deep=True — depth_days=90,
max_depth_pages=40). history_pages остаётся как жёсткий потолок для
совместимости, но семантика первого чтения теперь: листать ?before= пока
самый старый пост страницы не старше now − depth_days и страниц < потолка.
Дальше 90 дней не читать никогда (depth_days обрезается до 90).last_post_id есть) глубину не использует: листает до
last_post_id, но не дальше depth_days и не больше max_depth_pages
(защита от канала, который выложил 400 постов за 12 часов)._walk_channel возвращает и pages, и oldest_seen_at (для статистики и
реестра: depth_reached_at).POST /api/export/telegram/channels/{username}/read?deep=1 →
_start_channel_read(username, city, deep=True) → params={"channels": u,
"depth_days": 90, "max_depth_pages": 40, "refresh_all": False, "deep": True};
при deep посты старше last_post_id разбираются (иначе они отсеются как
«уже виденные»), known_digests по-прежнему отсекает неизменившийся текст.
Реестр: deep_read_at (timestamptz null); повторное «глубже» раньше чем через
7 дней — 409 «уже читали глубже N дней назад».past/beyond_horizon не меняется: старые посты дают карточки только
при будущей дате (проба 21.09: посты старше 30 дней — 0–1 будущее событие на
канал за 60 дней истории, docs/39 §9.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, причина «пост
изменён: больше не анонс»).
Новый модуль 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).
Исходы:
exists=False → source_state=gone, source_checked_at=now, в extras
события source_note="пост удалён"; экспорт снимает с ленты (§6.4). Событие с
другими живыми источниками (склейка с KudaGo/Timepad) с ленты не снимается —
только если все ссылки источников gone/cancelled (или единственная).exists=True, digest совпал → source_checked_at=now.exists=True, digest отличается → как правка (§6.1): classify/extract_event/
normalize_post → upsert через persist.upsert_event(...) (тот же путь, что у
ctx.flush), preview_hash обновляется, Т-57 при смене даты/места/названия;
если новый kind ∉ CARD_KINDS → cancelled.Оптимизация (не обязательна в первой версии): https://t.me/s/<канал>/<id>
отдаёт ленту вокруг поста (≈ 20 постов, §11.5) — одним запросом можно сверить
несколько живых карточек с близкими id; удаление в этом режиме — отсутствие
data-post при наличии соседей с меньшим и большим id.
В collect() посты с kind == reminder, где REMIND сработал на отмен|перенос|
переносится (вынести в rules.py: cancel_signal(text) -> "cancel" | "move" |
None), не выбрасываются, а сопоставляются с карточкой того же канала:
telegram с тегом tg:<канал> за последние
60 дней публикации и с сеансом не раньше now − 1 день.normalize_key из
interest_map, стоп-слова, ≥ 3 символов), Jaccard ≥ 0.5 или
difflib.SequenceMatcher(None, a, b).ratio() ≥ 0.6; при нескольких — с
ближайшей датой к датам из текста напоминания (find_dates), если дат нет —
самая близкая по времени публикации.cancel → source_state=cancelled, extras.source_note="отменено: <ссылка
на пост>", экспорт снимает с ленты (§6.4). move с датой (find_dates
нашёл явную будущую дату) → обновить сеансы карточки (sessions=[новая],
time_known по тексту), Т-57 source_changed.fields=["date"], пометка
«перенос» оператору; move без даты → только пометка source_note="перенос,
дата не указана", с ленты не снимать.stats.filter_reasons.reminder_unmatched, ничего
не делать (как сейчас).<rev>_source_link_state.py: в event_source_links добавить
source_state varchar(16) NOT NULL DEFAULT 'ok', source_checked_at timestamptz
NULL, индекс (source_id, source_state, source_checked_at); модель
EventSourceLink соответственно.export.py (запрос карточек и _card): подтянуть source_state первичной
ссылки и признак «все ссылки мертвы» (bool_and(source_state <> 'ok'));
feed_ready = ... and not all_sources_dead; в карточку добавить
"source_state": "ok"|"gone"|"cancelled" и "source_note"; включить
source_state в расчёт content_hash — иначе стенд увидит «не изменилось»
и с ленты не снимет (_apply_card: при совпавшем хеше строка не
переписывается). Стенд: при feed_ready=False _apply_card ставит
feed_eligible=False, is_published=False (если нет publish_locked) —
этого достаточно, правок стенда не требуется; is_active не трогается,
карточка не исчезает, оператор может вернуть её решением на стенде.maintenance purge-raw (90 дней), отдельной уборки не нужно.В 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.
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.
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 у чтений, действия —
в аудит (как у реестра).
Никаких новых прав: принятие/отказ = ingest.policy, запуск поиска и «глубже» =
ingest.run. Проверить rbac.py на паритет с urls.py (есть тест паритета —
test_rbac_routes, если он в репозитории; иначе добавить в §8).
backend/admin-panel/assets/sections/ingest.js, ingest-telegram.js)Блок «Предложенные каналы» — новая панель сразу под «Telegram-каналы» (та же
страница «Сборщики», тот же стиль panel, префикс классов .tgc-):
ingest.run; пока идёт поиск — «Ищем…», кнопка выключена, статус из
/discovery/status опрашивается раз в 5 с до done|error; результат —
toast «Найдено N, предложено M»).event_share убыв.): канал (@name со ссылкой на
t.me в новой вкладке, название под ним, при small — серая пометка «мало
подписчиков»), подписчики, постов в неделю, доля анонсов (в процентах), темы
(до трёх слов из словаря: «детское», «театр», «лекции»…), откуда найден
(«каталог», «репост из @x», «по имени»), последняя проверка (ingestFmtAgo),
кнопки «Читать» и «Не подходит» (право ingest.policy). Строка раскрывается
по клику — пять последних постов с видом (как на странице канала, повторно
использовать разметку renderTelegramChannel, не копировать)..empty —
docs/38 ловушка).toast с текстом detail, как в bindIngestTelegram.ingest-telegram.js): чипы у строки — «снята: пост удалён»,
«снята: отменено», «источник изменился: дата/место/название», «перенос без
даты»; фильтр «требуют внимания»; в шапке — счётчики §6.5.Тексты — русские, без слов «digest», «upsert», «verdict»; состояния канала и
карточек — словами, как уже сделано (tgStatusOf).
Все — в 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.
Ветка 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 (тема
«Сборщики событий»).
Приёмка владельцем: страница «Сборщики» → «Найти каналы» для Казани → список с метриками → «Читать» → карточки на странице канала → карточка на стенде; на странице канала видны пометки «снята: пост удалён» после удаления тестового поста.
docs/38) — оставить только путь (1) через stats["mentions"]? (да.)city=all)? (нет, один город
за раз; cron идёт по всем.)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).
<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⁶).
href="https://t.me/<name>" (иногда
t.me/s/<name>); страница подборки — <title>Афиша города Казань в ТГ: Куда
сходить. 13 чатов и 47 каналов</title>; robots Disallow: *?*.href="/channel/@<name>", пагинация href="/city/Kazan?page=2",
страницы категорий /category/<slug> (общие, без «афиши»); robots разрешает.t.me — 000.https://t.me/<канал>/<id>?embed=1<div class="tgme_widget_message … js-widget_message" data-post="<канал>/<id>",
текст tgme_widget_message_text js-message_text (у поста без текста —
отсутствует), <time datetime="…">, фото tgme_widget_message_photo
(в альбоме — по одному на фото). Класс text_not_supported_wrap есть у всех
embed-страниц — не признак.<div class="tgme_widget_message_error" dir="auto">Post not found</div>,
без data-post.https://t.me/s/<канал>/<id>Отдаёт обычную ленту (≈ 14–20 постов) с этим постом внутри и соседями с обеих
сторон (moscowafishi/17185 → посты 17175…17193); разметка как в §11.1.
Годится для проверки нескольких живых карточек одним запросом (§6.2,
оптимизация).