
Стоит сказать «я сделал агрегатор новостей», как собеседник уже представляет RSS-читалку с кнопкой «обновить» и мысленно ставит тебе диагноз «изобрёл велосипед, причём квадратный». Я и сам так начинал. А потом обнаружил скучную правду: собрать ленту легко, невозможно по ней понять, что за сутки реально произошло в нише. Сто источников аккуратно превращаются в сто вкладок, и ты снова сидишь и читаешь всё руками, как в каменном веке до RSS.
ContentCombine вырос из желания убрать ровно одну операцию — чтение. Машина собирает материалы из разных источников, оценивает их, склеивает повторы в сюжеты, отделяет кейсы от проходных анонсов, переписывает отобранное под тон ниши и выкладывает в Telegram и Google Sheets. Я включаюсь там, где нужно редакторское решение, а не там, где нужно героически пролистать ещё двести заголовков и почувствовать себя занятым.
Сначала движок работал на игровых новостях. Потом я перенёс его на SEO и AI: заменил источники, словари и правила виральности, не переписывая ядро конвейера. А дальше началось самое интересное — новая ниша быстро показала, какие эвристики были универсальными, а какие только притворялись. На этом движке и крутится ежедневный дайджест лучших новостей, кейсов и постов из Telegram-каналов. Как это устроено и где оно с удовольствием ломалось — дальше.
Это мультинишевый агрегатор и редакторский конвейер. Путь от сырого источника до публикации выглядит так:
источники → сбор → нормализация → скоринг → дедуп → сюжеты → кейсы → редакторская доска → публикацияНа этом пути система отвечает редактору на пять вопросов: что всплыло за сутки, какие темы повторяются у разны
х источников, где реальный тренд, а где одинокая публикация в пустоту, что сохранить в кейсы и что отправить в дайджест. Есть и шестой, служебный: какие источники сегодня сломались или начали тащить новости из 2019 года под видом свежих.
Цель у меня была неприлично амбициозная для пет-проекта: оставить систему работать без няньки. Звучит как слайд из питча, поэтому сразу обезврежу. В идеальном режиме человек правда только подтверждает итог. Но чтобы до этого идеального режима добраться, пришлось сделать гору совершенно нефотогеничных вещей: health-мониторинг источников, watchdog, circuit breaker, ретраи, фильтр свежести, карантин для сломанных фидов и ручную разметку кейсов. Автономность — это не магия и не «одна гениальная функция», это длинный список занудной инфраструктурной работы, которую все обычно откладывают на «потом» и не делают никогда.

Обычная читалка живёт в двух действиях: собрала ссылки, показала список. Между «собрала» и «показала» ContentCombine успевает вставить ещё семь, и весь смысл именно в них.
Обычный агрегатор | ContentCombine |
|---|---|
показывает поток | показывает приоритеты |
считает статьи | считает независимые источники |
ложится от одного плохого фида | отключает источник и проверяет позже |
не знает нишу | использует нишевые сущности и триггеры |
не отделяет кейсы | хранит кейсы отдельно |
не следит за свежестью | чистит старьё |
заточен под одну тему | переносится на новую нишу |
только читает | выводит в Telegram, Sheets, XLSX |
Вся разница упирается в одно слово — приоритет. RSS-читалка вываливает поток в обратном хронологическом порядке и великодушно оставляет отбор тебе. Здесь отбор — обязанность системы: она считает не сколько статей вышло по теме, а сколько независимых источников её подхватили, отличает серию перепечаток одного блога от настоящего тренда и не уходит в обморок, когда очередной фид отдаёт 403 вместо XML.

