Как решить DataDome CAPTCHA на PHP (Guzzle CookieJar)

datadome captcha in php - How to Solve DataDome CAPTCHA in PHP (Guzzle CookieJar)

Решение DataDome CAPTCHA на PHP заканчивается получением cookie, а не токена. Это руководство читает страницу блокировки с кодом 403, собирает адрес проверки и передаёт его в метод dataDome() пакета capskip/capskip, за которым стоит решатель CapSkip для DataDome. Возвращённую cookie он затем сохраняет в хранилище клиента Guzzle, который отправляет следующий запрос. В документации CapSkip сказано, что cookie работает только с тем User-Agent и адресом, с которыми была получена, а клиент Guzzle закрепляет и то и другое.

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

  • CapSkip 3.2.0 или новее на ПК с Windows: это первая версия, которая решает DataDome (ползунок и проверку устройства).
  • PHP 8.0.2 или новее с расширениями curl и json и Composer, оба в вашем PATH. Проверка платформы в Composer показала 8.0.2 на установке 8.0.30 и 8.1.0 на 8.1.33 и 8.4.15.
  • Пустая рабочая папка. Выполните в ней composer require capskip/capskip guzzlehttp/guzzle и сохраните там скрипт как datadome-demo.php. В выпусках capskip до 1.5.0 нет метода dataDome.
  • Набор сертификатов CA. PHP для Windows поставляется без него, поэтому первый запрос по https завершается ошибкой cURL error 60, пока вы не скачаете cacert.pem с curl.se и не укажете путь к нему в curl.cainfo в php.ini.
  • Страница под защитой DataDome, которую вам разрешено автоматизировать.
  • Хост и порт для CapSkip. В режиме Local он отвечает только на 127.0.0.1, для скриптов на том же ПК. Режим Server позволяет PHP на другой машине обращаться к нему через вашу сеть или публичный IP. Режим выбирается в разделе Настройки подключения.
  • Для всего, что серьёзнее теста, по одному sticky-прокси на каждую задачу.

Как проверялось: на двух имитациях серверов, которые я написал, только на Windows и без живого сайта DataDome. Первая копирует проверки из раздела DataDome на странице CapSkip Документация API. Вторая изображает защищённый сайт, который отвечает 403 со страницей блокировки, принимает только cookie, выданную первой имитацией, а ещё пересылает запросы как прокси. Для https использовался временный CA. Скрипты ни разу не обращались к приложению CapSkip, поэтому здесь нет времени настоящего решения и нет вердикта живого сайта.

Блоки запускались на PHP 8.0.30, 8.1.33, 8.2.29, 8.3.28 и 8.4.15 с capskip 1.6.0 и Guzzle 8.3.1, а также на 8.0.30 и 8.4.15 с capskip 1.6.0 и Guzzle 7.16.1. На PHP 8.5.0 прогон завершается, но файл Http.php в SDK печатает уведомление Deprecated про curl_close() на каждый запрос к CapSkip, в моём прогоне их было три.

Шаг 1: один клиент Guzzle на каждый профиль, затем страница блокировки

Поместите пять блоков кода в datadome-demo.php в показанном порядке. В первом собраны все строки use, поэтому в остальные их добавлять не нужно.

Три настройки Guzzle по умолчанию мешают этому циклу. Клиент не хранит cookie, пока вы не дадите ему хранилище. Он называет себя GuzzleHttp плюс своя основная версия, и имитация видела это от чистого клиента на Guzzle 7 и 8. И он выбрасывает ClientException на любой ответ 4xx, что здесь важнее всего, потому что страница блокировки 403 как раз и есть проверка, которую нужно прочитать. С этой настройкой по умолчанию мой прогон упал на первом запросе, а имитация CapSkip так и не получила задание. Поэтому session() выключает http_errors. Она же задаёт хранилище, User-Agent и прокси вместе, чтобы они не разошлись. Новый профиль означает нового клиента.

<?php
// composer require capskip/capskip guzzlehttp/guzzle
require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use Psr\Http\Message\ResponseInterface;

const PAGE_URL = 'https://www.example.com/'; // a page behind DataDome
// For the first request only. The cookie comes back with its own User-Agent.
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
    . '(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36';

