Как найти утечки, по которым вычисляют headless-браузер

headless browser detection - How to Find Headless Browser Detection Leaks in Your Stack

Обнаружение headless-браузера это не одна проверка, а набор мелких. Большинство сборок для автоматизации проваливают одни и те же три ещё до того, как первая страница успеет загрузиться. Панель какого-нибудь вендора для этого не нужна: всё важное читается прямо из браузера, который у вас уже открыт. В этой статье четыре пробы, порядок находок по скорости блокировки и патчи для тех сигналов, которые стоит починить. Здесь же граница, о которой обычно молчат. Чистый отпечаток снижает частоту проверок, но никогда не сводит её к нулю.

Что понадобится

  • Chrome или Chromium и способ выполнить JavaScript на странице: вручную через DevTools или вызовом evaluate у драйвера.
  • Любой драйвер, который вы уже используете. Пробы написаны на чистом JavaScript, поэтому Playwright, Puppeteer и Selenium работают без изменений.
  • Python 3.10 или новее, если хотите запустить патч и пример решения в конце статьи.
  • CapSkip установлен и запущен, и нужен только для последнего раздела. Откройте руководство по настройке и посмотрите, какой режим подключения вы выбрали: от него зависит, какой хост будет стоять в коде.

Один момент стоит решить до начала. Решателю не обязательно жить на той же машине, что и браузер. Локальный режим слушает адрес loopback и обслуживает только это устройство, а серверный слушает ваш сетевой или публичный IP, поэтому к одному экземпляру по API может обратиться другая машина, VPS или хостинговый раннер. Оба режима описаны в разделе Настройки подключения, а ниже есть короткий раздел про серверный вариант.

Шаг 1: прочитайте сигналы, которые решают всё на старте

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

// Paste into DevTools, or hand it to your driver's evaluate call
// so it runs in the real page context rather than a fresh tab.
const leaks = {
  webdriver: navigator.webdriver,
  plugins: navigator.plugins.length,
  languages: navigator.languages.join(","),
  cores: navigator.hardwareConcurrency,
  memory: navigator.deviceMemory,
  platform: navigator.platform,
  hasChrome: !!window.chrome,
  hasRuntime: !!(window.chrome && window.chrome.runtime),
};
console.table(leaks);   // read every row, not just the first

Настоящая сессия Chrome отдаёт свойство webdriver как undefined, от трёх до восьми записей в списке плагинов, минимум два принятых языка, число ядер под стать машине, строку платформы вида Win32 или MacIntel и заполненный объект window.chrome с runtime внутри. Автоматизированный контейнер обычно отдаёт true, ноль, один язык, два ядра, Linux x86_64 и вообще ничего по двум последним пунктам.

Про свойство webdriver знают все, и это единственный пункт списка, который присутствует намеренно. Так устроено по стандарту: спецификация W3C WebDriver требует, чтобы совместимый драйвер выставлял флаг, который это свойство и показывает, а MDN описывает то же поведение. Значит, это не чей-то забытый баг, а браузер, который делает ровно то, что ему предписано.

Читайте строки вместе, а не по одной, потому что детектирующие скрипты сверяют их между собой. Windows в user agent рядом со строкой платформы Linux это куда более сильный сигнал, чем любое из двух значений по отдельности, и на этом спотыкается почти каждый самодельный патч.

Шаг 2: поищите артефакты драйвера, оставленные в window

Chromedriver и слой инъекции Selenium оставляют после себя именованные глобальные переменные. Они легко перечисляются, у обычных пользователей их не бывает, и найденная переменная это уже не вероятность, а факт.

// Injected globals from Chromedriver and Selenium. A clean
// browser prints the word clean and nothing else.
const prefixes = ["__cdc", "__selenium", "__webdriver", "__driver"];
const found = Object.keys(window).filter(
  (k) => prefixes.some((p) => k.startsWith(p))
);
// Two more that Chromedriver adds under fixed names.
for (const name of ["domAutomation", "domAutomationController"]) {
  if (window[name] !== undefined) found.push(name);
}
console.log(found.length ? found : "clean");

Находка здесь самое ценное, что может дать весь аудит, потому что спорить тут не о чем. Случайное имя, начинающееся с префикса cdc, это классический маркер Chromedriver, и обычный ответ на него такой: перестать патчить вручную и перейти на сборку драйвера, которая убирает его за вас. Этот путь мы разобрали отдельно в материале как работать с капчей в undetected-chromedriver, а более широкий обзор по Selenium собран здесь: распознавание капчи в Selenium . Там варианты драйверов разобраны подробнее.

Шаг 3: спросите у GPU, чем он себя считает