Вся конструкция держится на одной формуле:
универсальный движок + нишевой пакет = агрегатор под новую нишу
Движок принципиально ничего не знает про конкретную тему. В нём живут парсеры, планировщик, база, дедупликация, фреймворк скоринга, кластеризация в сюжеты, контроль свежести, source health, watchdog, circuit breaker, очереди ретраев, дашборд, экспорт и публикация. Этот набор одинаково равнодушен и к новостям про GTA 6, и к апдейтам Google.
Ниша же — это просто данные. Нишевой пакет лежит в папке niches/<ниша>/: источники, словарь сущностей, словарь виральных триггеров, стоп-слова, веса скоринга, правило «что считать кейсом», тон и промпты рерайта. Чтобы превратить игровой агрегатор в SEO-комбайн, я не переписывал ядро, а заменил список источников на 235 SEO-площадок, подменил словари и передеплоил. Тот же движок, другой нишевой пакет. А потом уже на живых данных стало видно, какие параметры надо выносить из кода в конфиг.
Если собрать нишевой пакет в один файл, он выглядит примерно так (часть этого пока живёт в коде — см. «Что дальше»):
niche: seo
sources:
- name: Search Engine Land
type: rss
url: https://searchengineland.com/feed
weight: 1.0
scoring:
entity_weight: 0.18 # сущность — слабый якорь, а не драйвер склейки
tfidf_floor: 0.24 # порог лексической близости для сюжета
min_sources_for_storyline: 2 # тренд = ≥2 независимых источника
case_rules:
tags: [research, case, исследование]Параметры entity_weight, tfidf_floor и min_sources_for_storyline ещё всплывут в разделе про грабли — именно из-за них перенос на SEO и оказался не таким бесшовным, как обещала формула.
Мысль вроде очевидная, но с первого раза в неё не верят, потому что индустрия приучила к обратному. Универсальность — не в том, что один всемогущий промпт «понимает любую тему» (этим сейчас торгуют на каждом углу). Она в том, что код конвейера вообще не подозревает, про какую тему он работает, а вся предметная область вынесена наружу, в данные. Где именно проходит граница между движком и нишей — вопрос коварный, и я несколько раз самоуверенно ошибался в том, где она лежит. Об этом будет отдельный, довольно болезненный раздел.
┌─────────────────────┐
│ Источники │
│ RSS / HTML / TG / BS│
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Нормализация │
│ title/url/date/tags │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Скоринг │
│ entities/triggers │
│ freshness/headline │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ Дедупликация/сюжеты │
│ TF-IDF + sources │
└──────────┬──────────┘
↓
┌───────────────────┼───────────────────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Тренды │ │ Кейсы │ │ Лента │
└────┬─────┘ └────┬─────┘ └────┬─────┘
↓ ↓ ↓
┌──────────────────────────────────────────────────┐
│ Выходы: Telegram / Google Sheets / XLSX │
└──────────────────────────────────────────────────┘
Схема 1. Путь материала: источники → нормализация → скоринг → дедуп и сюжеты → три поверхности → выходы.
Это, к счастью, честная схема, а не диаграмма из шести прямоугольников со словом «AI» в центральном. Поток идёт сверху вниз и ровно один раз ветвится. Источник любого типа сначала приводится к единой структуре: заголовок, ссылка, дата, источник, теги, сущности. Дальше скоринг проставляет важность, дедупликация схлопывает повторы и собирает сюжеты, а на выходе материал расходится по трём поверхностям — Тренды, Кейсы и Лента. Оттуда он уходит в выходные адаптеры: Telegram, Google Sheets или выгрузку в XLSX.
Каждый слой дальше разберу по отдельности. Скоринг, сюжеты и свежесть — самые спорные узлы, и именно на них универсальность движка проверяется на прочность, а заодно выясняется, что «универсальный» и «работает на новой нише без правок» — не всегда одно и то же.

