Как на самом деле работает детект Puppeteer: 12 сигналов, которые выдают ботов
Фреймворки автоматизации наследуют отпечаток настоящего браузера, но меняют его десятками заметных способов. Полевой справочник по 12 сигналам — JavaScript, сетевым и поведенческим, — которыми ловят Puppeteer и Playwright.
Фреймворки автоматизации управляют настоящими браузерами, а значит, наследуют их отпечаток. Chrome под управлением Puppeteer имеет тот же user agent, ту же версию Chrome и ту же сборку Chromium, что и Chrome живого человека. На первый взгляд они неотличимы.
Но фреймворки автоматизации меняют браузер десятками тонких способов — большинство из них видно из JavaScript. Детект — не магия. Это чек-лист.
1. Флаг navigator.webdriver
Самый простой и известный сигнал. Когда Chrome запущен под фреймворком автоматизации, navigator.webdriver возвращает true. Это часть спецификации W3C WebDriver, и Chromium реализует его по умолчанию.
Любой серьёзный проект автоматизации закрывает это за секунды. Puppeteer-extra-stealth переопределяет геттер. Пользователи Playwright инъектируют скрипты Object.defineProperty в новые документы. Флаг скрыть тривиально.
Но сам факт, что его можно тривиально скрыть, — уже сигнал. Легитимному Chrome нечего скрывать. Когда страница проверяет Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver') и дескриптор выглядит иначе, чем у нативного браузера, автоматизация поймана даже после того, как флаг пропатчен.
2. Аномалия объекта chrome
Легитимный Chrome предоставляет глобальный объект window.chrome с существенной структурой — chrome.runtime, chrome.loadTimes, chrome.csi. Headless Chrome и старые конфигурации Puppeteer либо вовсе не содержат этого объекта, либо предоставляют урезанную версию.
Stealth-плагины воссоздают объект, но воссоздание несовершенно. chrome.runtime.onConnect может присутствовать без chrome.runtime.PlatformOs. Сигнатуры функций могут возвращать объекты вместо undefined. Каждое несовпадение — положительный сигнал.
3. Несогласованности Permissions API
Настоящие браузеры возвращают согласованные результаты при запросах через Permissions API. navigator.permissions.query({name: 'notifications'}) должен вернуть 'default', если пользователь явно не разрешил и не запретил доступ.
Headless Chrome по умолчанию возвращает 'denied', потому что нет UI, чтобы показать запрос разрешения. Фреймворки автоматизации латают это — но часто неверно. Частая улика: запрос необычного разрешения вроде 'clipboard-read' возвращает результат, не совпадающий с тем, что делает настоящий Chrome в текущей версии.
4. Массивы плагинов и MIME-типов
navigator.plugins в настоящем браузере возвращает PluginArray с записями вроде PDF-вьюера и PDF-плагина Chromium. В headless-режиме этот массив пуст.
Stealth-плагины добавляют фиктивные записи, но у записей часто неверные свойства — отсутствует length, неправильные поля описания или объекты плагинов, которые не ведут себя как экземпляры Plugin при проверке через instanceof.
5. Несовпадения языка и локали
navigator.language и navigator.languages должны совпадать с HTTP-заголовком Accept-Language. Они также должны быть согласованы с часовым поясом, который сообщает Intl.DateTimeFormat().resolvedOptions().timeZone.
Бот, заявляющий, что находится в Киеве (часовой пояс Europe/Kyiv), но отправляющий Accept-Language: en-US,en;q=0.9 и сообщающий navigator.language === 'en-US', возможен, но статистически необычен. В сочетании с IP из дата-центра во Франкфурте картина проясняется.
6. Строки WebGL renderer
WebGL2RenderingContext.getParameter(WebGLDebugRendererInfo.UNMASKED_RENDERER_WEBGL) возвращает вендора и модель GPU. На реальной машине это может быть:
ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0)
Headless Chrome, работающий в контейнере, часто возвращает:
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
Любая строка GPU, содержащая SwiftShader или Subzero, — сильный сигнал бота.
7. Аномалии экрана и вьюпорта
У настоящего окна браузера window.outerWidth и window.outerHeight учитывают хром браузера — адресную строку, вкладки, панель закладок. Разница между внешними и внутренними размерами обычно составляет 80–140 пикселей по вертикали.
У headless-браузеров часто outerHeight === innerHeight, потому что нет хрома для отображения. Puppeteer с headless: false это исправляет, но многие конфигурации автоматизации по-прежнему работают с равными размерами.
8. Перечисление шрифтов
У реальных пользователей установлены сотни шрифтов, различающихся по ОС, языку и установленным приложениям. У Windows 11 с установленным Office более 500 шрифтов. У контейнеров автоматизации на Alpine Linux — 30.
Перечисление шрифтов через document.fonts.check() или рендеринг на offscreen canvas выявляет это немедленно. Браузер, сообщающий о себе как Windows 11 Chrome, но предоставляющий лишь 30 шрифтов, — почти наверняка автоматизация.
9. Энтропия движений мыши
Реальные пользователи двигают мышью с дрожанием. Кривые нелинейны. Скорость меняется. Между двумя кликами обычно происходят сотни событий mousemove.
Фреймворки автоматизации синтезируют движение — либо прямыми линиями к координатам, либо запрограммированными кривыми, лишёнными микротремора человеческой руки. Даже симуляции на кривых Безье, как правило, имеют подозрительно гладкие профили ускорения.
Системы детекта ботов собирают трассы движений и оценивают их относительно модели человеческого движения. Трассы со слишком постоянной скоростью, слишком чистыми кривыми или отсутствующими микрособытиями помечаются.
10. Тайминговые сигнатуры
Фреймворки автоматизации выполняют JavaScript с иными тайминговыми характеристиками, чем браузеры под управлением человека. Тайминг performance.now() между событиями, особенно между pointerdown и pointerup, следует иному распределению.
Реальное время удержания клика — 50–150 мс с высокой дисперсией. Время удержания клика по умолчанию в Puppeteer — фиксированное значение, часто 30 мс или 100 мс, почти без дисперсии. Сотня кликов с идентичным временем удержания — однозначная автоматизация.
11. Несовпадение TLS-отпечатка
Этот сигнал полностью обходит JavaScript. Когда Puppeteer подключается, он использует TLS-стек Chromium — тот же, что и настоящий Chrome. Но скраперы на основе undici или axios, которые прикидываются Chrome через подмену user agent, используют TLS-стек Node.js, у которого отчётливо иная сигнатура Client Hello (хеш JA4).
Запрос, заявляющий User-Agent: Chrome/124.0.6367.60, но предъявляющий TLS-отпечаток Node.js, — мгновенное подтверждение небраузерного клиента.
12. Утечка протокола CDP
Puppeteer и Playwright общаются с Chrome через Chrome DevTools Protocol. В некоторых конфигурациях этот протокол оставляет обнаружимые артефакты — лишние свойства объекта Runtime, изменённые форматы Error.stack или специфические выводы console.debug, появляющиеся только при подключённом CDP-клиенте.
В частности, команда Runtime.enable меняет способ отрисовки трасс стека. Боты, которые никогда не вызывают ошибок, избегают этого сигнала, но любой поток автоматизации, натыкающийся на исключение (а большинство так и делает), оставляет отпечатки.
Наслоение этих сигналов
Ни один сигнал сам по себе не доказывает автоматизацию. Реальный пользователь с необычным драйвером GPU может вернуть странную строку WebGL. Пользователь, заботящийся о приватности, может иметь изменённый navigator.plugins.
Сила современного детекта — в комбинировании сигналов: 12 слабых срабатываний сильнее одного сильного, потому что реальный пользователь редко срабатывает более чем на 2–3 аномалии одновременно.
Системы детекта присваивают каждому сигналу вес и вычисляют оценку. Выше порога посетитель считается автоматизацией. Точные порог и веса — самое сложное, что нужно правильно подобрать: слишком строго — блокируются легитимные пользователи; слишком мягко — проскакивают изощрённые боты.
Сами сигналы — лёгкая часть. Почти каждый фреймворк автоматизации выдаёт все 12. Настоящая инженерия — решить, какие комбинации важны, какие являются ложными срабатываниями и как обновлять модель по мере адаптации фреймворков автоматизации.