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

Движок принципиально ничего не знает про конкретную тему. В нём живут парсеры, планировщик, база, дедупликация, фреймворк скоринга, кластеризация в сюжеты, контроль свежести, source health, watchdog, circuit breaker, очереди ретраев, дашборд, экспорт и публикация. Этот набор одинаково равнодушен и к новостям про GTA 6, и к апдейтам Google.
Ниша же — это просто данные. Нишевой пакет лежит в папке niches/<ниша>/: источники, словарь сущностей, словарь виральных триггеров, стоп-слова, веса скоринга, правило «что считать кейсом», тон и промпты рерайта. Чтобы превратить игровой агрегатор в SEO-комбайн, я не переписывал ядро, а заменил список источников на 235 SEO-площадок, подменил словари и передеплоил. Тот же движок, другой нишевой пакет. А потом уже на живых данных стало видно, какие параметры надо выносить из кода в конфиг.
Если собрать нишевой пакет в один файл, он выглядит примерно так (часть этого пока живёт в коде — см. «Что дальше»):
niche: seosources: - name: Search Engine Land type: rss url: https://searchengineland.com/feed weight: 1.0scoring: 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 сверху следит за всем, специализированные предохранители — ниже.
Watchdog
Центральный сторож следит за heartbeat подсистем: планировщик и веб-сервер обязаны регулярно отмечаться. Если компонент молчит дольше порога, watchdog считает его зависшим и пытается восстановить. Отдельно он считает zombie-потоки — те, что ушли в бесконечное ожидание и которые Python убить из соседнего потока не может. Когда зомби набирается больше пяти, watchdog не геройствует, а делает os._exit(1): процесс падает, Railway видит мёртвый контейнер и поднимает заново. Грубо, зато надёжно — перезапуск лечит то, что иначе пришлось бы расследовать руками в три часа ночи.
Circuit breaker
Платные и внешние сервисы — LLM, частотность Keys.so, Google Trends — спрятаны за предохранителем. Пять фейлов подряд, и вызовы к сервису отключаются на пять минут, потом пробуются снова. Перед каждым обращением к LLM идёт проверка состояния предохранителя, так что упавший гейтвей не превращается в сотню таймаутов по 45 секунд. Ядро от этого не страдает: скоринг, лента и дайджест не зависят от LLM напрямую, и если предохранитель открыт, пропадают только необязательные надстройки вроде рерайта.
База и веб-сервер
PostgreSQL получает по соединению на поток — одно глобальное многопоточный сервер загонял бы в гонку. Каждое соединение переподключается само и живёт с statement_timeout в 120 секунд, чтобы один тяжёлый запрос не подвесил остальное. HTTP-сервер многопоточный, на демон-потоках.
Конвейер
Рерайт повторяется дважды, прежде чем сдаться. Упавший экспорт в Sheets уходит в очередь и ретраится. Задачи, застрявшие в «running» дольше получаса (привет, прерванный редеплой), сбрасываются обратно в «pending». На старте сервис проверяет базу, прежде чем принимать запросы, а на остановке даёт себе тридцать секунд на корректное завершение и выходит принудительно.
Главная мысль раздела простая. Автономность — это не одна умная функция и не магический try/except на главном цикле. Это занудный слой защиты вокруг каждого места, где реальный мир ломает пайплайн: источник, API, база, поток, задача, экспорт. Source health, который отключает сломанные источники, заслуживает отдельного разговора — он в разделе про источники.
Два режима: full-auto и no-LLM
«Минимум человека» — не лозунг, а конкретный режим работы. Точнее, два режима, и второй из них я считаю более честным аргументом, чем первый.
Full-auto
Полный конвейер умеет вести материал сам: ревьюит новые записи, пересчитывает тех, у кого скор почему-то остался нулевым, и запускает рерайт там, где система сама посчитала его уместным. На выходе получается материал, готовый к редакторской доске и публикации, без единого ручного клика по дороге.
новая запись→ авто-ревью→ скоринг→ если score = 0, пересчитать→ если рерайт рекомендован, переписать→ готово к редакторскому выходу
No-LLM
А вот это — мой любимый режим, потому что он отвечает на главный неудобный вопрос к любому «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 (см. «Что дальше»): демо показывает состояние «как сейчас», а не «как задумано».
Источники: 235 входов — это поверхность отказов
В 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 так не считало. Универсальность оказалась настоящей в одних местах и красиво притворяющейся в других, и выяснялось это всегда одинаково — через тихо сломанную выдачу, которая выглядела убедительно ровно до момента, когда я в неё всматривался.
Виральность держалась на двух словарях
Я был уверен, что вынести нишу — это поправить один файл с сущностями. На деле скоринг опирался на два независимых слоя. Первый — именованные сущности с тирами важности, чистые данные, заменяются один в один. Второй — словарь виральных триггеров, зашитый прямо в код скоринга: десятки категорий событий со своими весами, и именно он главный драйвер очков. Через словарь сущностей он не выносится, его надо править отдельно.
Забавно вышло с комбинациями. Игровые комбо вроде «утечка плюс крупный тайтл» или «иск плюс крупная компания» я ожидал переписывать под SEO с нуля. А они подошли без единой правки: Google API leak — та же утечка, антимонопольные иски — те же иски, закрытие сервиса — то же закрытие. Универсальные усилители новости оказались действительно универсальными. Мораль для всех, кто собирает похожее: граница между движком и нишей почти никогда не проходит там, где кажется на первый взгляд. Где она проходит на самом деле, видно, только когда залезаешь в скоринг руками.
Мультиязычность без тяжёлой лингвистики
Матчинг ключей в движке нарочно примитивный. Всё приводится к нижнему регистру, совпадение ищется по подстроке, и только для совсем коротких ключей (три символа и меньше) включается проверка по границе слова, чтобы geo, aeo или икс не цеплялись к случайному мусору внутри слов.
Из этой примитивности следует приятное: русскую морфологию и разные языки не нужно переводить, их достаточно перечислить. Ключ фильтр подстрокой ловит и «фильтра», и «фильтром». А куча сигналов вообще не зависит от языка — ChatGPT, Perplexity, GPTBot, Core Web Vitals, llms.txt одинаково пишутся хоть в русском тексте, хоть в немецком. Тяжёлый NLP со стеммингом и лемматизацией я не подключал и не жалею: для технической ниши аккуратная словарная модель работает, если честно держать в голове её ограничения, а не делать вид, что это семантическое понимание.
Сущности Google и ChatGPT слиплись в блоб