Тезис «человек только подтверждает» держится не на честном слове, а на слое защиты вокруг каждой точки, где реальный мир способен уронить пайплайн. Источник, внешний API, база, поток, фоновая задача, экспорт — что-нибудь из этого отвалится обязательно. Вопрос лишь в том, узнаешь ли ты об этом утром по подозрительной тишине в канале или система разрулит сама и промолчит.
Когда я впервые сел мерить стабильность, по моей внутренней шкале стабильности вышло около 6.3 из 10: один зависший парсер мог утянуть за собой весь цикл, а редеплой в неудачный момент — потерять задачу. Цель была вытянуть примерно до 9 из 10. Не до мифической стопроцентной надёжности, в которую верят только до первого инцидента, а до состояния «закрыл дашборд и не думаешь о нём». Дошёл за счёт нескольких подсистем, каждая по отдельности невзрачная, а вместе они и есть та самая автономность.
┌──────────────────────────┐
│ Scheduler │
│ запускает сбор/экспорт │
└────────────┬─────────────┘
↓
┌───────────────────────────────────────────────────────┐
│ Watchdog │
│ heartbeat подсистем · зависшие задачи · zombie-потоки │
│ >5 zombie → форс-рестарт → Railway поднимает заново │
└──────────────────────┬────────────────────────────────┘
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Source Health│ │ Circuit │ │ Database │
│ 5 фейлов → │ │ Breaker │ │ reconnect │
│ auto-disable │ │ LLM/Keys/GT │ │ per-thread │
│ cooldown → │ │ 5 fail → │ │ timeout │
│ auto-restore │ │ wait → reset │ │ 120s │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
↓ ↓ ↓
┌───────────────────────────────────────────────────────┐
│ Pipeline Recovery │
│ ретраи рерайта · очередь Sheets · застрявшие задачи │
│ startup healthcheck · graceful shutdown · force exit │
└───────────────────────────────────────────────────────┘Схема 2. Слой защиты: watchdog сверху следит за всем, специализированные предохранители — ниже.
Центральный сторож следит за heartbeat подсистем: планировщик и веб-сервер обязаны регулярно отмечаться. Если компонент молчит дольше порога, watchdog считает его зависшим и пытается восстановить. Отдельно он считает zombie-потоки — те, что ушли в бесконечное ожидание и которые Python убить из соседнего потока не может. Когда зомби набирается больше пяти, watchdog не геройствует, а делает os._exit(1): процесс падает, Railway видит мёртвый контейнер и поднимает заново. Грубо, зато надёжно — перезапуск лечит то, что иначе пришлось бы расследовать руками в три часа ночи.
Платные и внешние сервисы — LLM, частотность Keys.so, Google Trends — спрятаны за предохранителем. Пять фейлов подряд, и вызовы к сервису отключаются на пять минут, потом пробуются снова. Перед каждым обращением к LLM идёт проверка состояния предохранителя, так что упавший гейтвей не превращается в сотню таймаутов по 45 секунд. Ядро от этого не страдает: скоринг, лента и дайджест не зависят от LLM напрямую, и если предохранитель открыт, пропадают только необязательные надстройки вроде рерайта.
PostgreSQL получает по соединению на поток — одно глобальное многопоточный сервер загонял бы в гонку. Каждое соединение переподключается само и живёт с statement_timeout в 120 секунд, чтобы один тяжёлый запрос не подвесил остальное. HTTP-сервер многопоточный, на демон-потоках.
Рерайт повторяется дважды, прежде чем сдаться. Упавший экспорт в Sheets уходит в очередь и ретраится. Задачи, застрявшие в «running» дольше получаса (привет, прерванный редеплой), сбрасываются обратно в «pending». На старте сервис проверяет базу, прежде чем принимать запросы, а на остановке даёт себе тридцать секунд на корректное завершение и выходит принудительно.
Главная мысль раздела простая. Автономность — это не одна умная функция и не магический try/except на главном цикле. Это занудный слой защиты вокруг каждого места, где реальный мир ломает пайплайн: источник, API, база, поток, задача, экспорт. Source health, который отключает сломанные источники, заслуживает отдельного разговора — он в разделе про источники.
«Минимум человека» — не лозунг, а конкретный режим работы. Точнее, два режима, и второй из них я считаю более честным аргументом, чем первый.
Полный конвейер умеет вести материал сам: ревьюит новые записи, пересчитывает тех, у кого скор почему-то остался нулевым, и запускает рерайт там, где система сама посчитала его уместным. На выходе получается материал, готовый к редакторской доске и публикации, без единого ручного клика по дороге.
новая запись
→ авто-ревью
→ скоринг
→ если score = 0, пересчитать
→ если рерайт рекомендован, переписать
→ готово к редакторскому выходуА вот это — мой любимый режим, потому что он отвечает на главный неудобный вопрос к любому «AI-проекту»: что останется, если выдернуть из розетки LLM. Ответ: почти всё. Сбор, нормализация, скоринг, дедупликация, сюжеты, кейсы, экспорт, дайджест — всё это работает без единого платного вызова. LLM в системе не несущая стена, а съёмная панель: он улучшает рерайт и пару надстроек, но радар по нише собирается и без него.
Для статьи это важнее, чем кажется. Проект, который можно поднять без платных зависимостей и всё равно получить рабочий ежедневный дайджест, гораздо проще повторить и проверить, чем красивую демку, намертво пришитую к чьему-то API-ключу.
Поэтому рабочую сборку движка я выложил в открытый доступ — contentcombine-demo. Это игровая ниша, урезанная до пяти публичных RSS, чтобы репозиторий клонировался и поднимался за минуту: pip install -r requirements.txt, python main.py. Дальше на SQLite без единого ключа работает весь каркас — сбор, нормализация, скоринг по сущностям и триггерам, дедупликация, сюжеты, контроль свежести и веб-дашборд. LLM, Google Trends, Keys.so, Sheets и Telegram остаются опциональными надстройками через .env. Одна честная оговорка про нишу: в демо она задаётся по текущей раскладке — источники в config.py, сущности в nlp/, триггеры в checks/. Это та самая размазанность между кодом и конфигом, которую я свожу в единый niche.yaml (см. «Что дальше»): демо показывает состояние «как сейчас», а не «как задумано».
В SEO-нише у меня сейчас 235 источников: 126 RSS, 47 Telegram-каналов, 38 главных страниц сайтов, 22 ленты Bluesky и 2 sitemap. Из них реально доставляют новости около 206 — остальные либо молчат, либо переведены на пониженную частоту. Звучит как «много контента», а на деле это 235 независимых способов сломаться. Источник может умереть, сменить вёрстку, начать отдавать 403, прислать вместо новостей навигационное меню или потащить из архива статьи 2019 года с видом свежих.

