Постановка
Есть список публичных адресов 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 и 3 мгновенно и стоят три запроса.
Отдельный исход «не удалось узнать». Пока его нет, любой сетевой сбой молча становится утверждением о данных.
Проверка классификатора с обеих сторон. Положительный пример проверяет только то, что вы не забыли включить признак.
Внешний читатель. Шестую ловушку я бы не увидел: она в той части, которую писал уверенно.
Ни одна из шести не ловится типами, линтером или тестом на «код не падает». Все шесть ловятся одним приёмом: подать проверке то, на чём она обязана покраснеть, и посмотреть, покраснела ли.