// Guzzle fixes its options when the Client is built, so one Client is one
// identity: a cookie jar, a User-Agent and a proxy.
function session(CookieJar $jar, string $userAgent, ?string $proxyUrl = null): Client
{
    return new Client([
        'cookies' => $jar,
        'headers' => ['User-Agent' => $userAgent],
        'proxy' => $proxyUrl,
        'http_errors' => false, // a 403 block page is an answer, not an exception
        'timeout' => 30,
    ]);
}

На запрос, которому DataDome не доверяет, приходит 403, а иногда 401, и короткая HTML-страница. Адрес проверки, который в документации называется captcha_url, представляет собой либо src у iframe с geo.captcha-delivery.com, либо то, что скрипт собирает из объекта dd. Функция ниже справляется с обоими вариантами. Запись rt в объекте dd выбирает страницу проверки: буква c выбирает ползунок по адресу /captcha/, а буква i выбирает проверку устройства по адресу /interstitial/. В документации значение t, равное bv, определено как заблокированный адрес, а fe показано на обычных проверках; такое прочтение fe принадлежит документации.

Первый запрос уходит с обычным User-Agent от Chrome, который в CapSkip никогда не передаётся, поэтому его версия не обязана ни с чем совпадать. Каждая страница блокировки несёт новую проверку, поэтому используйте её адрес сразу.

// Build the challenge address from a DataDome block page, or null.
function captchaUrlFrom(ResponseInterface $resp): ?string
{
    if (!in_array($resp->getStatusCode(), [401, 403], true)) {
        return null;
    }
    $html = (string) $resp->getBody();
    // Some block pages carry the challenge iframe: its src is the address.
    if (preg_match('/<iframe[^>]+src="(https:\/\/[^"]*captcha-delivery\.com\/[^"]+)"/', $html, $found)) {
        return $found[1]; // an HTML-escaped &amp; is fine, CapSkip decodes it
    }
    // Most build it from the dd object in a script instead.
    if (!preg_match('/var\s+dd\s*=\s*(\{[^}]*\})/', $html, $found)) {
        return null;
    }
    $dd = json_decode(str_replace("'", '"', $found[1]), true);
    if (!is_array($dd)) {
        return null;
    }
    $query = ['initialCid' => $dd['cid'] ?? '', 'hash' => $dd['hsh'] ?? '', 'cid' => $dd['cookie'] ?? ''];
    if (!empty($dd['t'])) {
        $query['t'] = $dd['t']; // fe is a challenge, bv means the address is banned
    }
    $query['referer'] = PAGE_URL;
    $query['s'] = $dd['s'] ?? '';
    foreach (['e', 'b'] as $key) {
        if (!empty($dd[$key])) {
            $query[$key] = $dd[$key];
        }
    }
    $query['dm'] = 'cd';
    $path = ($dd['rt'] ?? '') === 'i' ? 'interstitial' : 'captcha';

    return 'https://' . ($dd['host'] ?? 'geo.captcha-delivery.com') . "/{$path}/?"
        . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}

Шаг 2: решаем проверку и кладём cookie в хранилище

Метод называется dataDome(string $captchaUrl, string $url, array $options = []), и datadome() в нижнем регистре в моём прогоне вернул тот же результат. Массив options принимает userAgent и proxy, а значения null в нём не отправляются. Прокси передаётся как массив с type и uri, как ниже, или как обычная строка, которую SDK отправляет как HTTP-прокси.

Не передавайте userAgent. Тогда CapSkip использует User-Agent своего браузера и сообщает его в результате, а вы с этого момента отправляете именно эту строку. Передаваемый вами User-Agent должен быть стандартным Chrome для Windows с той же основной версией, что и у этого браузера: SDK локально отклонил строку Firefox, а имитация ответила ERROR_WRONG_USERAGENT на строку Chrome 120. Метод ждёт до recaptchaTimeout, по умолчанию 300 секунд.

// Local mode. In Server mode, use your server's address instead.
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

function solve(CapSkip $solver, string $captchaUrl, ?string $proxyUrl): array
{
    $options = [];
    if ($proxyUrl !== null) {
        [$scheme, $rest] = explode('://', $proxyUrl, 2);
        $options['proxy'] = ['type' => strtoupper($scheme), 'uri' => $rest];
    }
    // No userAgent option: CapSkip uses its own browser's and reports it.
    return $solver->dataDome($captchaUrl, PAGE_URL, $options);
}