Поэтому каждый источник живёт под присмотром source health. Подсистема считает подряд идущие сбои по каждому источнику отдельно. Пять промахов — и источник отключается, чтобы не тратить на него попытки и не засорять логи. Через десять минут карантина система пробует его снова: ответил нормально — источник тихо возвращается в строй, не ответил — досиживает следующий круг. Текущее состояние видно в эндпоинте /api/health/sources и в отдельной вкладке «Здоровье» на дашборде, так что не нужно гадать, почему конкретный канал замолчал.
Честная деталь, без которой картинка была бы слишком глянцевой: заметная часть источников добавлялась как homepage по принципу «вроде тут есть новости, разберёмся по ходу». Часть из них оказалась пустышками. Но это и не проблема — движок сам отвалидирует и отключит мёртвые, мне не нужно вычищать список руками. Список источников самоочищается, и это ровно то поведение, ради которого вся возня с source health и затевалась.
«Лучшие новости дня» звучит так, будто внутри сидит маленький редактор с тонким вкусом. Внутри сидит сумма сигналов, и это, пожалуй, к лучшему — вкус плохо масштабируется на 3500 материалов.
Каждый материал получает скор из нескольких слагаемых: сработавшие виральные триггеры со своими весами, найденные сущности с бустом по тиру важности, событийные комбо, качество заголовка, свежесть и число независимых источников, подхвативших тему.
Сердце скоринга — словарь виральных триггеров. Это около тридцати категорий событий, у каждой свой вес и свой список ключей на разных языках. Вес отражает не «важность темы вообще», а то, насколько событие двигает рынок прямо сейчас. Вот верхушка таблицы для SEO-ниши:
Категория | Вес | Примеры ключей |
|---|---|---|
Санкции | 45 | manual action, deindexed, «попал под фильтр», «накрутка ПФ», «Минусинск», «Баден-Баден» |
Апдейт алгоритма | 40 | core update, helpful content update, «Тайфун», «Вега», «Оригами» |
Волатильность выдачи | 38 | ranking drop, traffic crash, «обвал трафика», «восстановление трафика» |
AI-поиск | 30 | ChatGPT, Perplexity, AI Overviews, «нейроответы», YandexGPT |
Регуляции | 26 | antitrust, monopoly, «антимонопольный иск», «разделение Google» |
AI-агенты | 24 | ai agent, agentic, OpenAI Operator, «ии-агент» |
Срочное | 20 | api leak, «google confirms», «подтвердил Google», breaking |
Zero-click | 20 | zero-click, «ноль кликов», «падение переходов из-за AI Overviews» |
Здесь видно, почему скоринг получился «по-русски». Именованные фильтры Яндекса — «Минусинск», «Баден-Баден», «Тайфун», «накрутка ПФ» — это отдельные ключи с тяжёлым весом, потому что Рунет реагирует на них сильнее, чем на любой апдейт Google. Матчинг идёт по подстроке в нижнем регистре, так что «фильтра» и «фильтром» ловятся тем же ключом «фильтр»: морфологию я перечисляю формами, а не разбираю стеммером.
Поверх триггеров — три механики, без которых скор быстро выродился бы в накрутку.
Первое — дедуп по категории. Из триггеров с общим префиксом (penalty_*, algoupd_*) в зачёт идёт только самый весомый. Иначе заметка, где десять раз сказано «фильтр», «санкции», «пессимизация», набила бы очки на одной теме. В категории остаётся один, самый тяжёлый, остальные обнуляются.
Второе — сущности и комбо. Найденная сущность добавляет буст по тиру важности: S даёт +30, A +15, B +8, C +3. А пары «событие + крупный игрок» считаются отдельно, потому что вместе значат больше суммы частей: утечка плюс крупный тайтл — +45, закрытие крупной студии — +30, скандал плюс крупный тайтл — +25, иск плюс крупная компания — +20. Эти комбо переехали из игровой ниши в SEO без единой правки: Google API leak — та же утечка, антимонопольный иск — тот же иск.
Третье — свежесть как множитель. Скор тает с возрастом: новость младше трёх часов идёт с полным весом, до 12 часов — ×0.9, до суток — ×0.75, до двух суток — ×0.5, дальше — ×0.3. Вчерашняя сенсация не толкается локтями с сегодняшней, а итог всё равно упирается в потолок 100.
На примере арифметика собирается так. Заголовок «Google confirms March 2026 core update is now rolling out»: «core update» — тяжёлый триггер апдейта (+40), «google confirms» — срочное подтверждение (+20), Google — сущность верхнего тира (+30). Уже 90, плюс тему в тот же день подхватили несколько независимых источников. Скор упирается в потолок 100, новость свежая, множитель 1.0 — и заголовок уходит в самый верх ленты, одинаково на русском, английском и немецком. А «10 рецептов пирога», забредшее из плохо настроенного фида, не находит ни одного триггера и ни одной сущности — и честно получает свой ноль.

Чтобы скоринг не оставался чёрным ящиком, у него есть отдельный экран. Вкладка «Виральность» прогоняет ленту через ту же формулу и показывает не итоговый балл, а его разбор: какие триггеры сработали и с каким весом, какая сущность дала буст, какая у материала тональность и теги. Видно не «90 баллов», а из чего эти 90 собрались.

Сверху — агрегаты за период: распределение по уровням, какие категории сейчас греются, преобладающая тональность и средний виральный балл по каждому источнику. Это уже не про отдельную новость, а про нишу: на этой неделе рынок гудит про апдейты и волатильность, один источник стабильно тащит высоковиральное, другой — шум. Фильтры по уровню, категории, источнику, дате и конкретному триггеру отвечают на запрос вроде «покажи всё про санкции за последние три дня».

