В документации Telegraph API для эндпоинта createPage указано ограничение на поле content:

content (Array of Node, up to 64 KB). Content of the page.

В реальности цифра не соответствует поведению. API стабильно возвращает CONTENT_TOO_BIG на русскоязычном тексте около 20 КБ исходного markdown. Я напоролся на это при разборе довольно странной деградации в нашем продакшне: часть постов блога публиковалась в связанный Telegram-канал без ссылки на Telegraph-версию, причём только русскоязычные, а их англоязычные переводы проходили нормально.

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

Контекст и симптом

Сервис публикует сгенерированные статьи блога в двух местах: на основном сайте и параллельно в виде страницы Telegraph, ссылка на которую прикрепляется к сообщению в тематическом Telegram-канале. Сообщение в канал формируется так:

text = lang === 'en'
  ? `? <b>${title}</b>\n\n? <a href="${siteUrl}">Read on site</a>\n? <a href="${telegraphUrl}">Read on Telegraph</a>`
  : `? <b>${title}</b>\n\n? <a href="${siteUrl}">Читать на сайте</a>\n? <a href="${telegraphUrl}">Читать в Telegraph</a>`;

Если функция createTelegraphPage не возвращает URL (например, из-за ошибки API), сообщение формируется в сокращённом виде, только со ссылкой на сайт. Этот fallback задумывался как способ не терять публикацию в канале целиком, но по факту скрывал ошибку: RU-канал неделями публиковался без Telegraph-ссылки, никто этого не замечал, а в логах Railway ошибка молча уходила в console.error без алерта.

Первое, что я проверил, - не слетел ли TELEGRAPH_TOKEN. Нет, не слетел. getAccountInfo на нём отвечал ok: true. Переменная в Railway без скрытых пробелов. EN-переводы на том же токене продолжали спокойно публиковаться. То есть токен заведомо живой.

При этом сам код createTelegraphPage не зависит от языка нигде, кроме подписи футера «Читать на NeuroVerdict» или «Read on NeuroVerdict». Если на одном языке всё работает, а на другом нет, и код не умеет их различать, остаётся одно: дело в самом содержимом.

Минимальное воспроизведение

Чтобы отделить факторы, я собрал минимальный воспроизводимый пример. Создал свежий Telegraph-аккаунт (эндпоинт createAccount отдаёт access_token синхронно), после чего отправил один и тот же сериализованный content с разным объёмом исходного markdown:

const acct = await (await fetch(
  'https://api.telegra.ph/createAccount?short\\_name=SizeTest&author\\_name=T'
)).json();
const token = acct.result.access_token;

async function tryPayload(mdBytes) {
  const para = 'Тестовый абзац с нормальным текстом. ';
  let md = '';
  while (md.length < mdBytes) md += para + '\n\n';

  const content = md.split('\n\n').filter(Boolean).map(
    p => ({ tag: 'p', children: [p] })
  );

  const body = JSON.stringify({
    access_token: token,
    title: `Size ${Math.round(mdBytes / 1024)}KB`,
    author_name: 'T',
    author_url: 'https://neuroverdict.com',
    content,
  });
  const res = await fetch('https://api.telegra.ph/createPage', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body,
  });
  return res.json();
}

Далее идут последовательные вызовы с размерами от 5 КБ до 30 КБ с паузой в 1,5 с между ними, чтобы исключить влияние rate-limiter:

Исходный markdown

JSON.stringify(content).length

UTF-8 байт (Buffer.byteLength)

Ответ API

5 КБ

8 581

~13 600

ok: true

10 КБ

17 096

~27 200

ok: true

20 КБ

34 191

~54 400

error: CONTENT_TOO_BIG

30 КБ

51 221

~81 600

error: CONTENT_TOO_BIG

40 КБ

68 217

~108 700

error: CONTENT_TOO_BIG

То есть реальный потолок проходит где-то между 10 и 20 КБ исходного markdown. В пересчёте на сериализованный JSON это интервал 17-34 КБ символов, что для кириллицы соответствует примерно 27-54 КБ в байтах UTF-8. О заявленных 64 КБ речи не идёт.

Почему документация расходится с поведением

Формат транспорта я сначала заподозрил в первую очередь. Попробовал и JSON (как выше), и application/x-www-form-urlencoded с content в виде JSON-encoded строки. Поведение идентичное до байта, ошибка на тех же порогах. Отпало. Envelope тоже картину не объяснял: он добавляет порядка 200-300 байт, а разница между обещанным и реальным сидит на уровне десятков КБ.

