01Идея целиком.
Никакого «агента», который делает всё сразу. Есть одна таблица в SQLite, где каждая строка — статья,
и десяток колонок-статусов: meta, parse, translate,
tts_audio, podcast_audio, sent. Cron раз в минуту запускает
короткий скрипт, который берёт одну статью и делает один следующий шаг — тот, у которого статус
ещё пустой.
Это принципиальный выбор, а не экономия сил. Подкаст готовится четыре-восемь минут. Если пытаться сделать за один запуск и разбор, и перевод, и подкаст, и отправку, получится многоминутный процесс, который валится по таймауту на любом из шагов и теряет всё сделанное. А так упал шаг — через минуту cron вернётся и продолжит ровно с того места.
На пустой базе первый проход только запоминает текущий список блога как «уже виденное» и не обрабатывает ничего. Без этого первый же прогон возьмёт в работу весь архив: подписчик получит двадцать постов подряд, а суточный лимит 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; ссылка с любой другой схемой превращается в обычный текст.
Текст приходит с чужого сайта и вдобавок проходит через внешнюю модель, поэтому ни одному тегу в нём верить нельзя дважды. Три реальные ловушки, которые пришлось закрыть в санитайзере:
- Модель иногда возвращает разметку, экранированную повторно:
<strong>вместо настоящего тега. Перед разбором раскрываем сущности. - Модель может сохранить 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": "…"}
Сценарий внутри — пять шагов, и каждый может упасть:
Создать блокнот
Временный, с одноразовым именем. Его идентификатор обязательно нужно запомнить, иначе блокнот останется мусором в аккаунте навсегда.
Добавить источник
Отдаём исходную ссылку, NotebookLM сам читает страницу. Никакого своего текста передавать не нужно.
Заказать аудио
Команда возвращает идентификатор артефакта. Именно по нему потом ищется готовый файл.
Дождаться
Опрос статуса каждые двадцать секунд, дедлайн пятнадцать минут. Готовность — это появление непустой ссылки на аудио.
Скачать и убрать
Файл во временное имя, потом атомарное переименование. Блокнот удаляется в блоке
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Ручной сценарий.
Параллельно автоматической ленте есть второй путь: владелец отправляет боту в личные сообщения любую ссылку. Доступ ограничен одним идентификатором диалога, чужие сообщения бот игнорирует.
- Бот спрашивает формат: пересказ и адаптация или полный перевод.
- Спрашивает тип аудио: озвучка или подкаст.
- Достаёт текст страницы: обычные сайты через Firecrawl, видео — через субтитры.
- Если у ролика нет субтитров, ссылка отдаётся NotebookLM: он сам разбирает видео и выдаёт подробное изложение. Тогда формат честно понижается до адаптации — полного перевода не из чего делать, и владельцу об этом пишут.
- Дальше тот же массив блоков, то же аудио, то же превью с кнопками.
Режим пересказа — один запрос к модели с явным требованием структуры: вернуть JSON с заголовком, тезисами, глоссарием и блоками; организовать длинный текст в три-восемь смысловых разделов, добавив заголовок перед каждой группой абзацев; не добавлять HTML-теги и не оставлять служебных слов. Последнее уточнение появилось не просто так — модели любят протаскивать имена типов блоков в сам текст.
11Грабли, которые видно только на проде.
Иначе весь архив источника встаёт в очередь и выжигает суточный лимит на третьей статье.
Незнакомый код ошибки лучше считать временным. Но квоты и протухшую авторизацию надо распознавать явно и превращать в блокировку, иначе повторы молча убивают контент.
К каждой блокировке нужна маленькая команда снятия. И флаги должны быть раздельными по сервисам.
Сказал «готово» — проверьте файл. Сказал «ошибка» — прочитайте текст и классифицируйте сами.
Он исключён из репозитория, выкладка его не создаёт, и скачивание падает на каждой статье. Создавать при обращении и явно ставить права: маска процесса иначе урежет их ниже нужного.
D в регулярках на имена файлов.
Без него конец строки совпадает и перед переводом строки — и в имя пролезает путь.
Из адреса выходит сносный заголовок. А вот если не записалась страница, отправку надо отменять: ссылка на 404 хуже отсутствия поста.
Разбор HTML, санитайзер, нарезка текста, форматирование сообщения, классификация ответов API — всё это методы без сети. В боте около двадцати пяти тестовых файлов, и ни один не выходит наружу: зависимости приходят через конструктор и подменяются заглушками.
12Если повторять с нуля.
Минимальный работающий канал получается заметно меньше, чем всё описанное. Порядок, в котором это стоит собирать, чтобы на каждом этапе было что показать:
- Таблица и статусы. SQLite, одна строка на материал, колонки-статусы, cron раз в минуту, файловый замок. Пока без единого внешнего вызова — просто конвейер, который умеет ходить по шагам и считать попытки.
- Источник. Список ссылок откуда угодно: RSS, HTML-список, ручной ввод. Холостой первый запуск.
- Разбор в блоки. Типы блоков и белый список инлайн-тегов. Это фундамент — от него зависят и перевод, и озвучка, и пост.
- Перевод плоским словарём с проверкой совпадения ключей. Здесь уже можно постить в тестовый канал голый текст.
- Тезисы отдельным вызовом по оригиналу. Пост становится читаемым.
- Озвучка. Нарезка, склейка через ffmpeg, перекодировка в OGG Opus для голосового сообщения.
- Подкаст. Последним и опционально: это самая хрупкая часть, и она обязана быть необязательной.
Всё, что в этом списке идёт после третьего пункта, можно выкидывать поодиночке — конвейер должен переживать отсутствие любого из них. Это и есть главный архитектурный принцип: пост без подкаста лучше, чем отсутствие поста.
Скилл для Claude
Соберите такой же конвейер у себя.
Весь разбор выше упакован в скилл: положите папку в каталог скиллов, опишите задачу своими словами — и ассистент проведёт вас по конвейеру от таблицы статусов до первого поста в канале. Ключей, доменов и адресов серверов внутри нет: всё, что нужно для работы, скилл спрашивает у вас и записывает в конфиг на вашей стороне.
- SKILL.md
- INSTALL.md
- references/ — 6 справочников
- templates/config.example.php
Разбор сделан по рабочему коду: PHP 8.2 без зависимостей, SQLite, две обёртки на Python, около шести тысяч строк вместе с тестами. Ключи, идентификаторы чатов и доступы в разбор не входят. Обновлено .