Главная ценность экрана — триггеры правятся прямо отсюда. Вес можно поднять или опустить, ключи дополнить, триггер выключить или завести свой. Правки уходят в таблицу-оверрайд и подхватываются без передеплоя, поверх зашитых в код дефолтов. Это и есть граница «движок/ниша» в действии: настройка скоринга живёт в данных и редактируется из интерфейса, а радар из раздела ниже учится на том же — на моих решениях.
Скоринг — не каменная скрижаль. Решения, которые я принимаю руками, возвращаются в систему: одобрил, удалил, отправил в кейсы. Из этого пересчитываются веса источников, и со временем площадки, которые регулярно дают полезное, весят больше тех, что стабильно поставляют шум. Радар постепенно подстраивается под то, что я считаю стоящим, без отдельного этапа «обучите модель».
Скор можно усилить внешними данными — частотностью из Keys.so и динамикой из Google Trends. Оба слоя опциональные и платные, за тем же предохранителем: нет ключей или сервис лёг — скор считается по бесплатным сигналам. Платное улучшает, каркас держится и без него.
Скучный, но важный слой гигиены. Идентификатор новости — это md5 от нормализованного URL: я срезаю якорь, utm-метки, gclid и хвостовой слеш, прежде чем считать хэш. Поэтому одна и та же статья, прилетевшая с разными рекламными хвостами из RSS, соцсетей и рассылки, остаётся одной записью, а не тремя близнецами, между которыми потом разбирается дедупликация. Дешёвый приём, который снимает целый класс дублей ещё до того, как они появились.
Вот здесь начинается честная часть. Когда я заменил источники и словари, мне казалось, что дело сделано: движок-то универсальный. SEO так не считало. Универсальность оказалась настоящей в одних местах и красиво притворяющейся в других, и выяснялось это всегда одинаково — через тихо сломанную выдачу, которая выглядела убедительно ровно до момента, когда я в неё всматривался.
Я был уверен, что вынести нишу — это поправить один файл с сущностями. На деле скоринг опирался на два независимых слоя. Первый — именованные сущности с тирами важности, чистые данные, заменяются один в один. Второй — словарь виральных триггеров, зашитый прямо в код скоринга: десятки категорий событий со своими весами, и именно он главный драйвер очков. Через словарь сущностей он не выносится, его надо править отдельно.
Забавно вышло с комбинациями. Игровые комбо вроде «утечка плюс крупный тайтл» или «иск плюс крупная компания» я ожидал переписывать под SEO с нуля. А они подошли без единой правки: Google API leak — та же утечка, антимонопольные иски — те же иски, закрытие сервиса — то же закрытие. Универсальные усилители новости оказались действительно универсальными. Мораль для всех, кто собирает похожее: граница между движком и нишей почти никогда не проходит там, где кажется на первый взгляд. Где она проходит на самом деле, видно, только когда залезаешь в скоринг руками.
Матчинг ключей в движке нарочно примитивный. Всё приводится к нижнему регистру, совпадение ищется по подстроке, и только для совсем коротких ключей (три символа и меньше) включается проверка по границе слова, чтобы geo, aeo или икс не цеплялись к случайному мусору внутри слов.
Из этой примитивности следует приятное: русскую морфологию и разные языки не нужно переводить, их достаточно перечислить. Ключ фильтр подстрокой ловит и «фильтра», и «фильтром». А куча сигналов вообще не зависит от языка — ChatGPT, Perplexity, GPTBot, Core Web Vitals, llms.txt одинаково пишутся хоть в русском тексте, хоть в немецком. Тяжёлый NLP со стеммингом и лемматизацией я не подключал и не жалею: для технической ниши аккуратная словарная модель работает, если честно держать в голове её ограничения, а не делать вид, что это семантическое понимание.