А вот тут универсальность сломалась громко. Сюжеты строились из смеси текстовой близости заголовков и пересечения сущностей, дальше связные компоненты. Для игр работало идеально: сущности конкретные. 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
Дальше я чуть не ушёл оптимизировать не то. Сюжетов было восемь на полторы тысячи новостей, и это выглядело как «слишком строгая кластеризация, надо ослаблять пороги». Я уже занёс руку над коэффициентами, когда наткнулся на захардкоженный 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 и вечнозелёные гайды. И ценность у них другая: новость про вчерашний апдейт через месяц мертва, а кейс про восстановление трафика через месяц пригодится для статьи, аудита, клиентской подборки или поста.
Поэтому в системе появился отдельный слой — пометка «это кейс». Её можно поставить руками или поймать автоматически по тегу, и она даёт материалу другое поведение: фоновая чистка свежести его не трогает, как бы он ни старел. Кейсы живут на своей вкладке и копятся в базу, из которой потом удобно собирать материал, когда он реально понадобится. По сути это разделение памяти на новостную ленту, которая обязана забывать, и архив, который обязан помнить.
Выходы: Sheets, Telegram, рерайт
Дальше отфильтрованный и оценённый поток надо куда-то отдать. И вот тут принципиальный момент: выход — это не «Telegram-бот, прибитый гвоздями к движку», а сменный слой адаптеров. Сейчас их три, и каждый показывает разную грань.
Google Sheets — редакторская доска
Самый недооценённый выход. Материал уезжает в таблицу с маршрутизацией по вкладкам: новости со скором выше шестидесяти попадают в NotReady, уже переписанные LLM — в Ready, сюжеты и удалённые идут своими отдельными путями, а раз в сутки в девять утра по Москве выгружаются сюжеты. Зачем таблица, когда есть дашборд? Затем, что редактор работает в привычной среде, в таблицу можно пустить команду, а можно отдать её клиенту, и никто не будет осваивать ещё один интерфейс. Google Sheets превращается в промежуточную доску, на которой удобно разбирать выдачу руками.
Telegram — публикация после фильтрации
Здесь важно не соврать формулировкой. Это не «бот сам пишет посты из воздуха». Дайджест в Telegram — финальный шаг после сбора, скоринга, дедупликации и разметки. В ежедневный выпуск уходит лучшее из новостей, кейсов и постов других каналов, со ссылками на первоисточники и темами дня. У дайджеста несколько стилей, а общий собирается в три раздела — новости, кейсы, телеграм — и укладывается в одно сообщение. Машина делает черновую работу по отбору, человек решает, что из этого достойно подписчиков.
Рерайт под тон ниши
И только теперь — рерайт, потому что именно он превращает агрегатор в комбайн, и именно его проще всего понять неправильно. Поэтому сразу рамка: рерайт включается не на сырой поток, а на уже отфильтрованный, размеченный и оценённый материал. Это не генерация контента из воздуха, а редакторская переработка конкретного источника под тон ниши — у движка на входе настоящая новость с настоящей ссылкой, а не задача «придумай что-нибудь про SEO». К каждому переписанному материалу привязана ссылка на первоисточник: задача не присвоить новость, а быстро переработать и оформить редакторский выпуск.
Механически цепочка простая: функция рерайта берёт отобранный материал и промпты из нишевого пакета, складывает результат в таблицу статей со временем публикации, а фоновая джоба раз в минуту проверяет расписание и постит готовое. Бюджетный кап и фолбэк-цепочку моделей разберу прямо ниже — они общие для всех LLM-этапов. Сейчас выход рерайта — Telegram, но раз слой адаптеров уже есть, добавить публикацию по API в произвольную систему — будь то CMS, свой сайт или вебхук — это вопрос ещё одного адаптера, а не переписывания движка. Telegram и Sheets как раз и доказывают, что это слой, а не две случайные фичи.
LLM как сменный слой: любая модель на любой этап
Рерайт — не единственное место, где зовётся модель, и удобный повод показать: сам 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 по моделям, по источникам и по версиям промптов плюс дневной тренд затрат. Видно не только что система работает, но и во сколько обходится каждый вызов и какой промпт дешевле при том же результате. Это и держит дневной бюджетный кап осмысленным, а не взятым с потолка.

