У мессенджера MAX нет публичного API со статистикой каналов. Числа есть у каталогов-агрегаторов, но они чужие, а последний их снапшот датирован 10 июля. Нам нужен был свой ряд: чтобы график на странице канала показывал наш замер. Ниже — как устроен сбор и четыре места, где мы ошиблись; одно из них стоило трёх ночей молчащего ряда.
Откуда берутся числа
Сайт max.ru собран на SvelteKit, и у каждой страницы канала есть data-эндпоинт:
GET https://max.ru/<handle>/__data.json
Без авторизации, без токена, HTTP 200, около 450 байт. Живой ответ на 26 августа 2026:
{"type":"data","nodes":[null,{"type":"data","data":[ {"linkInfo":1,"isBot":8,"canonical":9}, {"channel":2}, {"title":3,"description":4,"icon":5,"participantsCount":6,"channelId":7}, "MAX • Анонсы", "Официальный канал анонсов мессенджера MAX", "https://i.oneme.ru/i?r=...", 3620284, 68545739033120, true, "https://max.ru/max_news" ],"uses":{"url":1}},null]}
Ключевая деталь формата: SvelteKit сериализует данные плоским массивом с дедупликацией, и в объектах лежат не значения, а индексы в том же массиве. {"channel":2} читается как «канал описан элементом №2», а {"title":3, ..., "participantsCount":6} — «заголовок в элементе 3, счётчик подписчиков в элементе 6». Обход обязан идти по индексам: наивный поиск подстроки participantsCount найдёт саму схему — имя поля стоит в теле ответа раньше, чем значение.
Эндпоинт годится только для обогащения: хендл нужно знать заранее, публичного листинга каналов у max.ru нет.
Грабля 1: формат сменился, а парсер вернул None
Около 8 июля max.ru вставил в структуру уровень косвенности. Раньше channel лежал прямо в data[0], стало: data[0] = {"linkInfo":1, ...} → data[1] = {"channel":2}. Парсер честно вернул None по всем каналам.
Ночной перемер ядра выдал ok = 0 / 95 262, not_found = 95 262, error = 0 и exit 0. Три ночи подряд, зелёный лог, ряд стоит. Отказ выглядел как здоровая ночь: ошибок нет, скрипт отработал, просто «каналов не нашлось».
Отсюда два правила. Держать обе формы разбора — старая всплывает в кэшах и зеркалах, и слепой переход на новую сломал бы их так же молча. И сторожить не только наличие отказа, но и его форму: ok = 0 при непустом targeted теперь даёт exit 2 и алерт.
Гвард висит на проходе по ядру; выборочные прогоны вроде --entity-type bot он не покрывает. Частичный сдвиг формата он тоже не поймает — если поля переедут внутри конверта, парсер вернёт заполненную наполовину запись, и она пройдёт как ok. Дыра известна и остаётся открытой.
Грабля 2: у ботов другая нода
У бота структура иная: {"bot": i} → data[i] = {id, name, avatar, description} — без participantsCount. Публичного счётчика подписчиков у ботов в MAX нет.
Парсер искал только channel, и первый прогон верификации с --entity-type bot дал ok = 0 / not_found = 20 — то есть ночной шаг штамповал бы 5 803 живых бота как «не найден». Ветку для ботов пришлось ставить строго ПОСЛЕ нормализации linkInfo, а тип сущности брать из ответа: захардкоженный channel переписал бы entity_type у всех ботов разом.
Грабля 3: not_found был мусорной корзиной
Самая дорогая. Исходное правило: парсер вернул None → пишем not_found. Под него попадали три разных события: канала действительно нет; max.ru сменил формат (см. выше); нас затроттлило.
Троттлинг здесь не гипотеза. max.ru держит кумулятивный per-IP soft-лимит и при упоре отдаёт HTTP 200 с телом, которое не резолвится — не 429, не капчу, не редирект. Симптом: ночной not_found гулял между 13 923 и 30 335 при неизменных targeted = 82 345, и читалось это как «тридцать тысяч каналов умерло» — при том, что имя канала непустое у 82 345 из 82 345.
Хуже разброса было следствие: пропуск всё равно штамповал max_checked_at, в ряду появлялась дыра, а детектор аномалий читал дыру как flat_then_jump — то есть писал наши собственные сбои в публичный раздел анти-накрутки.
Статусы расщепляли трижды. Два первых захода оказались неверны ровно в том случае, ради которого писались.
Заход 1. Критерий «дошли ли до валидного конверта» = «JSON распарсился и есть нода type="data"». Опровергнут живым прогоном: у несуществующего канала ноды type="data" нет вовсе, вместо неё приходит
{"type":"data","nodes":[null,{"type":"error","error":{"message":"Not found"},"status":404},null]}
HTTP 200, 98 байт. То есть каждый реально мёртвый канал помечался «нас не пустили» и не демоутился никогда.
Заход 2. Добавили форму «явный 404 внутри 200» — и упёрлись снова: на троттлинге конверт валиден. Четыре прокси в одну секунду на один и тот же URL:
46.8.23.203 3 словаря, participantsCount 24 663 → полное тело 188.130.187.204 3 словаря → полное тело 109.248.142.242 2 словаря, чисел нет → урезанное 45.90.196.70 3 словаря → полное тело
Отвечает по-разному один и тот же адрес — разница идёт по IP. У обрезка data[0] остаётся валидным словарём с linkInfo, но целевой узел пуст.
Работающий критерий отвечает на другой вопрос — не «распарсилось ли», а «о ком этот ответ»:
linkInfoуказывает на непустой словарь → max.ru сказал факт о канале →ok;пустой
data:[{}]или явныйstatus: 404→ тоже факт о канале →not_found, демоут разрешён;linkInfoесть, а узел не резолвится → это факт о нас →blocked: ряд не трогаем,max_checked_atне штампуем, канал не демоутим.
Четвёртый исход, error, живёт уровнем выше: сеть не ответила или HTTP пришёл не 200. Такие каналы уходят в ретрай и до разбора тела не доходят.
Ночь на 26 августа по журналу покрытия: targeted 82 349 · ok 53 725 · blocked 28 467 · error 157. Три ночи подряд blocked рос (13 421 → 26 630 → 28 467) при падении ok (68 928 → 55 616 → 53 725). Это дрейф прокси-пула, и виден он только потому, что колонка разделена: под старым правилом те же 28 467 значились бы как «каналов не найдено», и чинить было бы нечего.
Ради этого колонку и разделили. Пул заменили, и с 28 августа ночь берёт ядро почти целиком: ok 80 731 при blocked 1 620, на 5 сентября — 80 645 из 82 352.
Тело, которое max.ru отдаёт при настоящем упоре в кумулятивный лимит, мы так и не захватили. Критерий «целевой узел не резолвится» выведен из живого расхождения по IP; фикстуры самого упора у нас нет, и порог тревоги blocked > ok держится на предположении.
Почему нельзя смешивать источники
Ряд обязан быть однородным по источнику: всё производное — дельты, график, аномалии — считается только по снапшотам с source_aggregator = 'max.ru'. Правило появилось после конкретного инцидента. Когда расчёт дельт брал последний снапшот без фильтра источника, починка парсера дала дельту «вчера чужое число → сегодня своё» и разослала алерт «+339 %» на канале, который реально вырос на 0,39 %. Детектор аномалий при этом отработал корректно: он увидел impossible_velocity — на нашем собственном переключении источника.
Замер на 1 сентября — 89 568 пар «наше свежее число / последнее число агрегатора». Пара берётся так: свежайший наш снапшот канала на 1 сентября против свежайшего снапшота того же канала у любого из агрегаторов, оба больше нуля.
перцентиль | наше / чужое |
|---|---|
p10 | 0,946 |
p25 | 0,981 |
медиана | 1,009 |
p75 | 1,102 |
p90 | 1,270 |
p99 | 2,909 |
В типичном случае агрегатор ошибается на проценты. Уезжает хвост: 2,34 % пар расходятся вдвое и больше, 0,67 % — вчетверо, 0,30 % — в восемь раз. Перекос при этом односторонний: из тех же 2,34 % в 2,15 % выше оказывается наше число, то есть чужие данные чаще занижены, чем завышены.
Отдельно от точности стоит их возраст. Последний снапшот у шести из семи агрегаторов, которые мы когда-либо парсили, датирован 10 июля; седьмой замер ещё старше, 26 мая. К 1 сентября эти числа стояли на месте 53 суток. Смешать такое в один ряд значит получить ступеньку на графике в момент смены источника и не суметь отличить её от накрутки.
Грабля 4: чужой текст в собственном описании
Числа мы к тому моменту отстояли. Текст — нет. short_description карточек приезжал скрапом с каталогов-конкурентов вместе с их обвязкой, и это уехало в прод: строка рендерится лидом страницы, уходит в <meta name="description"> и в подписи плиток листингов.
Замер 1 сентября: имя чужого каталога в описании несли 18 804 индексируемые страницы — 39 % всего, что мы отдаём роботу. Три формы, и лечится каждая по-своему:
форма | страниц | что с ней делать |
|---|---|---|
| 14 652 | свой текст живой, чужой хвост приклеен в конце — отрезать |
| 707 | интерфейсный мусор целиком, вместе с чужим адресом почты |
| 3 440 | шаблон, полезного — обрезанный на многоточии огрызок |
Три формы покрывают 18 799 страниц из 18 804; оставшиеся пять в разбор не попали.
На 14 652 строках Listmax хвост стоит в конце без исключений — значит его можно резать регуляркой, не разбирая текст, и живой авторский лид остаётся на месте. Где после реза остаётся меньше сорока символов, подставляются первые двести символов официального max_description; если и он короче сорока — короткая строка, собранная из полей БД. Ничего не выдумывается: имя, тип, категория.
У правки оказался неочевидный побочный эффект: часть страниц держалась в индексе ровно длиной чужой обвязки — гейт индексируемости смотрит на длину описания. После вычистки 33 страницы ушли в noindex. Чтобы правка не выбила те, что поисковик показывает прямо сейчас, в предикат добавлена нога «страница уже была в выдаче» — та же защита, что до этого стояла у каналов. Из 467 каталожных URL, которые Яндекс показывал за месяц, не закрылась ни одна.
Строка «в каталоге такого-то» не ломает парсер и не валит тесты. Она просто стоит на восемнадцати тысячах твоих страниц, пока кто-нибудь не сделает замер.
Анти-накрутка
Детектор ищет три паттерна: spike_revert (скачок от +50 % и не меньше 200 подписчиков, а следом откат на треть от пика в пределах трёх точек), impossible_velocity (+100 % за сутки при абсолютном приросте от 500) и flat_then_jump (окно ровно в семь точек с коэффициентом вариации меньше 1 %, а следующая точка — плюс 50 % и больше).
Важнее самих паттернов оказались две вещи вокруг них.
Гейт активации. Флаги пишутся только для каналов с рядом от 18 точек, и точки считаются уже после отброса выбросов. На коротком ряду любой паттерн — шум, а публично помечать канал накрученным по шуму нельзя. Порог держали и тогда, когда он означал ноль флагов месяцами.
Разрывы ряда. Все три детектора инферят «за один день», а ночной обход берёт не всё ядро сразу (см. blocked выше) — соседние точки могут стоять на несколько суток врозь. Без явной проверки зазора каждый пропуск фабрикует ложный флаг: детектор начинает обвинять каналы в наших собственных недоборах. Проверка стоит на паре точек вокруг скачка, но не внутри плато flat_then_jump — семь «ровных» точек там всё ещё могут быть растянуты на месяцы. Это следующее, что надо чинить.
На 5 сентября в базе 507 флагов на 478 каналах: flat_then_jump 437, impossible_velocity 64, spike_revert 6.
Что получилось
На 1 сентября 2026 в ряду 3 236 412 собственных замеров подписчиков, снятых за 76 ночей начиная с 31 мая. Восемнадцать суток в этом промежутке пустые.
Своя точка есть у 90 119 карточек, из них 90 106 — каналы. Глубина ряда доходит до 61 точки: от семи точек — у 82 055 карточек, от восемнадцати — у 81 094, от тридцати — у 79 991. Ядро в 82 351 канал ночь обходит примерно за два часа.
Метод целиком — что считается ростом, как обрабатываются пропуски и почему одна точка не динамика — описан на gosmax.ru/methodology. Флаги анти-накрутки с разбором кейсов лежат на gosmax.ru/anti-cheat.
Вывод, ради которого всё это писалось: три грабли из четырёх — не ошибки парсинга, а ошибки классификации отказа. Скрипт, который на любую неудачу возвращает один статус, ничего не измеряет — он производит число, похожее на измерение.
Постскриптум: журнал изменений оказался журналом наших правок
Ряд подписчиков — не единственное, что пишет ночь. Параллельно она сравнивает имя, описание, категорию и знак проверки с тем, что было вчера, и складывает расхождения в entity_changes. Мысль была в том, чтобы владелец канала видел «вас переименовали такого-то числа». Когда дошло до вывода этого журнала на сайт, выяснилось, что состоит он почти целиком из наших собственных правок.
Поле name дало за всё время 46 074 строки. Из них 22 507 — снятие префикса «Max », который добавляет сам мессенджер в заголовке страницы, а мы одно время забирали вместе с именем. Ещё 18 859 — разэкранирование HTML-сущностей: МУК "ДК с. Спасское" превращалось в МУК "ДК с. Спасское", и diff честно считал это переименованием. Две формы дают 90 % поля; после фильтра от 46 074 строк остаётся 3 132.
На ленте это видно ещё резче. За тридцать дней до 1 сентября таблица приняла 8 211 строк, а до страницы дошла 161 — 98 % отсеялось как наша собственная уборка.
Ловушка ровно та же, что с зазором в детекторе накрутки: наблюдатель записывает собственное поведение и выдаёт его за поведение объекта. Причём здесь это было видно на карточке — страница писала «переименован (был «Max …»)» про действие, которого владелец не совершал. Фильтр в итоге живёт в двух местах: нормализация не логируется на записи, а исторические строки отсекаются на чтении — одним фрагментом SQL, общим для карточки и для сводной ленты, иначе они разошлись бы в показаниях.
Читающая половина фильтра при этом заметно грубее пишущей: она отбрасывает строку, если старое имя после разворачивания сущностей содержит новое как подстроку. Настоящее сокращение вроде «Департамент культуры Москвы» → «Культура Москвы» под это правило тоже попадёт и на ленту не выйдет. Охват выбран вместо точности осознанно, но цена у выбора есть.
Что осталось после чистки, видно на gosmax.ru/changes: 161 запись за тридцать дней, преимущественно новые подтверждения знака. Недельные срезы — на gosmax.ru/report.