А вот тут универсальность сломалась громко. Сюжеты строились из смеси текстовой близости заголовков и пересечения сущностей, дальше связные компоненты. Для игр работало идеально: сущности конкретные. GTA 6 и Elden Ring — это реально разные истории, общая сущность почти гарантирует общий сюжет.
В SEO сущности оказались вездесущими. «Google», «ChatGPT», «нейросети» сидят в половине новостей подряд. Две абсолютно несвязанные заметки делят сущность, пересечение по сущностям выскакивает на максимум, и его вклада в одиночку хватает, чтобы слепить пару. А дальше связные компоненты делают своё чёрное дело: одна слабая связь тянет за собой следующую, и на живых проде-данных «Google запускает рекламного агента», «клиент ушёл из Google Ads» и «немецкий суд разбирает AI у Google» собрались в один сюжет из семнадцати новостей, у которых общего — только слово Google.
Чинил, не ломая алгоритм, а подкрутив данные. Во-первых, ввёл лексический порог: без реального совпадения слов в заголовках пары нет, сущность только усиливает уже похожую пару, а не создаёт её с нуля. Во-вторых, выкинул из пересечения гипер-частые сущности — те, что встречаются больше чем в десятой части пачки. Блоб из семнадцати схлопнулся максимум до пяти, и склейки остались только там, где новости и правда об одном. Мораль: метрики похожести, настроенные на одной нише, на другой не падают с ошибкой. Они тихо выдают правдоподобный мусор, и это куда опаснее честного краша.
Починил блобы — вылезла вторая болезнь. Из четырнадцати сюжетов десять оказались собраны из одного-единственного источника: Mojeek со своими ежемесячными подборками шесть раз подряд, серия CTR-обзоров одного сервиса, три поста SISTRIX про Core Web Vitals. Текстовая близость честно сработала — заголовки правда похожи. Только это не тренд, а серия перепечаток одного автора.
Лечится семантикой, а не математикой: сюжет засчитывается, только если в нём минимум два разных источника, а сила тренда измеряется числом независимых источников, а не количеством статей. После этого осталось восемь сюжетов, все кросс-источниковые, и среди них поехали красивые кросс-языковые склейки одной истории — рекламный AI-агент Google всплыл и в английском Search Engine Land, и в русском ppc.world. Вывод дешёвый, но сильный: тренд — это согласие независимых голосов, а не громкость одного.
Дальше я чуть не ушёл оптимизировать не то. Сюжетов было восемь на полторы тысячи новостей, и это выглядело как «слишком строгая кластеризация, надо ослаблять пороги». Я уже занёс руку над коэффициентами, когда наткнулся на захардкоженный LIMIT 500 в выборке. Две трети базы просто не участвовали в кластеризации. Поднял лимит до двух тысяч — и на тех же самых порогах получил двадцать один сюжет.
Когда я всё-таки полез ослаблять пороги, блоб немедленно вернулся: на той неделе вышел Claude Fable 5, сущность «claude» стала гипер-горячей и через транзитивность снова склеила одиннадцать разных историй в один «тренд», который к тому же прошёл защиту про два источника. Правильный вывод оказался не про порог: блоб лечится низким весом сущности, а не закручиванием близости. Сущность должна быть слабым якорем, а не движущей силой склейки. Но первый урок я ценю больше: прежде чем трогать умные параметры модели, проверь тупую константу на входе. Дешёвый LIMIT испортит любую умную математику, и ты будешь долго винить математику.
Последняя грабля самая бытовая и оттого противная. RSS-парсер честно отсекал статьи старше недели. А парсеры главных страниц и sitemap тащили из листингов что угодно по возрасту, потому что фильтра там просто не было. Итог на проде получился показательный: треть новостей пришла вообще без даты (площадки без нормальной разметки даты), а среди датированных нашлись сотни старше двух недель и десятки старше трёх месяцев. Рекордсмен — пост SISTRIX про Mobile-First Indexing из 2019 года, собранный как свежак.
Чинил по всем фронтам. Научил систему доставать дату из чего получится: JSON-LD, мета-теги, тег <time>, а если совсем глухо — из самого URL, где часто торчит /2026/06/15/. Поставил фильтр возраста на все парсеры, а не только на RSS. И добавил фоновую джобу, которая отправляет устаревшее не в небытие, а в корзину: пометка «удалено», восстановить можно, через тридцать дней чистится само. Один прогон увёл в корзину под две сотни древних статей. Мораль: свежесть — это не один фильтр на входе RSS, а инвариант, который надо держать на каждом входе. Один парсер без проверки возраста отравляет всю ленту, а дата публикации на чужих сайтах — отдельная боль, где четыре способа её достать всё равно оставляют треть материалов без даты.
После SEO я стал осторожнее говорить «универсальный». Универсальность здесь не значит, что движок без настройки одинаково хорошо понимает любую предметку. Она значит другое: когда новая ниша ломает старые эвристики, я вижу, какой параметр должен переехать из кода в нишевой конфиг. Это не магия, а постепенное выдавливание предметной области наружу.
Для игр обычной ленты хватало: новость отыграла своё за сутки и спокойно ушла под фильтр свежести. В SEO так нельзя. Тут кроме новостей есть кейсы, исследования, эксперименты, разборы «просело — выросло», наблюдения из Telegram и вечнозелёные гайды. И ценность у них другая: новость про вчерашний апдейт через месяц мертва, а кейс про восстановление трафика через месяц пригодится для статьи, аудита, клиентской подборки или поста.
Поэтому в системе появился отдельный слой — пометка «это кейс». Её можно поставить руками или поймать автоматически по тегу, и она даёт материалу другое поведение: фоновая чистка свежести его не трогает, как бы он ни старел. Кейсы живут на своей вкладке и копятся в базу, из которой потом удобно собирать материал, когда он реально понадобится. По сути это разделение памяти на новостную ленту, которая обязана забывать, и архив, который обязан помнить.