Результат представляет собой массив с ключами captchaId, code, userAgent и cookie. Значение code содержит cookie в точности такой, какой её установил DataDome, вместе с атрибутами. Значение cookie содержит только пару из имени datadome и значения. Ключ proxy появляется, только если вы не передали прокси и CapSkip использовал прокси из собственного списка DataDome. В нём указаны хост и порт без схемы и логина, поэтому добавьте логин сами, если запись списка его требует. Тогда cookie работает только через этот прокси.

Исключения SDK находятся в CapSkip\Exceptions и наследуют CapSkipError, который наследует Exception, а не RuntimeException. Только ApiException, то есть отклонённое задание, считается неудачным решением, которое стоит повторить.

Здесь PHP-скрипт отличается от скрипта, который собирает заголовок Cookie. Значение code напрямую разбирается в Guzzle SetCookie, и хранилище затем применяет правила браузера: отправляет cookie только на хост, который соответствует атрибуту Domain, для подходящего атрибута Path, пока не истечёт Max-Age, а по https только если задан атрибут Secure. Оно также сохраняет заменяющую cookie. Имитация прислала новую вместе со страницей 200, и следующий запрос через того же клиента её нёс.

Хранилище тихо ломается четырьмя способами, и я попал в каждый. Cookie без атрибута Domain сохраняется, но никогда не отправляется. Несовпадение Domain или Secure не даёт заголовка Cookie, поэтому сайт отвечает 403, хотя хранилище cookie содержит. setCookie() возвращает false, а не выбрасывает исключение, когда отклоняет cookie, например просроченную. А cookie datadome, которую установила сама страница блокировки, остаётся в хранилище рядом с решённой, поэтому уходят обе, и сайт может прочитать не ту. Моя первая имитация такую cookie не устанавливала; когда стала устанавливать, все три попытки падали, пока её не удалил вызов clear() ниже. Блок защищает от всех четырёх случаев.

function sendWithCookie(array $result, CookieJar $jar, ?string $proxyUrl): array
{
    // code is the cookie as DataDome set it, with Domain, Path and Max-Age.
    $cookie = SetCookie::fromString($result['code']);
    if (!$cookie->getDomain()) {
        // A jar keeps a cookie with no Domain but never sends it.
        $cookie->setDomain(parse_url(PAGE_URL, PHP_URL_HOST));
    }
    // The block page may have set a datadome cookie of its own. Drop it.
    $jar->clear(parse_url(PAGE_URL, PHP_URL_HOST), '/', 'datadome');
    if (!$jar->setCookie($cookie)) { // false, not an exception, for a cookie it rejects
        throw new RuntimeException('The cookie jar refused: ' . $result['code']);
    }
    // The cookie works only from the address that earned it.
    $via = $proxyUrl;
    if ($via === null && !empty($result['proxy'])) {
        // CapSkip solved through its own list. Add user:pass@ if that entry needs a login.
        $via = 'http://' . $result['proxy'];
    }
    $http = session($jar, $result['userAgent'], $via);

    return [$http, $http->get(PAGE_URL)];
}

Шаг 3: полный пример с циклом повтора при 403

Последний блок связывает шаги воедино. Добавьте его в datadome-demo.php и выполните php datadome-demo.php в рабочей папке. Каждая попытка начинается с нового хранилища и клиента, поэтому каждый заход несёт свежую проверку и не несёт cookie от неудачного. Этот выбор мой и против DataDome не проверялся.

Цикл повторяет ApiException вроде ERROR_CAPTCHA_UNSOLVABLE, а также cookie, которая всё равно получает 403. ApiException с упоминанием ERROR_PROXY_ пробрасывается сразу, потому что тот же адрес сломался бы так же. 403 без объекта dd и без iframe останавливает цикл. Каждый повтор стоит сайту ещё одного запроса страницы блокировки, поэтому разумный предел: три попытки.