У меня осталась гипотеза, которую я не могу подтвердить или опровергнуть, потому что нет доступа к их внутренней реализации. Скорее всего, Telegraph парсит content в собственную структуру Node и меряет против лимита уже её. При парсинге из JSON-массива объектов объём, судя по всему, пухнет: каждый { tag, children } обрастает метаданными или служебными полями, и то, что в запросе выглядит как 20 КБ, внутри у них может занимать условные 60. Это бы объяснило, почему ограничение 64 КБ написано в документации, а CONTENT_TOO_BIG прилетает сильно раньше. Но это догадка.

Практический вывод более прозаичный, чем хочется. Документированные 64 КБ не соответствуют ни верхней, ни нижней границе принимаемого. Работать можно только с эмпирически найденным числом, и у нас оно вышло таким: 17 КБ сериализованного JSON как зелёная зона, 18 КБ исходного markdown как точка отсечения перед отправкой. Цифры, разумеется, привязаны к текущему поведению сервиса, если Telegraph подкрутит свой внутренний лимит, придётся подкручивать и наш.

Специфика кириллицы

Отдельно стоит разобрать, почему русский текст упирается в ограничение раньше английского, потому что из-за этого баг и прятался.

Внутреннее представление строк в JavaScript - UTF-16. Длину в UTF-16 code units возвращает и String.prototype.length, и длина строки, полученной из JSON.stringify. В этом представлении кириллический символ занимает столько же, сколько латинский, один code unit. А вот при сериализации в HTTP-запрос JSON-строка кодируется в UTF-8, и там тот же кириллический символ уже занимает два байта. То есть при одинаковом length русский текст отправится примерно вдвое тяжелее английского по сети, и если Telegraph считает лимит по байтам после кодирования (а это наиболее правдоподобная версия), до него русский контент добирается где-то на 40-50% раньше.

На нашем случае это ловушка отработала без изъянов. EN-переводы у нас делает Gemini Flash Lite, они стабильно получаются короче RU-оригинала на 30-40% по символам, а в байтах уже на все 50%. «Баттл-пост» из пяти моделей в оригинале даёт 60-80 КБ, а его перевод 20-30 КБ. Второе помещается в зелёную зону Telegraph, первое нет. Вот вам и «на одном языке работает, на другом нет», хотя код между ними общий.

Вывод достаточно скучный, но я его для себя проговариваю регулярно, потому что он забывается. В любой интеграции, которая живёт на байтовом лимите, UTF-8 байты и UTF-16 code units это разные вещи, и путать их стоит только если вас устраивает, что на разных языках поведение будет разное. Это не про Telegraph, это про всё - базы, транспортный слой, rate-лимиты. В доках OpenAI и Anthropic лимиты вообще описаны в токенах, которые для кириллицы считаются по другим правилам, чем для латиницы, и единственный способ не промахнуться это прогонять репрезентативный корпус на всех языках, которые у вас в проде.

Что делать с большими постами

Из очевидных путей было два. Можно разбивать длинный пост на связанные Telegraph-страницы с хвостиком «Продолжение →» в конце каждой части. А можно резать исходник под безопасный размер и в конце давать ссылку на сайт, где лежит полная версия.

Я сначала попробовал идти по первому варианту и быстро отступил. Telegraph не умеет в нативную навигацию между страницами. Ссылку «Продолжение» нужно руками вставлять в конец каждого куска, а нормальной «← предыдущая» уже не получится, потому что URL следующей страницы неизвестен до момента её публикации. Это решается двухпроходным постингом, когда сначала создаётся скелет всех страниц, а потом по ним проходят и обновляют перекрёстные ссылки. Минус в том, что API-вызовов становится 2N, и любой частичный сбой на последнем шаге оставляет вам осиротевшие страницы, которые никуда не ведут. Прибавьте к этому отсутствие версионирования (читатель, пришедший по ссылке на часть 2, никак не узнает, что это не начало статьи), и желание что-то на этом строить пропадает.

Остался вариант с усечением. Он проще в реализации и, что сильно важнее, совпадает с реальной ролью Telegraph в нашем пайплайне. Мы публикуем страницу в Telegraph не ради того, чтобы её читали, а ради того, чтобы Telegram взял с неё OG-мету и нарисовал миниатюру-карточку под сообщением в канале. Для этой задачи с головой хватает первых двух-трёх экранов. Полная статья у нас на сайте, и ссылка на сайт в канальном сообщении всегда есть первой.

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

Реализация

