Постановка

Есть список публичных адресов Telegram. По каждому надо ответить на два вопроса: существует ли он и что это за сущность. Дальше у каналов прочитать публичное превью и классифицировать содержимое.

Никакого API: MTProto для такой задачи избыточен, а веб-превью Telegram отдаёт всё нужное обычным GET. Казалось бы, сорок строк на fetch и разбор.

Ловушка 1. Несуществующий адрес отвечает 200

Первое предположение: раз имя свободно, будет 404. Не будет.

GET https://t.me/zzqqxx_not_a_real_channel_9182
HTTP/2 200
<meta property="og:title" content="Telegram: Contact @zzqqxx_not_a_real_channel_9182">
<meta property="og:image" content="https://telegram.org/img/t_logo_2x.png">
<meta property="og:description" content="">

Страница валидная, заголовок на месте, разбор проходит. Проверка «если нет og:title, значит адреса нет» такой ответ пропускает: заголовок есть, он просто служебный.

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

Рабочий признак нашёлся сравнением с существующей сущностью: у неё в og:title стоит имя (BotFather, TaskerBot), у свободного адреса — строка Telegram: Contact @имя.

Ловушка 2. Ограничитель частоты тоже отвечает 200

Второй прогон дал другую картину: 145 адресов внезапно стали «личными аккаунтами». Код в этой части не менялся вовсе.

Причина — темп запросов. При частых обращениях Telegram отдаёт снова 200, но страница пустая: ни заголовка, ни счётчика, ни разметки профиля. Снаружи это неотличимо от ловушки №1, и алгоритм честно относил такие ответы к «нет счётчика, значит аккаунт».

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

Ловушка 3. Признак, который есть у всех

Разбираясь с ботами, я решил опереться на кнопку «Send Message» в карточке: логика была, что у бота и живого аккаунта она есть, а у свободного имени нет.

tgme_action_button_new" href="tg://resolve?domain=zzqqxx_not_a_real_channel_9182">Send Message

Это вывод карточки несуществующего имени. Кнопка есть и там: Telegram предлагает написать по любому адресу, потому что имя может быть занято пользователем со скрытым профилем.

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

Ловушка 4. Превью бывает только у каналов

t.me/s/имя отдаёт публичное превью с последними сообщениями. Соблазнительно использовать его как проверку «открыт ли адрес»: есть превью — открыт, нет — закрыт или мёртв.

Так нельзя. Превью существует только у каналов. У группового чата его нет никогда, даже у полностью открытого. То есть отсутствие превью означает одновременно «группа», «закрытый канал» и «адреса нет» — три разных исхода под одним признаком.

Тип сущности определяется другим: строкой счётчика в карточке. N subscribers — канал, N members, M online — группа. Это единственное место, где Telegram различает их явно.

Ловушка 5. Ленивый разбор HTML отрезает половину текста

Текст сообщения в превью лежит в блоке с известным классом. Очевидный разбор:

html.matchAll(/class="tgme_widget_message_text[^"]*"[^>]*>([\s\S]*?)<\/div>/g)

Ленивая группа доходит до первого </div>, а внутри текста сообщения бывают вложенные блоки: цитаты, разметка, вложения. Всё после первого вложенного блока терялось молча — исключения нет, текст есть, он просто короче.

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

function divBody(html, openEnd) {
  let depth = 1;
  const re = /<(\/?)div\b[^>]*>/gi;
  re.lastIndex = openEnd;
  let m;
  while ((m = re.exec(html))) {
    depth += m[1] ? -1 : 1;
    if (depth === 0) return html.slice(openEnd, m.index);
  }
  return html.slice(openEnd);
}

Шестая ловушка, и она не про Telegram

Классификатор определял тему канала по маркерам в тексте. В список признаков найма попали слова о формате работы: «удалёнка», «remote», «full-time». Выглядит бесспорно — так пишут в вакансиях.

Так пишет и заказчик: «нужен человек удалённо, полная занятость, посоветуйте кого». На таком тексте классификатор уверенно отвечал «вакансия».

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

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

Что из этого следует

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

Что реально помогло, по убыванию пользы:

  1. Контрольные отрицательные примеры в каждом прогоне. Три выдуманных имени в списке ловят ловушки 1 и 3 мгновенно и стоят три запроса.

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

  3. Проверка классификатора с обеих сторон. Положительный пример проверяет только то, что вы не забыли включить признак.

  4. Внешний читатель. Шестую ловушку я бы не увидел: она в той части, которую писал уверенно.

Ни одна из шести не ловится типами, линтером или тестом на «код не падает». Все шесть ловятся одним приёмом: подать проверке то, на чём она обязана покраснеть, и посмотреть, покраснела ли.