Дальше отфильтрованный и оценённый поток надо куда-то отдать. И вот тут принципиальный момент: выход — это не «Telegram-бот, прибитый гвоздями к движку», а сменный слой адаптеров. Сейчас их три, и каждый показывает разную грань.
Самый недооценённый выход. Материал уезжает в таблицу с маршрутизацией по вкладкам: новости со скором выше шестидесяти попадают в NotReady, уже переписанные LLM — в Ready, сюжеты и удалённые идут своими отдельными путями, а раз в сутки в девять утра по Москве выгружаются сюжеты. Зачем таблица, когда есть дашборд? Затем, что редактор работает в привычной среде, в таблицу можно пустить команду, а можно отдать её клиенту, и никто не будет осваивать ещё один интерфейс. Google Sheets превращается в промежуточную доску, на которой удобно разбирать выдачу руками.
Здесь важно не соврать формулировкой. Это не «бот сам пишет посты из воздуха». Дайджест в Telegram — финальный шаг после сбора, скоринга, дедупликации и разметки. В ежедневный выпуск уходит лучшее из новостей, кейсов и постов других каналов, со ссылками на первоисточники и темами дня. У дайджеста несколько стилей, а общий собирается в три раздела — новости, кейсы, телеграм — и укладывается в одно сообщение. Машина делает черновую работу по отбору, человек решает, что из этого достойно подписчиков.
И только теперь — рерайт, потому что именно он превращает агрегатор в комбайн, и именно его проще всего понять неправильно. Поэтому сразу рамка: рерайт включается не на сырой поток, а на уже отфильтрованный, размеченный и оценённый материал. Это не генерация контента из воздуха, а редакторская переработка конкретного источника под тон ниши — у движка на входе настоящая новость с настоящей ссылкой, а не задача «придумай что-нибудь про SEO». К каждому переписанному материалу привязана ссылка на первоисточник: задача не присвоить новость, а быстро переработать и оформить редакторский выпуск.
Механически цепочка простая: функция рерайта берёт отобранный материал и промпты из нишевого пакета, складывает результат в таблицу статей со временем публикации, а фоновая джоба раз в минуту проверяет расписание и постит готовое. Бюджетный кап и фолбэк-цепочку моделей разберу прямо ниже — они общие для всех LLM-этапов. Сейчас выход рерайта — Telegram, но раз слой адаптеров уже есть, добавить публикацию по API в произвольную систему — будь то CMS, свой сайт или вебхук — это вопрос ещё одного адаптера, а не переписывания движка. Telegram и Sheets как раз и доказывают, что это слой, а не две случайные фичи.
Рерайт — не единственное место, где зовётся модель, и удобный повод показать: сам LLM здесь не вживлён в ядро, а подключается через тонкий клиент в нескольких точках, любую из которых можно не использовать. «Той самой нейросети, на которой всё держится», в коде нет. Есть список мест, где модель можно позвать:
прогноз тренда — оценка потенциала новости с обоснованием;
слияние дублей — собрать из нескольких заметок об одном событии одну, взяв лучшее из каждого источника;
редакторский вердикт — publish / rewrite / skip с короткой причиной;
рерайт под тон ниши — шесть стилей: новость, SEO-статья, обзор, кликбейт, короткая заметка, пост для соцсетей;
автоперевод заголовка с определением языка;
генерация поисковых запросов под анализ частотности.
Важен не сам список, а то, что модель на любом из этих этапов задаётся снаружи. Клиент совместим с OpenAI API и ходит через OpenRouter, поэтому имя модели — это переменная окружения. По умолчанию стоит дешёвая gpt-4o-mini, но LLM_MODEL переключается на Claude, Gemini, DeepSeek или Llama без единой правки логики: сегодня одна модель, завтра — та, что подешевела за ночь, и никакого релиза ради этого не нужно. Та же формула «движок один, ниша — данные», только данные здесь — какую модель и с каким промптом звать.
Падения этого слоя я заложил сразу, потому что внешний гейтвей флапает. Вызов идёт по фолбэк-цепочке: основная модель, за ней запасные из LLM_FALLBACK_MODELS, в конце — второй API-ключ на случай, когда проблема в ключе, а не в модели. Основная икнула на Cloudflare-челлендже — вызов уходит к следующей, а не возвращает «LLM недоступен». Сверху — тот же предохранитель из раздела про автономность, лимит вызовов и дневной бюджетный кап, чтобы один прогон не пролил весь бюджет.
Промпты — тоже данные. Инструкции рерайта и набор стилей подхватываются из настроек, так что тон ниши правится без передеплоя: поменял текст промпта — следующий вызов уже пишет иначе. А дайджест собирается из уже переписанных материалов детерминированно: стили «короткая заметка» и «пост для соцсетей» заточены под выпуск, но сама склейка трёх разделов в одно сообщение обходится без модели. Выдерни LLM — дайджест продолжит выходить, просто без причёсанного рерайта.
Когда сбор, отбор и выходы работают, встаёт следующий вопрос: здоров ли контур целиком и не разоряет ли он меня. На это отвечает «Аналитика» — экран про конвейер, а не про отдельную новость.
В центре — воронка пайплайна: сколько материалов спарсилось, у скольких есть скор, сколько ушло в одобренные, переписанные, выгруженные и опубликованные. Видно, на каком шаге течёт. Обрыв между «спарсилось» и «оценилось» — проблема в скоринге; между «одобрено» и «опубликовано» — в выходах. Рядом конверсия по источникам: какие площадки дают публикуемое, а какие гонят объём в корзину. Тот же сигнал, что кормит веса источников, только показанный человеку.
Дальше тренды: новостей в день и средний скор за две недели (не деградирует ли отбор), доля одобренного, горячие термины-биграммы, пиковые часы сбора, разбивка по источникам и тегам. И отдельный блок про деньги: стоимость LLM по моделям, по источникам и по версиям промптов плюс дневной тренд затрат. Видно не только что система работает, но и во сколько обходится каждый вызов и какой промпт дешевле при том же результате. Это и держит дневной бюджетный кап осмысленным, а не взятым с потолка.


