0%прочитано

Разбор системы

Как устроен наш контент-бот.

Он каждый день сам находит новую статью в чужом блоге, разбирает её на блоки, переводит через OpenAI, начитывает голосом, заказывает подкаст у NotebookLM и кладёт всё это одним постом в Telegram-канал.

Готовый скилл

Скилл для Claude Code и Codex

Навык content-digest-bot для сборки аналогичного автоматического конвейера контента в вашем проекте.

Скачать скилл (ZIP)
Скилл для Claude / Codex Готов к импорту

01Идея целиком.

Никакого «агента», который делает всё сразу. Есть одна таблица в SQLite, где каждая строка — статья, и десяток колонок-статусов: meta, parse, translate, tts_audio, podcast_audio, sent. Cron раз в минуту запускает короткий скрипт, который берёт одну статью и делает один следующий шаг — тот, у которого статус ещё пустой.

Это принципиальный выбор, а не экономия сил. Подкаст готовится четыре-восемь минут. Если пытаться сделать за один запуск и разбор, и перевод, и подкаст, и отправку, получится многоминутный процесс, который валится по таймауту на любом из шагов и теряет всё сделанное. А так упал шаг — через минуту cron вернётся и продолжит ровно с того места.

watch раз в сутки: новые ссылки из списка блога встают в очередь
meta заголовок, описание, обложка
parse HTML превращается в массив безопасных блоков
translate OpenAI переводит блоки на русский
images / page выкладка страницы на сайт
tts полная озвучка русского текста
podcast NotebookLM делает подкаст по исходной ссылке
send один пост в Telegram
Правило первого запуска

На пустой базе первый проход только запоминает текущий список блога как «уже виденное» и не обрабатывает ничего. Без этого первый же прогон возьмёт в работу весь архив: подписчик получит двадцать постов подряд, а суточный лимит NotebookLM закончится на третьей статье.

02База как конвейер.

Вся координация живёт в таблице, а не в коде. Шесть механизмов, которые делают очередь предсказуемой:

МеханизмКак сделаноЗачем
Один шаг за проход цепочка проверок «статус пуст — сделай этот шаг и выйди» порядок шагов виден в одном методе, длинных процессов нет
Взаимное исключение flock на файл-замок, отдельный у каждого крона минутный cron не запускает второй экземпляр поверх работающего
Попытки счётчик попыток плюс имя шага, к которому он относится, максимум три после третьей неудачи падает только этот шаг, остальные идут дальше
Дубликаты INSERT OR IGNORE по первичному ключу проверка «есть ли уже» гоняется между процессами, ключ — нет
Блокировки отдельные флаги на каждый внешний сервис в таблице настроек сломан доступ целиком — стоп очереди, а не расход статей
Ошибка текст последней ошибки прямо в строке статьи диагностика без логов: видно, что и на чём встало

Разница между «временной» и «блокирующей» ошибкой — главная развилка всей системы. Временная (сеть моргнула, 429, 500) съедает попытку. Блокирующая (протухли куки, кончился суточный лимит, ключ отвергнут) попытку не съедает, а поднимает флаг и останавливает очередь до ручного вмешательства. Если их перепутать, три попытки за три минуты выкинут статью навсегда — а виновата была не она.

Развилка важнее ретраев

Падение NotebookLM не должно задерживать перевод, поэтому флаги раздельные. Telegram блокирует всю очередь, потому что публиковать некуда. NotebookLM блокирует только шаг подкаста: статья выходит без подкаста, но выходит.

03Блоки и теги.

Статья никогда не хранится и не переводится как кусок HTML. Сразу после загрузки страница разбирается в плоский массив типизированных блоков — и дальше по всему конвейеру ходит только он:

[
  {"type":"heading","level":2,"text":"Заголовок раздела"},
  {"type":"paragraph","html":"Текст со <strong>выделением</strong> и ссылкой"},
  {"type":"list","ordered":false,"items":["Пункт","Ещё пункт"]},
  {"type":"quote","html":"…"},
  {"type":"code","lang":"python","text":"…"},
  {"type":"image","src":"…","alt":"…","caption":"…"},
  {"type":"table","rows":[["Колонка","Колонка"],["…","…"]]},
  {"type":"video","src":"…"}
]

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

Белый список инлайн-тегов

