如何在 PHP 中识别 DataDome 验证码(Guzzle CookieJar)

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

在 PHP 中处理 DataDome 验证码,最终得到的是一个 cookie,而不是 token。本指南读取 403 封锁页面,构造出挑战地址,并把它传给 capskip/capskip 包的 dataDome() 方法,该方法会与 CapSkip DataDome 识别工具通信。随后,它把返回的 cookie 保存在发出下一个请求的 Guzzle 客户端的 cookie jar 里。CapSkip 文档说明,这个 cookie 只能配合获取它时所用的 User-Agent 和 IP 地址使用,而 Guzzle 客户端会把这两者都固定下来。

你需要什么

  • Windows PC 上的 CapSkip 3.2.0 或更高版本,这是第一个能识别 DataDome(滑块和设备检查)的版本。
  • PHP 8.0.2 或更高版本(带 curl 和 json 扩展)以及 Composer,两者都要在你的 PATH 里。Composer 的平台检查在 8.0.30 的安装上提示 8.0.2,在 8.1.33 和 8.4.15 上提示 8.1.0。
  • 一个空的工作文件夹。在里面运行 composer require capskip/capskip guzzlehttp/guzzle,并把脚本保存为 datadome-demo.php。1.5.0 之前的 capskip 版本没有 dataDome 方法。
  • 一个 CA 证书包。Windows 版 PHP 不带它,所以在你从 curl.se 下载 cacert.pem 并让 php.ini 里的 curl.cainfo 指向它之前,第一个 https 请求会失败并报 cURL error 60。
  • 一个受 DataDome 保护、且你被允许自动化操作的页面。
  • CapSkip 的主机和端口。在 Local 模式下,它只在 127.0.0.1 上响应,供同一台 PC 上的脚本使用。Server 模式则让另一台机器上的 PHP 能通过你的网络或公网 IP 调用它。模式的设置位置是 连接设置.
  • 超出测试范围的场景,每个任务使用一个粘性代理。

测试方式:我自己写的两个模拟服务器,只在 Windows 上运行,没有使用线上的 DataDome 站点。其中一个照搬了 CapSkip API 文档中 DataDome 部分列出的检查。另一个扮演受保护的站点:它用封锁页面返回 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 上运行能够完成,但 SDK 的 Http.php 文件每向 CapSkip 发送一个请求,就会打印一条关于 curl_close() 的 Deprecated 通知,我这次运行里一共三条。

第 1 步:每个身份一个 Guzzle 客户端,然后读取封锁页面

按所示顺序把这五个代码块放进 datadome-demo.php。第一个代码块包含所有 use 行,所以其他代码块不再添加。

Guzzle 有三个默认行为和这个循环作对。除非你交给它一个 cookie jar,否则客户端不会保存 cookie。它会把自己称为 GuzzleHttp 加上主版本号,模拟服务器在 Guzzle 7 和 8 的裸客户端那里都看到了这一点。而且它遇到任何 4xx 应答都会抛出 ClientException,这一点在这里最要紧,因为 403 封锁页面正是你需要读取的挑战。保留这个默认值时,我的运行在第一个请求就挂了,模拟 CapSkip 一个任务也没有收到。所以 session() 把 http_errors 关掉。它还会把 cookie jar、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,要么是来自 geo.captcha-delivery.com 的 iframe 的 src,要么是脚本根据 dd 对象拼出来的内容。下面的函数两种情况都能处理。dd 对象的 rt 项决定挑战页面:字母 c 选中 /captcha/ 下的滑块,字母 i 选中 /interstitial/ 下的设备检查。文档把 t 值为 bv 定义为被封禁的 IP 地址,并说普通挑战上显示的是 fe;对 fe 的这种解读是文档自己的。

第一个请求使用一个普通的 Chrome User-Agent 发出,它从不发送给 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 放进 cookie jar

方法是 dataDome(string $captchaUrl, string $url, array $options = []),在我的运行中,小写的 datadome() 返回了相同的结果。options 数组接受 userAgent 和 proxy,其中为 null 的值不会被发送。代理可以像下面这样作为带有 type 和 uri 的数组传入,也可以作为纯字符串传入,SDK 会把它当作 HTTP 代理发送。

不要传 userAgent。这样 CapSkip 会使用它自己浏览器的 User-Agent,并在结果中报告它,此后你就一直发送这个字符串。如果你自己提供,它必须是 Windows 版标准 Chrome,且主版本号与该浏览器相同:SDK 在本地拒绝了一个 Firefox 字符串,而模拟服务器对一个 Chrome 120 字符串返回了 ERROR_WRONG_USERAGENT。该方法最多等待 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 的值是 DataDome 设置的原样 cookie,属性也包含在内。cookie 的值只是 datadome 的名称和值这一对。只有当你没有传入代理,并且 CapSkip 使用了它自己 DataDome 列表中的代理时,才会出现 proxy 键。它只含主机和端口,没有协议前缀和登录信息,所以如果列表中的这一项需要登录,请自行补上。此后这个 cookie 只能通过该代理使用。