Боевой результат на SEO
Сухие цифры на момент написания: те же 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». Самые поучительные баги вылезли уже на проде, в боевом дайджесте.
Автопубликация отправляла не тот дайджест
Вечерний крон собирает общий дайджест и постит в канал. Однажды я заметил в истории два дайджеста за вечер: один я собрал руками, и он мне нравился, второй ушёл в канал и был заметно хуже. Оказалось, крон не берёт готовый дайджест из истории, а каждый раз генерирует новый с нуля и постит именно его. Мой отобранный вариант физически не имел шанса попасть в канал. Вывод-улучшение очевиден и до сих пор в бэклоге: публиковать выбранное, а не свежесгенерированное.
Недетерминизм LLM пробрался прямо в прод
Тот же самый промпт на тех же данных в двух соседних вызовах дал разный результат, и один из них приехал в канал с битым текстом — посреди фразы торчали обрывки вроде «оцениPut» и «?ographics». Модель просто икнула на конкретном вызове, а проверки на это не было: ретрай срабатывал только на пустой ответ, а не на правдоподобный мусор. Вывод: валидировать ответ модели и держать фолбэк, потому что «вернулось что-то» не равно «вернулось нормальное».
58 молчащих фидов и опровергнутая гипотеза
Полсотни источников отдавали 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 в нём — полезная съёмная деталь, а не несущая стена.
И, кажется, именно в этом всё дело: выигрывает не тот, кто громче всех вставил в пайплайн нейросеть, а тот, кто построил вокруг неё систему, которая работает и без неё, переживает сбои, учится на редакторских решениях и достаточно гибкая, чтобы следующий человек мог разобрать её под свою нишу, а не начинать с нуля.
ссылка на оригинал статьи https://habr.com/ru/articles/1052928/