Внутри абзаца разрешены ровно пять тегов: a, strong, em, code, br. Всё остальное разворачивается — тег выбрасывается, содержимое остаётся. Теги script и style выбрасываются вместе с содержимым. Атрибуты не переносятся ни у одного тега: чужие class и style не нужны, а on* — прямая дыра. У ссылок остаётся только адрес, и только со схемой http или https; ссылка с любой другой схемой превращается в обычный текст.

Текст приходит с чужого сайта и вдобавок проходит через внешнюю модель, поэтому ни одному тегу в нём верить нельзя дважды. Три реальные ловушки, которые пришлось закрыть в санитайзере:

  • Модель иногда возвращает разметку, экранированную повторно: &lt;strong&gt; вместо настоящего тега. Перед разбором раскрываем сущности.
  • Модель может сохранить JSON-экранирование слэша в закрывающем теге. Нормализуем заменой строк.
  • Парсер DOM принимает «< 7» за начало битого тега и молча теряет символ. Экранируем только те угловые скобки, за которыми не идёт буква, слэш, восклицательный или вопросительный знак.
Кодировка

При загрузке фрагмента в парсер кодировку объявляют явно, иначе он считает вход latin-1 и превращает кириллицу в мусор. Фрагмент оборачивают в body с флагами, запрещающими достраивать обёртки, — чтобы вокруг абзаца не появились лишние теги.

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

04Перевод.

Самая неочевидная часть. Блоки не отправляются модели как есть. Сначала из них собирается плоский словарь «адрес поля — строка», где адрес это путь внутри массива:

{
  "0.text":     "Introducing the new model",
  "1.html":     "We are <strong>shipping</strong> today",
  "2.items.0":  "First bullet",
  "2.items.1":  "Second bullet",
  "5.rows.0.1": "Latency"
}

Модель получает этот JSON и системную инструкцию: перевести все значения на русский полностью, без сокращений и добавлений; ключи и инлайн-теги сохранить без изменений; обращение на «Вы», длинное тире, кавычки-ёлочки, без эмодзи. Ответ запрашивается в JSON-режиме.

Дальше приёмка ответа, и она жёсткая. Список ключей в ответе сортируется и сравнивается с исходным: не совпал ровно — весь батч считается провалом и уходит в повтор. Любое нестроковое значение — тоже провал. Только после этого переводы раскладываются обратно по адресам в исходный массив блоков.

Почему так, а не «переведи статью»

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

Батчи

Поля набираются в пачки примерно по восемь тысяч символов и переводятся отдельными запросами. Порог вынесен в настройки: если в логах появляется «ответ модели оборван по длине», его уменьшают. Одна упавшая пачка роняет весь шаг перевода, а не пропускает кусок статьи — на повторе шаг начинается заново.

Классификация ответов

Клиент к API — несколько десятков строк, и разбор ответа вынесен в отдельный статический метод, чтобы тесты проверяли классификацию, не выходя в сеть:

  • 401 и 403 — блокирующая ошибка: ключ отвергнут, повторять бессмысленно;
  • 429 и 5xx — временная: повторить;
  • остальные не-2xx — провал шага: после повторов лучше не станет;
  • пустое содержимое при коде 200 — тоже провал, а не «успех с пустотой».

05Тезисы и глоссарий.

Второй, отдельный вызов модели, и он идёт по английскому оригиналу, а не по переводу: так ошибки перевода не наследуются в тезисы. На вход подаётся заголовок и текст статьи без разметки, обрезанный до двенадцати тысяч символов. Просим один JSON:

{
  "title":    "Русский заголовок статьи",
  "summary":  ["тезис", "тезис", "тезис"],     // 3–5 штук
  "glossary": [{"term":"context window",
                "ru":"контекстное окно",
                "explain":"…"}]                 // 5–12 штук
}

Разбор ответа снова недоверчивый: пустой заголовок — провал шага; элементы списка тезисов, не являющиеся строками, отбрасываются; запись глоссария принимается, только если непусты все три поля сразу. Русский заголовок хранится отдельно от оригинального: в посте и в имени аудиофайла используется он, оригинал остаётся запасным.

Тезисы — это то, что реально едет в Telegram. Полный перевод остаётся на странице, в канал уходит заголовок, три-пять пунктов и две кнопки. Если тезисов почему-то нет, форматтер молча подставляет описание из мета-тега: короткий пост лучше пустого.

06Полная озвучка.

Первая из двух аудиодорожек — робот честно читает весь русский текст. Провайдер переключается в настройках: OpenAI TTS или ElevenLabs, логика одинаковая.

Что именно читаем