Ключевая функция truncateMarkdownAtBoundary принимает исходный markdown, максимально допустимый объём в байтах UTF-8 и язык (для корректной локализации маркера), возвращает усечённый текст и флаг факта усечения:

const TELEGRAPH_SAFE_MD_BYTES = 18 * 1024;

function truncateMarkdownAtBoundary(md, maxBytes, lang) {
  if (Buffer.byteLength(md, 'utf8') <= maxBytes) {
    return { md, truncated: false };
  }

  let cut = md;
  while (Buffer.byteLength(cut, 'utf8') > maxBytes) {
    cut = cut.slice(0, Math.floor(cut.length * 0.9));
  }

  // Предпочитаем резать по двойному переводу строки, то есть по границе
  // абзаца, а не посреди предложения. Если такой границы в нижней половине
  // нет, оставляем hard cut.
  const lastBreak = cut.lastIndexOf('\n\n');
  if (lastBreak > cut.length * 0.5) cut = cut.slice(0, lastBreak);

  const note = lang === 'en'
    ? '\n\n_Article truncated for Telegraph. Read the full version on the site._'
    : '\n\n_Статья сокращена для Telegraph. Полный текст на сайте._';

  return { md: cut + note, truncated: true };
}

Тут есть две неочевидные детали, про которые стоит сказать отдельно, а то их потом спросят в комментариях.

Цикл режет не по байтовому индексу, а по длине в code units с шагом в 10%. Это важно: если нарезать по целевому байтовому индексу, можно попасть ровно в середину кириллического двухбайтового символа, получить невалидную UTF-8 последовательность, и Telegraph такой payload просто отбросит. Срез через String.prototype.slice по длине строки валидную границу гарантирует, потому что в BMP (куда попадает кириллица) один code unit всегда равен одному символу. Шаг в 10% на первый взгляд выглядит прожорливо, но по факту на типичных размерах входа цикл укладывается в одну-две итерации. Для edge case вроде очень коротких текстов у нас и так ранний return.

И второе. Поиск ближайшей границы абзаца через cut.lastIndexOf('\n\n') вещь опциональная, но я решил, что она того стоит. Без неё срез часто приходится на середину предложения или даже слова, и в Telegraph это выглядит как обычный технический баг. Условие lastBreak > cut.length * 0.5 просто подстраховка на случай, когда весь текст это один большой абзац без пустых строк: в таком вырожденном случае откатываться на ближайший \n\n уже нет смысла, лучше оставить hard cut, чем потерять половину материала.

Теперь про retry-цикл, он вторая половина решения. Число 18 КБ я получил на типичном содержимом, но реальные публикации иногда бывают «дорогими» по сериализации, например если в тексте много ссылок (каждая это лишние 30-50 байт JSON за счёт атрибутов href). Поэтому я закладываюсь, что даже первое усечение может не пройти. Логика простая: на CONTENT_TOO_BIG уменьшаем лимит на 40%, повторяем, максимум три попытки.