function fetchProtected(CapSkip $solver, ?string $proxyUrl = null): array
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        $jar = new CookieJar(); // a clean jar for every attempt
        $http = session($jar, BROWSER_UA, $proxyUrl);
        $block = $http->get(PAGE_URL); // a fresh block page, a fresh challenge
        $captchaUrl = captchaUrlFrom($block);
        if ($captchaUrl === null) {
            if ($block->getStatusCode() >= 400) {
                throw new RuntimeException('HTTP ' . $block->getStatusCode() . ' without a DataDome block page');
            }
            return [$http, $block]; // nothing to solve here
        }
        try {
            $result = solve($solver, $captchaUrl, $proxyUrl);
        } catch (ApiException $e) {
            if (str_contains($e->getMessage(), 'ERROR_PROXY_')) {
                throw $e; // the address is the problem, so a retry fails the same way
            }
            echo "attempt {$attempt}: {$e->getMessage()}\n";
            continue;
        }
        [$http, $page] = sendWithCookie($result, $jar, $proxyUrl);
        if ($page->getStatusCode() === 200) {
            return [$http, $page];
        }
        echo "attempt {$attempt}: HTTP {$page->getStatusCode()} with the cookie\n";
    }
    throw new RuntimeException('DataDome not passed in 3 attempts');
}