Сухие цифры на момент написания: те же 235 источников (≈206 реально доставляют), около 3600 материалов в базе, ежедневный дайджест уходит в Telegram, редакторская доска лежит в Google Sheets.
Интереснее не объём, а то, что одна и та же базовая формула скоринга, без переписывания ядра, разумно ранжирует разные языки. Несколько живых примеров:
100 — Сайты выпали из индекса Яндекса после фильтра Тайфун — обвал трафика (ru)
100 — Google confirms March 2026 core update is now rolling out (en)
100 — Google bestätigt neues Core Update — Rankingverlust (de)
98 — Perplexity and ChatGPT cited as AI Overviews reshape zero-click search (geo)
0 — 10 рецептов пирога / Marvel trailer / как написать контент (шум)На smoke-наборе шумовые примеры получили 0, важные SEO-события ушли наверх, а одна и та же формула сработала на русском, английском и немецком. Для первого боевого переноса этого хватило, чтобы считать идею рабочей.
Тестовые дайджесты по SEO сейчас "щебечет" канал в тг - seoptichka (дайджест один раз в сутки из 200-300 новостей за день)
Теперь то, что обычно не показывают в постах про «я автоматизировал X». Самые поучительные баги вылезли уже на проде, в боевом дайджесте.
Вечерний крон собирает общий дайджест и постит в канал. Однажды я заметил в истории два дайджеста за вечер: один я собрал руками, и он мне нравился, второй ушёл в канал и был заметно хуже. Оказалось, крон не берёт готовый дайджест из истории, а каждый раз генерирует новый с нуля и постит именно его. Мой отобранный вариант физически не имел шанса попасть в канал. Вывод-улучшение очевиден и до сих пор в бэклоге: публиковать выбранное, а не свежесгенерированное.
Тот же самый промпт на тех же данных в двух соседних вызовах дал разный результат, и один из них приехал в канал с битым текстом — посреди фразы торчали обрывки вроде «оцениPut» и «?ographics». Модель просто икнула на конкретном вызове, а проверки на это не было: ретрай срабатывал только на пустой ответ, а не на правдоподобный мусор. Вывод: валидировать ответ модели и держать фолбэк, потому что «вернулось что-то» не равно «вернулось нормальное».
Полсотни источников отдавали HTTP 200, но не доставляли ни одной новости. Я был уверен, что у них сменился адрес фида и они отдают HTML вместо RSS, и уже собирался переписывать парсеры. Зонд опроверг гипотезу за полчаса: фиды живые и валидные, а корень был в захардкоженном окне свежести в семь дней плюс блокировки по User-Agent. Редкие источники, постящие раз в пару недель, просто не пролезали в окно. Расширил окно и добавил повторную попытку с другим User-Agent — ожило два с лишним десятка фидов. Мораль повторяет урок про LIMIT: прежде чем чинить «умное», проверь тупую константу.
И коротко то, что я бы сделал иначе с самого начала, не доводя до граблей: сразу вынес бы виральные триггеры в конфиг, а не в код; заложил бы требование разных источников в само определение тренда; с порога сделал бы сущность слабым якорем, а не драйвером склейки; держал бы фильтр свежести инвариантом на всех парсерах; и спроектировал бы выходы как слой адаптеров, а health-дашборд — как обязательный экран, а не как «инфраструктуру, которую добавим потом».
Система работает, но список «надо бы» честно длинный. Параметры ниши пока размазаны между конфигом и кодом — хочу свести всё в niche.yaml и triggers.yaml, чтобы новая ниша заводилась парой файлов. Дальше — собрать несколько готовых нишевых пакетов и проверить тезис об универсальности не на двух нишах, а на пяти.
Технически самое интересное — гонять несколько ниш параллельно в одном деплое, у каждой свой канал и свой дайджест. Отсюда же растёт гипотеза про SaaS для редакторов нишевых каналов, но я предпочитаю сначала добить инструмент для себя, а не строить продукт на воображаемых пользователях. В планах ещё третья поверхность выдачи — «Сигналы» для мелких наблюдений, которые ещё не дотянули до темы; ручная оценка качества сюжетов и накопленный датасет ложных склеек, чтобы кластеризацию можно было настраивать на реальных ошибках; новые выходные адаптеры в CMS, VK и вебхуки; и недельный отчёт «что изменилось в нише», которого мне самому не хватает.
Главный результат не в том, что я автоматизировал один Telegram-канал. Результат в том, что получился автономный мультинишевый производственный контур: один движок можно перенести на другую предметную область, если вынести нишу в источники, сущности, триггеры, тон и правила публикации.
Игры были первым боевым тестом и показали, что идея вообще работает. SEO стал вторым и быстро показал, где универсальность настоящая, а где ещё прячутся нишевые эвристики, которые тихо ломаются при переносе и выдают правдоподобный мусор вместо честной ошибки. Чинятся они не магией, а скучной доменной работой: порогами, весами, словарями, фильтрами, проверкой источников и ручным контролем там, где цена ошибки высока.
Самое полезное в этой истории — не конкретно мой SEO-дайджест. Его каждый всё равно переделал бы под себя. Полезен сам подход: взять каркас, заменить источники, переписать словари, настроить веса, определить, что в вашей нише считается трендом, кейсом, шумом и публикацией. Для iGaming, финтеха, недвижимости, медицины, промышленности или локальных новостей это будут другие сущности и другие триггеры, но контур останется тем же: мониторинг → отбор → самоисцеление → редакторская доска → публикация.
Поэтому ContentCombine для меня давно не лента новостей. Это заготовка производственного контура, которую можно довести под свои задачи: оставить только no-LLM-радар, подключить рерайт, выгружать в Sheets, публиковать в Telegram, отправлять в CMS, собирать недельные отчёты или использовать как внутренний мониторинг рынка. LLM в нём — полезная съёмная деталь, а не несущая стена.
И, кажется, именно в этом всё дело: выигрывает не тот, кто громче всех вставил в пайплайн нейросеть, а тот, кто построил вокруг неё систему, которая работает и без неё, переживает сбои, учится на редакторских решениях и достаточно гибкая, чтобы следующий человек мог разобрать её под свою нишу, а не начинать с нуля.