Текст для диктора собирается рекурсивным обходом переведённых блоков: берутся строки, из них вырезаются теги, а поля с адресами картинок и ссылок пропускаются целиком — иначе диктор начнёт зачитывать вслух ссылки. Блоки склеиваются через пустую строку: это одновременно и абзацная пауза, и граница для нарезки.

Инструкция голосу — не украшение

К каждому запросу подмешивается фраза: не произносите служебные маркеры разметки и названия блоков, читайте только естественный текст статьи. Без неё модель периодически зачитывает вслух служебные слова, попавшие в текст.

Нарезка и склейка

У TTS-эндпоинтов есть предел на длину входа, поэтому текст режется на куски: около 3800 символов у одного провайдера, 4500 у другого. Режем по абзацам, добивая кусок до предела; если один абзац сам длиннее предела — по последнему пробелу. Каждый кусок скачивается отдельным MP3 во временный файл, затем склеивается одной командой:

ffmpeg -y -f concat -safe 0 -i list.txt -c:a aac -b:a 128k out.m4a

Две мелочи, которые стоили отладки. Первая: MP3-поток нельзя просто скопировать в контейнер .m4a, нужен перекод в AAC. Вторая: если кусок ровно один, склейка не нужна — файл просто переименовывается, но при этом всё равно должен оказаться в правильном контейнере. Временные куски и список удаляются в любом случае, успех проверяется по факту: файл существует и его размер больше нуля.

Имя файла — не заголовок статьи, а хеш от её идентификатора. Причина простая: имя вложения видно подписчикам, и оно не должно рассказывать о внутренней кухне. Побочная польза — повторная попытка перезаписывает тот же файл, а не плодит копии. Валидность имени проверяется регулярным выражением, и в нём обязателен модификатор D: без него $ совпадает и перед переводом строки, так что имя вида «хеш.m4a\n/../../etc/passwd» прошло бы проверку.

07Подкаст через NotebookLM.

Вторая дорожка — диалоговый подкаст, где двое ведущих обсуждают статью. Публичного API у NotebookLM нет, поэтому используется сторонний CLI, а поверх него обёртка на Python, которую основной код запускает как обычный процесс. Обёртка печатает ровно один JSON и ничего больше:

{"ok": true,  "path": "/абсолютный/путь.m4a"}
{"ok": false, "error": "temporary" | "blocked", "message": "…"}

Сценарий внутри — пять шагов, и каждый может упасть:

Шаг 1

Создать блокнот

Временный, с одноразовым именем. Его идентификатор обязательно нужно запомнить, иначе блокнот останется мусором в аккаунте навсегда.

Шаг 2

Добавить источник

Отдаём исходную ссылку, NotebookLM сам читает страницу. Никакого своего текста передавать не нужно.

Шаг 3

Заказать аудио

Команда возвращает идентификатор артефакта. Именно по нему потом ищется готовый файл.

Шаг 4

Дождаться

Опрос статуса каждые двадцать секунд, дедлайн пятнадцать минут. Готовность — это появление непустой ссылки на аудио.

Шаг 5

Скачать и убрать

Файл во временное имя, потом атомарное переименование. Блокнот удаляется в блоке finally.

Каждый флаг в третьем шаге куплен живым прогоном:

  • --confirm — без него команда не работает: CLI спрашивает подтверждение, ввода в кроне нет, и он печатает «Aborted». Подкаст падал трижды подряд за двенадцать секунд именно из-за этого.
  • --language ru — иначе подкаст будет на английском.
  • --length short — иначе файлы подбираются к пределу Telegram в 50 МБ.
  • --json — вопреки документации, без него печатается человекочитаемый прогресс.

Три места, где документация расходится с поведением, и все три стоили отдельной отладки. Поле статуса всегда возвращает «unknown» и о готовности не говорит ничего. Скачивание хочет идентификатор флагом, хотя документация описывает позиционный аргумент. Ответ статуса — массив артефактов, и в нём надо искать свой по идентификатору, иначе скачается подкаст по чужому, более старому источнику.

Суточный лимит — это блокировка, а не «повторить»

Упор в квоту распознаётся по маркерам в тексте ошибки и превращается в блокирующую ошибку. Пока это считалось временной, три попытки за три минуты выжигали статью навсегда, и ни флага, ни строки в логе не оставалось. При этом маркеры доступа должны быть однозначными: голое слово «expired» в список не годится, потому что так же может ругаться шаг добавления источника про саму чужую страницу.