try {
    [$http, $page] = fetchProtected($solver); // or ($solver, 'http://user:[email protected]:8080')
    echo $page->getStatusCode(), ' ', substr((string) $page->getBody(), 0, 40), PHP_EOL;
    // Same Client, so the same jar, User-Agent and proxy.
    echo $http->get(PAGE_URL)->getStatusCode(), PHP_EOL;
} catch (CapSkipError | RuntimeException $e) {
    fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

Полный скрипт для DataDome CAPTCHA на PHP вывел на имитации следующее:

200 <html><body><h1>Real content</h1></body>
200

Каждая строка ниже получилась одинаковой на всех этих сочетаниях, кроме одной детали: Guzzle 8 выбрасывает ConnectException при cURL error 60, а Guzzle 7 выбрасывает RequestException.

Настройка имитацииЧто сделал скрипт
Страница блокировки с ползунком, проверкой устройства или iframeОдно задание, затем 200, и снова 200 при повторном использовании
Отклонены первые два решения или все три200 на третьем задании либо RuntimeException и код выхода 1
Страница блокировки с t=bvValidationException; имитация ничего не получила
В результате указан прокси, или вы передаёте свойОба запроса с cookie шли через этот прокси и прошли успешно
Решение ответило ERROR_PROXY_BANNEDApiException после одного задания
Несовпадающий атрибут Domain или Secure на обычном httpТри попытки, каждая с 403 при наличии cookie
Cookie без атрибута Domain200 с запасным вариантом из блока Шага 2, три попытки с 403 без него
Страница блокировки, которая сама устанавливает cookie datadome200 с clear() в блоке Шага 2, три попытки с 403 без него
Сайт на https с временным CAcURL error 60 без curl.cainfo; с ним 200, в том числе для cookie с Secure

Привязка cookie и чтение имитацией сайта первой из двух cookie datadome: это правила, которые я написал после чтения документации CapSkip. О живом сервисе они ничего не говорят.

Командная строка или веб-запрос?

SDK ждёт решения до 300 секунд. В руководстве по PHP значение max_execution_time по умолчанию указано как 30 секунд, а для командной строки 0. Я удерживал задание в имитации 8 секунд, поэтому скрипт работал около 13, и замерял каждый прогон на PHP 8.4.15 от первого оператора до последнего; PHP 8.0.30 дал те же результаты с точностью до 0.1 секунды. В php.ini на этом ПК стоит 120, а для лимита 5 я использовал его копию. php.exe игнорирует max_execution_time в php.ini, поэтому консольный запуск ограничивают только флаг -d или set_time_limit(), тогда как php-cgi.exe, сборка CGI, через которую веб-сервер может запускать PHP, читает этот файл.

Как это прошлоРезультат
php.exe, ничего не задано (в php.ini стоит 120, php.exe читает 0)Завершился: статус 200 дважды, около 13 секунд
php.exe, лимит 5 в php.iniЗавершился: лимит проигнорирован, около 13 секунд
php.exe -d max_execution_time=5Fatal error через 7.9 секунды
php-cgi.exe, лимит 5 в php.iniFatal error через 7.9 секунды
php-cgi.exe -d max_execution_time=5 и set_time_limit(60) первым операторомЗавершился примерно через 13 секунд
php-cgi.exe, ничего не задано (читает 120 из php.ini)Завершился примерно через 13 секунд

Я запускал это только на Windows, где лимит учитывает и ожидание, поэтому скрипт, заблокированный в цикле опроса SDK, его расходует. PHP выбросил фатальную ошибку примерно через 8 секунд, а не через 5, потому что скрипт спал между опросами, когда лимит прошёл. Скрипт умирает, не прочитав cookie, а CapSkip уже потратил решение. Запускайте этот цикл из консоли, запланированной задачи или воркера очереди; о том, как устроен воркер, смотрите руководство по очередям Laravel. Внутри веб-запроса сначала вызовите set_time_limit() со значением больше, чем может занять решение.

Частые ошибки

ОшибкаПричинаИсправить
cURL error 60: SSL certificate problemВ PHP для Windows нет набора сертификатов CAСкачайте cacert.pem с curl.se и задайте curl.cainfo в php.ini
ValidationExceptionSDK отклонил ввод: адрес вне captcha-delivery.com, страницу блокировки с t=bv, URL страницы не по http или https, либо userAgent, который не является стандартным Chrome для Windows. В сообщении названо, что именноСоберите адрес заново по новой странице блокировки, смените прокси, если он забанен, уберите userAgent
ApiException с ERROR_PROXY_BANNED, ERROR_PROXY_CONNECT_REFUSED или ERROR_WRONG_USERAGENTАдрес забанен, CapSkip не может подключиться через прокси (не воспроизведено) или у вашего userAgent другая основная версия Chrome, чем у браузера, в котором решает CapSkipСмените или проверьте прокси либо не передавайте userAgent
TimeoutException или NetworkExceptionИстекло ожидание в 300 секунд, или по указанным хосту и порту никто не отвечаетЗапустите CapSkip и сверьте хост и порт с его настройками подключения
HTTP 403 с cookie, хотя хранилище её содержитАтрибут Domain не совпадает с хостом, к которому вы обращаетесь, либо задан атрибут Secure, а URL использует httpВыведите хранилище и сравните его с PAGE_URL
Fatal error: Maximum execution time of 30 seconds exceededВеб-запрос упёрся в лимит времени PHP во время опроса в SDK; число в сообщении равно вашему лимитуЗапускайте скрипт из консоли или воркера

FAQ

Можно ли решать DataDome CAPTCHA на PHP с помощью CapSkip, который работает на сервере?

Да, именно для этого и нужен режим Server. Тогда CapSkip принимает вызовы из вашей сети или с публичного IP (рекомендуется статический), и в конструкторе меняются только хост и порт. Машина с Windows остаётся вашей, и решения на ней не оплачиваются отдельно. Режим Server при запуске открывает порт для всех адресов, поэтому чтобы закрыть доступ, добавьте блокирующее правило Windows Firewall для машин, которых вы не ждёте. Решение выполняется на сервере, поэтому cookie принадлежит его адресу: отправляйте запросы Guzzle с этой машины или задайте один и тот же прокси и для решения, и для клиента.

Почему хранилище cookie, а не заголовок Cookie?

Заголовок тоже подойдёт. Собственный пример datadome.php из SDK отправляет значение cookie в заголовке из обычного дескриптора cURL; я его не запускал. Хранилище добавляет сопоставление по атрибутам Domain и Path, срок действия и замену cookie ценой тихих сбоев из Шага 2.

Нужен ли прокси?

Для первого теста нет. В документации CapSkip сказано, что cookie работает только с адреса, с которого была получена, поэтому при больших объёмах нужен sticky-прокси на каждую задачу. Чтобы их ротировать, прочитайте руководство по ротации прокси.

Коротко

Решение DataDome CAPTCHA на PHP требует одного клиента Guzzle на каждый профиль, страницы блокировки, прочитанной при выключенном http_errors, вызова dataDome() и возвращённой cookie в хранилище клиента. Повтор ничего не стоит, ведь локальный сервис распознавания капчи вы запускаете сами, поэтому пусть цикл запрашивает свежую проверку, а не использует устаревшую. Вызовы SDK для других типов капчи смотрите на странице сервиса распознавания капчи для PHP.