WebGL отдаёт производителя и рендерер графики обычными строками, а контейнер без GPU обязан честно назвать свой программный запасной вариант. Это самое громкое, что выдаёт headless Chrome в Docker, и никакие патчи navigator до этого не достают.

// The renderer is exposed as a plain string, so read it directly.
const gl = document.createElement("canvas").getContext("webgl");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
console.log(
  gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
  gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)
);
// SwiftShader, llvmpipe, Mesa or VMware means no real GPU here.

SwiftShader в ответе это не приговор, но и честно подменить его не получится: подставная строка рендерера всё равно должна согласоваться с теми пикселями, которые реально рисует ваш canvas. Если вы работаете в контейнере против агрессивной цели, реальных вариантов два: дать контейнеру настоящий GPU или перенести задачу на машину, где он есть.

Шаг 4: проверьте контекст Worker отдельно

Эта проба ловит недоделанные stealth-сборки, и почти никто её не запускает. Патчи, наложенные на window страницы, не переходят в Web Worker: у него свой свежий объект navigator, поэтому сборка, которая выглядит чистой в консоли, слоем ниже всё ещё отвечает правду.

// A Worker gets its own navigator, untouched by page patches.
const src = "postMessage(navigator.webdriver)";
const url = URL.createObjectURL(new Blob([src]));
new Worker(url).onmessage = (e) => console.log("worker says:", e.data);
// true here while the page says undefined is a mismatch,
// and a mismatch is a worse signal than either value alone.

Отнеситесь к последнему комментарию серьёзно, потому что эти скрипты оценивают именно согласованность. Браузер, который в одном контексте отвечает undefined, а в другом true, сообщил детектору сразу две вещи: что он автоматизирован и что кто-то пытался это скрыть. Второе и переводит сессию из оцениваемых в заблокированные.

Какие сигналы обнаружения headless-браузера важнее всего

Не каждая находка достойна вашего вечера. По скорости срабатывания:

СигналКак выглядит настоящая сессияВес
Глобальные переменные драйвера в объекте windowНи однойКритично: однозначно и сразу при загрузке
Свойство webdriver у navigatorUndefinedКритично: читается за миллисекунды
Согласованность платформы, user agent и языкаВсе три описывают одну машинуКритично: расхождение сильнее любого из значений
Строка рендерера WebGLНазванный GPU, а не программный запасной вариантВысокий: проверка за считаные секунды
Синтетические события с isTrusted равным falseTrue, потому что ввод пришёл от браузераВысокий: срабатывает на первом взаимодействии
Отпечаток TLS и HTTP/2Совпадает с той сборкой Chrome, за которую вы себя выдаётеВысокий, и его не видит ни одна проба выше
Пустой список плагинов, один язык, два ядраЗаполнено и правдоподобноСредний: идёт в общую оценку
Число шрифтов и аудиоотпечатокНастольный набор шрифтов, ненулевой аудиохешСредний: сам по себе решает редко

Строку про isTrusted часто понимают неверно, поэтому стоит уточнить. Вызов метода click у элемента из JavaScript создаёт событие, у которого isTrusted равен false, и это легко заметить. Тот же клик, выполненный через Playwright, Puppeteer или Selenium, так себя не ведёт, потому что он идёт через собственный конвейер ввода браузера. То есть это утечка самодельных скриптов по DOM, а не вашего драйвера.

Шаг 5: закройте критичные

Два правила до всякого кода. Патчите рано, до того как выполнятся скрипты страницы, иначе детектор успеет прочитать исходное значение и ваша правка опоздает. И патчите узко, потому что грубая подмена сама детектируется: если переписать нативную функцию, её исходник станет видно любому, кто вызовет toString, и скрытый сигнал превратится в очевидный.

# pip install playwright
from playwright.sync_api import sync_playwright