SDK 的异常位于 CapSkip\Exceptions 之下,并继承 CapSkipError,而 CapSkipError 继承的是 Exception,不是 RuntimeException。只有 ApiException(任务被拒绝)才是值得再试一次的识别失败。

这正是 PHP 脚本与手工构造 Cookie 请求头的脚本不同的地方。code 的值可以直接解析成 Guzzle 的 SetCookie,之后 cookie jar 会应用浏览器的规则:只把 cookie 发给与 Domain 属性匹配的主机,只用于匹配的 Path 属性,直到 Max-Age 用完,并且只有设置了 Secure 属性才通过 https 发送。它也会保留替换用的 cookie。模拟服务器在 200 页面里发来了一个新的,通过同一个客户端发出的下一个请求就带上了它。

cookie jar 会以四种方式悄悄失败,每一种我都遇到过。没有 Domain 属性的 cookie 会被保存,但永远不会被发送。Domain 或 Secure 不匹配时不会发送 Cookie 头,所以尽管 jar 里有这个 cookie,站点仍然返回 403。setCookie() 在拒绝一个 cookie(例如已过期的 cookie)时返回 false,而不是抛出异常。而封锁页面自己设置的 datadome cookie 会和已识别的那个一起留在 jar 里,于是两个都会发出去,站点可能读到错误的那个。我的第一个模拟服务器从不设置那个 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 jar 和客户端开始,所以每次都带着全新的挑战,也不带失败那次留下的 cookie。这个选择是我自己的,没有针对 DataDome 测试过。

循环会重试 ERROR_CAPTCHA_UNSOLVABLE 之类的 ApiException,也会重试带着 cookie 仍然得到 403 的情况。名称里含 ERROR_PROXY_ 的 ApiException 会立即重新抛出,因为同一个 IP 地址会以同样的方式失败。没有 dd 对象或 iframe 的 403 会让循环停下。每次重试都会让站点多收到一次封锁页面请求,所以三次尝试是个合理的上限。

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);
}

完整的 PHP DataDome 验证码脚本针对模拟服务器打印了这些内容:

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

下面每一行在所有这些组合上的结果都相同,只有一处细节例外:对于 cURL error 60,Guzzle 8 抛出 ConnectException,而 Guzzle 7 抛出 RequestException。

模拟设置脚本的表现
滑块、设备检查或 iframe 封锁页面一个任务,然后是 200,复用时再次返回 200
前两次识别被拒绝,或三次都被拒绝第三个任务返回 200,或者抛出 RuntimeException,退出码为 1
t=bv 的封锁页面ValidationException;模拟服务器什么也没收到
结果指明了代理,或者传入你自己的代理带 cookie 的两个请求都使用了该代理,并且都成功了
识别返回了 ERROR_PROXY_BANNED一个任务之后抛出 ApiException
Domain 属性不匹配,或在普通 http 上设置了 Secure三次尝试,每次带着 cookie 都得到 403
没有 Domain 属性的 cookie第 2 步代码块里有兜底处理时返回 200,没有它时三次尝试都是 403
自己设置 datadome cookie 的封锁页面第 2 步代码块里有 clear() 时返回 200,没有它时三次尝试都是 403
使用一次性 CA 的 https 站点没有 curl.cainfo 时出现 cURL error 60;有它时返回 200,带 Secure 的 cookie 也是如此

cookie 的绑定规则,以及模拟站点读取两个 datadome cookie 中的第一个这一做法,都是我读过 CapSkip 文档后自己写的规则。它们并不能说明线上服务的情况。

命令行还是 Web 请求?

SDK 最多等待 300 秒来完成一次识别。 PHP 手册 给出的 max_execution_time 默认值是 30 秒,命令行下是 0。我让模拟任务保持了 8 秒,所以脚本用了约 13 秒,并在 PHP 8.4.15 上从第一条语句到最后一条语句给每次运行计时;PHP 8.0.30 的结果相同,误差在 0.1 秒以内。这台 PC 上的 php.ini 写的是 120,限制为 5 的情况我用了它的一份副本。php.exe 会忽略 php.ini 里的 max_execution_time,所以控制台运行只能靠 -d 参数或 set_time_limit() 来限制,而 php-cgi.exe(Web 服务器可以通过它运行 PHP 的 CGI 版本)会读取这个文件。