И отдельно: основной код не верит отчёту обёртки на слово. После ответа «готово» он сам проверяет, что файл существует и непуст. Иначе воркер пометит подкаст готовым, отправка не найдёт файла — и пост уйдёт без вложения при полностью исправном NotebookLM.

08Запуск чужого процесса.

Обёртка на Python — это внешний сервис с недокументированным поведением, поэтому весь класс запуска построен на недоверии к ней. Приёмы, которые стоит копировать дословно:

ПриёмЧто именно
Массив аргументов Процесс запускается массивом, а не строкой. Экранирование остаётся на стороне языка, и внешние данные не могут стать куском команды.
Окружение собирается тут же Явно заданное окружение потомка не дополняет родительское, а полностью замещает его. Любые переменные, выставленные снаружи, до потомка не долетят — их надо класть в тот же массив.
Секреты через окружение Доступы уходят переменными окружения, а не аргументами: иначе они светятся в списке процессов для любого пользователя системы.
Чистка сообщений Текст ошибки перед сохранением прогоняется регулярным выражением, которое вырезает учётные данные из адресов. HTTP-клиенты внутри чужого CLI охотно печатают полный адрес прокси в тексте исключения, а этот текст попадает в базу.
Читаем поток ошибок Если его не вычитывать, полный буфер трубы заблокирует потомка намертво.
Бюджет времени Общий таймаут делится на шесть и передаётся обёртке как бюджет одного шага. Не на четыре по числу шагов: тогда суммарный бюджет равен родительскому таймауту ровно, и уборка временного блокнота попадает под нож именно тогда, когда она нужна.
Убийство дерева Сначала собрать весь список процессов снизу вверх, потом разослать мягкий сигнал, дать две секунды и только выжившим — жёсткий.

Порядок в последнем пункте — не педантизм. Жёсткий сигнал перехватить нельзя: если сразу убить обёртку им, её блок уборки не выполнится и временный блокнот останется в аккаунте навсегда. А собирать дерево надо до сигналов, потому что у мёртвого родителя список потомков уже не найти. И любой вывод, который не разобрался в ожидаемый JSON, трактуется как временная ошибка: лучше повторить напрасно, чем уронить очередь из-за опечатки в чужом выводе.

09Что уходит в канал.

Один пост: обложка, заголовок, подкаст, список тезисов, полная озвучка и две кнопки — на перевод и на оригинал. Порядок именно такой: подкаст выше тезисов, потому что он и есть главный продукт поста.

Голосовые, а не музыка

Обе дорожки перекодируются в OGG Opus и вставляются как голосовое сообщение, а не как аудиофайл:

ffmpeg -y -i in.m4a -vn -c:a libopus -b:a 48k -vbr on \
       -compression_level 10 -frame_duration 60 -application voip out.ogg

Именно этот контейнер и кодек Telegram распознаёт как голосовое — с волной и переключением скорости. В обычном аудиоплеере скорость поменять нельзя, а для сорокаминутного подкаста это половина ценности. Исходный файл при этом сохраняется, он идёт на страницу статьи.

Обрезка HTML

Разметка сообщения — HTML, лимиты Telegram: 4096 символов на сообщение и 1024 на подпись. Резать текст вслепую нельзя дважды: обрыв внутри парного тега оставит его незакрытым, а обрыв внутри HTML-сущности — её огрызок. И то, и другое Telegram отвергает целиком с ошибкой разбора. Поэтому от места разреза отступают назад до конца последней целой сущности и дозакрывают тег — причём закрывающий тег обязан поместиться внутрь лимита, а не сверх него.

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

Деградация вместо молчания

  • Сеть отдачи картинок вернула не картинку — пост уходит без обложки, ошибка пишется в базу.
  • Аудио не отправляется раз за разом, потому что файл слишком велик — на последней попытке пост уходит без вложения: текст со ссылками ценнее молчания.
  • Новый формат поста недоступен — откат на старую последовательность обычных сообщений.
  • Видео из статьи уходит отдельным тихим сообщением: только так Telegram строит видео-карточку, а отключённое уведомление оставляет подписчику один пуш вместо двух.
Превью перед публикацией

Готовый пост сначала уходит владельцу с кнопкой публикации. В данные кнопки помещается 64 байта, поэтому там лежит не адрес и не идентификатор статьи, а короткий одноразовый случайный токен, по которому черновик ищется в базе.

10Ручной сценарий.