for (let attempt = 0; attempt < 3; attempt++) {
  const token = await getToken();
  if (!token) return null;

  const content = buildContent(workingMd, lang);
  const res = await fetch(`${TELEGRAPH_API}/createPage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      access_token: token,
      title: title.slice(0, 256),
      author_name: 'NeuroVerdict',
      author_url: 'https://neuroverdict.com',
      content,
      return_content: false,
    }),
  });
  const data = await res.json();

  if (data.ok) {
    return { url: data.result.url, path: data.result.path };
  }
  if (/CONTENT_TOO_BIG/i.test(data.error || '')) {
    maxBytes = Math.floor(maxBytes * 0.6);
    if (maxBytes < 4 * 1024) return null;
    ({ md: workingMd } = truncateMarkdownAtBoundary(sourceMd, maxBytes, lang));
    continue;
  }
  // Прочие ошибки без повторов.
  return null;
}

Здесь намеренно сделана жёсткая нижняя граница в 4 КБ. Ниже неё материал становится настолько обрезанным, что лучше отказаться от Telegraph-публикации вообще и оставить в канале сообщение со ссылкой только на сайт. Этот fallback уже был в коде, просто теперь он отрабатывает по делу, а не «на любой шорох».

Заодно, пока был внутри функции, я починил и смежную проблему: поведение при протухшем или некорректном токене. Раньше в этом сценарии createTelegraphPage тоже молча возвращал null. Теперь, если API возвращает ACCESS_TOKEN_INVALID, код автоматически вызывает createAccount (получение нового токена занимает одну HTTP-операцию), сохраняет его в памяти процесса и повторяет публикацию. Токен пишется в лог отдельной строкой, чтобы оператор мог скопировать его в переменные окружения и избежать пересоздания аккаунта на каждом рестарте. Это самопочинка, а не полноценное решение, но она убирает классическую ситуацию, когда сервис падает целиком из-за того, что кто-то случайно запорол один env-var.

Почему мы не замечали это несколько недель

Сам баг исправляется одной функцией и парой десятков строк вокруг неё. Но меня после разбора больше волновал другой вопрос. Как вообще получилось, что сервис неделями публиковал в RU-канал сообщения без половины ссылок, и никто в команде этого не заметил?

Ответ лежит в коде publishToTelegraph:

const result = await createTelegraphPage(post.title, post.content_md, authorUrl, lang);
const telegraphUrl = result?.url || null;

if (!telegraphUrl) {
  console.warn(`[telegraph] No URL for "${post.title}", will still post to channel`);
}
// ... дальше всегда постим в канал, даже если telegraphUrl === null

Формально это корректный fallback. Лучше опубликовать в канал сообщение со ссылкой только на сайт, чем не опубликовать ничего. На практике это ловушка: сервис начинает молча деградировать, пользователи по инерции не жалуются (ну, пришло и пришло, ссылка же есть), операторы в эти конкретные console.warn не смотрят, а проблема живёт себе и живёт. Формально у нас всё «ок», а по факту часть функциональности отвалилась.

После разбора я дополнил возвращаемое значение publishToTelegraph явными флагами успеха: { telegraphUrl, channelOk, channelError, channelSkipped }. Вызывающий код теперь может понять, на каком именно этапе была просадка. Кроме того, у нас хранилось в БД поле channel_posted_at, которое раньше выставлялось при любой успешной публикации в канал, включая публикацию без Telegraph-ссылки. Теперь оно выставляется только при полном успехе, а партия «частичных» публикаций, которые лежат без Telegraph-URL, отдельно ловится и предлагается к ре-публикации через внутренний эндпоинт /admin/blog/retry-today-channels.

Я для себя вынес из этой истории простое правило, по крайней мере для нашей команды. Если fallback на частичный успех в продакшне срабатывает чаще, чем раз в полгода, к нему нужен явный алерт, а не только console.error. Второй вариант это отдельная метрика на дашборде с долей деградированных публикаций, но по моему опыту завести шумный лог-алерт в два-три раза проще, чем завести и, главное, поддерживать ещё одну метрику, за которой потом некому будет следить.

Верификация

В качестве конечного тестового сценария я взял реальный генерируемый RU-пост в жанре «баттл пяти моделей» общим объёмом 286 КБ:

[telegraph] Truncated source from 286434 → 16945 bytes for "..."
[telegraph] API error: CONTENT_TOO_BIG
[telegraph] Retrying with smaller payload (11059 bytes)
[telegraph] Published: https://telegra.ph/Bolshoj-testovyj-post-...

Первая попытка с лимитом 18 КБ отдала CONTENT_TOO_BIG, что ожидаемо: содержимое оказалось «дорогим», в нём много ссылок. Retry-цикл сжал до 11 КБ, вторая попытка прошла. Итоговая страница на Telegraph выглядит как отдельная самодостаточная заметка, в конце которой читатель видит маркер усечения и ссылку на полную версию на сайте.

Тесты были обновлены: к существующим 51 юнит-тесту модуля telegraph.js добавлены два интеграционных, покрывающих поведение на граничных размерах (на ~30 КБ и на ~150 КБ) и поведение retry-цикла при CONTENT_TOO_BIG. Все тесты зелёные, изменения ушли в отдельный PR и в production.

Напоследок

На документацию чужих API я стал смотреть иначе. Как на отправную точку, не как на опору. Особенно если речь про небольшой, нишевый сервис, который живёт уже много лет без публичного changelog. Telegraph здесь образцовый пример, он существует с 2016 года, доки последний раз обновлялись, кажется, ещё при выходе, и цифра 64 КБ с тех пор, возможно, была актуальной, а возможно, просто ориентиром для порядка величины. За почти десять лет в сервис явно вливали дополнительные проверки и внутренние преобразования, о которых наружу никто особо не сообщал.

На всякий случай, если у кого-то похожий сценарий с Telegraph плюс Telegram-канал для блога: зайдите в логи своего сервиса и погрепайте CONTENT_TOO_BIG или [telegraph]. По тому, как этот баг проявляется (молча, без жалоб пользователей), я почти уверен, что там что-то есть.

Комментарии (0)