运行情况结果
php.exe,未设置任何值(php.ini 写的是 120,php.exe 读到的是 0)完成:两次状态 200,约 13 秒
php.exe,php.ini 中的限制为 5完成:限制被忽略,约 13 秒
php.exe -d max_execution_time=57.9 秒后出现 Fatal error
php-cgi.exe,php.ini 中的限制为 57.9 秒后出现 Fatal error
php-cgi.exe -d max_execution_time=5,并以 set_time_limit(60) 作为第一条语句约 13 秒后完成
php-cgi.exe,未设置任何值(它从 php.ini 读到 120)约 13 秒后完成

我只在 Windows 上运行过,在 Windows 上这个限制连等待时间也一起计算,所以一个阻塞在 SDK 轮询循环里的脚本会把它用光。PHP 在大约 8 秒时抛出了致命错误,而不是 5 秒,因为限制到期时,脚本正在两次轮询之间休眠。脚本在没有读到 cookie 的情况下终止,而 CapSkip 已经完成了这次识别。请从控制台、计划任务或队列 worker 里运行这个循环;worker 的写法请参阅 Laravel 队列指南。在 Web 请求中,请先调用 set_time_limit(),并设一个大于一次识别所需时间的值。

常见错误

错误原因修复
cURL error 60: SSL certificate problemWindows 版 PHP 没有 CA 证书包从 curl.se 下载 cacert.pem,并在 php.ini 中设置 curl.cainfo
ValidationExceptionSDK 拒绝了输入:不在 captcha-delivery.com 上的地址、t=bv 的封锁页面、不是 http 或 https 的页面 URL,或者不是 Windows 版标准 Chrome 的 userAgent。消息会指明是哪一种从新的封锁页面重新构造地址,如果被封禁就更换代理,去掉 userAgent
带有 ERROR_PROXY_BANNED、ERROR_PROXY_CONNECT_REFUSED 或 ERROR_WRONG_USERAGENT 的 ApiException该 IP 地址被封禁,CapSkip 无法通过代理建立连接(未复现),或者你的 userAgent 的 Chrome 主版本号与 CapSkip 用来识别的浏览器不同更换或测试代理,或者不发送 userAgent
TimeoutException 或 NetworkException300 秒的等待用完了,或者你给出的主机和端口上没有任何应答启动 CapSkip,并把你的主机和端口与它的连接设置对照一下
jar 里明明有 cookie,却仍返回 HTTP 403Domain 属性与你请求的主机不匹配,或者设置了 Secure 属性而 URL 是 http打印 cookie jar,并与 PAGE_URL 对照
Fatal error: Maximum execution time of 30 seconds exceededWeb 请求在 SDK 轮询期间触及了 PHP 的时间限制;这个数字就是你的限制值从控制台或 worker 里运行脚本

常见问题

我能在 PHP 中针对服务器上的 CapSkip 识别 DataDome 验证码吗?

可以,Server 模式就是为此而设的。这时 CapSkip 接受来自你的网络或公网 IP 的调用(建议使用静态 IP),你只需要在构造函数里更改主机和端口。这台 Windows 机器仍然是你的,用量也不计量。Server 模式启动时会向所有地址开放端口,所以请为你不预期的机器添加 Windows Firewall 阻止规则,让它保持私有。识别是在服务器上运行的,所以 cookie 属于它的 IP 地址:请从那台机器发出你的 Guzzle 请求,或者把同一个代理同时交给识别和客户端。

为什么用 cookie jar 而不是 Cookie 请求头?

请求头也行。SDK 自带的 datadome.php 示例是用一个普通的 cURL 句柄,把 cookie 值放在请求头里发送的;我没有运行过它。cookie jar 增加了对 Domain 和 Path 属性的匹配、过期处理和替换 cookie,代价是第 2 步里那些悄无声息的失败。

必须使用代理吗?

第一次测试不需要。CapSkip 文档说,cookie 只在获得它的 IP 地址上有效,所以量大时每个任务都要一个粘性代理。要轮换它们,请阅读 代理轮换指南.

简短版结论

在 PHP 中识别 DataDome 验证码,需要为每个身份准备一个 Guzzle 客户端,用关闭 http_errors 的方式读取封锁页面,调用一次 dataDome(),并把返回的 cookie 放进该客户端的 cookie jar。在一个由你自己运行的 本地验证码识别工具 上重试不花一分钱,所以让循环去请求一个新的挑战,而不是复用过期的挑战。关于其他验证码类型的 SDK 调用,请参阅 PHP 验证码识别页面.