Параллельно автоматической ленте есть второй путь: владелец отправляет боту в личные сообщения любую ссылку. Доступ ограничен одним идентификатором диалога, чужие сообщения бот игнорирует.

  1. Бот спрашивает формат: пересказ и адаптация или полный перевод.
  2. Спрашивает тип аудио: озвучка или подкаст.
  3. Достаёт текст страницы: обычные сайты через Firecrawl, видео — через субтитры.
  4. Если у ролика нет субтитров, ссылка отдаётся NotebookLM: он сам разбирает видео и выдаёт подробное изложение. Тогда формат честно понижается до адаптации — полного перевода не из чего делать, и владельцу об этом пишут.
  5. Дальше тот же массив блоков, то же аудио, то же превью с кнопками.

Режим пересказа — один запрос к модели с явным требованием структуры: вернуть JSON с заголовком, тезисами, глоссарием и блоками; организовать длинный текст в три-восемь смысловых разделов, добавив заголовок перед каждой группой абзацев; не добавлять HTML-теги и не оставлять служебных слов. Последнее уточнение появилось не просто так — модели любят протаскивать имена типов блоков в сам текст.

11Грабли, которые видно только на проде.

Первый запуск обязан быть холостым.

Иначе весь архив источника встаёт в очередь и выжигает суточный лимит на третьей статье.

«Временная ошибка» — правильный дефолт, но с оговорками.

Незнакомый код ошибки лучше считать временным. Но квоты и протухшую авторизацию надо распознавать явно и превращать в блокировку, иначе повторы молча убивают контент.

Флаг, который ставится и не снимается, — тупик.

К каждой блокировке нужна маленькая команда снятия. И флаги должны быть раздельными по сервисам.

Не верьте отчёту внешнего процесса.

Сказал «готово» — проверьте файл. Сказал «ошибка» — прочитайте текст и классифицируйте сами.

Рабочий каталог может не существовать.

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

Модификатор D в регулярках на имена файлов.

Без него конец строки совпадает и перед переводом строки — и в имя пролезает путь.

Заголовок не получился — это не повод бросать статью.

Из адреса выходит сносный заголовок. А вот если не записалась страница, отправку надо отменять: ссылка на 404 хуже отсутствия поста.

Тесты пишутся на чистые функции.

Разбор HTML, санитайзер, нарезка текста, форматирование сообщения, классификация ответов API — всё это методы без сети. В боте около двадцати пяти тестовых файлов, и ни один не выходит наружу: зависимости приходят через конструктор и подменяются заглушками.

12Если повторять с нуля.

Минимальный работающий канал получается заметно меньше, чем всё описанное. Порядок, в котором это стоит собирать, чтобы на каждом этапе было что показать:

  1. Таблица и статусы. SQLite, одна строка на материал, колонки-статусы, cron раз в минуту, файловый замок. Пока без единого внешнего вызова — просто конвейер, который умеет ходить по шагам и считать попытки.
  2. Источник. Список ссылок откуда угодно: RSS, HTML-список, ручной ввод. Холостой первый запуск.
  3. Разбор в блоки. Типы блоков и белый список инлайн-тегов. Это фундамент — от него зависят и перевод, и озвучка, и пост.
  4. Перевод плоским словарём с проверкой совпадения ключей. Здесь уже можно постить в тестовый канал голый текст.
  5. Тезисы отдельным вызовом по оригиналу. Пост становится читаемым.
  6. Озвучка. Нарезка, склейка через ffmpeg, перекодировка в OGG Opus для голосового сообщения.
  7. Подкаст. Последним и опционально: это самая хрупкая часть, и она обязана быть необязательной.

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

Скилл для Claude

Соберите такой же конвейер у себя.

Весь разбор выше упакован в скилл: положите папку в каталог скиллов, опишите задачу своими словами — и ассистент проведёт вас по конвейеру от таблицы статусов до первого поста в канале. Ключей, доменов и адресов серверов внутри нет: всё, что нужно для работы, скилл спрашивает у вас и записывает в конфиг на вашей стороне.

  • SKILL.md
  • INSTALL.md
  • references/ — 6 справочников
  • templates/config.example.php
Скачать скилл ZIP, 30 КБ · распаковать в ~/.claude/skills/

Разбор сделан по рабочему коду: PHP 8.2 без зависимостей, SQLite, две обёртки на Python, около шести тысяч строк вместе с тестами. Ключи, идентификаторы чатов и доступы в разбор не входят. Обновлено .