PATCH = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = window.chrome || { runtime: {} };
"""

with sync_playwright() as p:
    # Real Chrome leaks less than the bundled Chromium build.
    browser = p.chromium.launch(channel="chrome", headless=False)
    page = browser.new_page(locale="en-US")
    # add_init_script runs before any page script reads navigator.
    page.add_init_script(PATCH)
    page.goto("https://example.com/page-with-recaptcha")
    # Re-run the Step 1 probe here to confirm the patch landed.

Трёх дешёвых улучшений в этом сниппете нет, потому что это настройка, а не код. Берите настоящий канал Chrome вместо встроенного Chromium. Держите постоянный каталог профиля между запусками, чтобы сессия приходила с куками и историей, а не выглядела новорождённой. И приводите локаль и часовой пояс в соответствие с тем, откуда якобы идёт ваш трафик. Эти три меры дают больше, чем любая подмена navigator, и ни одну из них нельзя поймать на лжи. Если нужны детали по фреймворку, страница распознавание капчи в Playwright описывает обвязку с этой стороны, а пользователи Puppeteer найдут тот же разбор на странице распознавание капчи в Puppeteer под свой драйвер.

Чего всё это не исправит

Три категории лежат за пределами браузера, поэтому все пробы выше их не видят. Ваше TLS-рукопожатие снимают в отпечаток ещё до того, как выполнится первый байт JavaScript, и поэтому HTTP-клиент на Python не проходит проверки, через которые тот же запрос из Chrome проходит без вопросов. У вашего IP есть репутация его сети. А поведение оценивают на протяжении всей сессии: частота запросов, порядок переходов, скорость заполнения форм. Так что обнаружение headless-браузера это всегда лишь часть причины, по которой вас остановили.

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

Как решить проверку, которая всё равно пришла

Как только виджет появился, проблема уже не в отпечатке, а в токене. CapSkip работает на вашем собственном железе, отвечает по API, совместимому с 2captcha, и возвращает токен, который вы сами подставляете и отправляете.

# pip install capskip
from capskip import CapSkip

# Local mode. In Server mode this is the solver box's address.
solver = CapSkip(host="127.0.0.1", port=8080)

# One call covers v2, Invisible, Enterprise and v3 as options.
result = solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

# Inject the token into the field the page submits with the form.
page.evaluate(
    "t => document.getElementById('g-recaptcha-response').value = t",
    result["code"],
)

print(result["code"][:24])   # token, ready to submit

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

Как запустить решатель на сервере

Парки браузеров обычно живут не на ноутбуке, и решателю там тоже быть не обязательно. В настройках подключения два режима, и разница между ними только в том, какой интерфейс слушает API.

РежимПрослушиваетКогда использовать
Локально127.0.0.1, только это устройствоБраузер и решатель на одной машине
СерверВаш сетевой адрес или публичный IPНужен доступ с другой машины, VPS, хоста контейнеров или хостингового CI-раннера

В серверном режиме вы указываете SDK этот адрес вместо loopback и больше ничего не меняете, поэтому парк из двадцати контейнеров может пользоваться одним решателем. Статический публичный IP пригодится, если вызовы идут из-за пределов вашей сети, ведь адрес попадёт в конфигурацию. Все подробности в разделе Настройки подключения в руководстве по настройке. Серверный режим это по-прежнему ваше железо и по-прежнему без счётчика, поэтому меняется место запуска решателя, а не его стоимость.

FAQ

Новый headless-режим всё ещё определяется?

Да, хотя уже не так грубо, как прежний. Новый headless-режим Chrome использует тот же бинарник обычного браузера, поэтому выдававшая себя строка user agent и часть отсутствовавших API ушли. Свойство webdriver по-прежнему выставляется, глобальные переменные драйвера по-прежнему внедряются, а контейнер без GPU по-прежнему сообщает про программный рендерер. Headless это один сигнал из многих, а не мгновенный отказ.

Достаточно ли одного stealth-плагина?

Он хорошо закрывает известные свойства JavaScript, и им стоит пользоваться. Он не трогает ваш отпечаток TLS, репутацию IP и характер запросов, а его патчи публичны, поэтому вендоры детектирования проверяют их напрямую. Запустите все четыре пробы после установки, а не считайте задачу закрытой.

Мои браузеры работают на хостинговых раннерах. Они смогут дотянуться до решателя?

Да, если CapSkip в серверном режиме. Хостинговый раннер не видит ваш адрес loopback, поэтому переключите решатель на прослушивание сетевого или публичного IP и укажите SDK именно его. Один экземпляр обслуживает все раннеры, туннель не нужен, а статический публичный IP делает конфигурацию стабильной. Оба режима и порт, который каждый из них занимает, описаны в руководстве по настройке в разделе Настройки подключения , где разобран и переключатель между ними.

Избавит ли чистый отпечаток от капчи?

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

Запустите пробы до того, как что-то менять

Аудит обнаружения headless-браузера занимает примерно десять минут и обычно находит две проблемы, а не двадцать. Уберите глобальные переменные драйвера, согласуйте значения navigator между собой и проверьте контекст Worker, чтобы патчи не противоречили друг другу. А оставшиеся проверки считайте отдельной задачей для отдельного инструмента. Если распознавание капчи работает на железе, которое принадлежит вам, такие проверки закрываются без оплаты за каждое решение, и именно к этой схеме в итоге приходит большинство скрейпинг-парков, как и поясняет материал распознавание капчи для веб-скрейпинга на примере пула воркеров.