如何用 Guzzle 和 cookie jar 在 PHP 中识别 CaptchaFox

solve captchafox in php - How to Solve CaptchaFox in PHP With Guzzle and a Cookie Jar

要在 PHP 中识别 CaptchaFox,先用一个会保留 cookie 的 Guzzle 客户端获取页面,并从小组件上读取 sitekey。把它和页面 URL 一起传给 capskip/capskip 包的 captchafox 方法。然后把 token 放进 cf-captcha-response 字段提交,以页面作为 Referer,并带上 CapSkip 随 token 一起返回的 user agent。Guzzle 的默认行为在每一步都和你作对。除非你主动要求,它不会保留 cookie;它不发送 Referer;它还会告诉每个服务器,自己的 user agent 是 GuzzleHttp/8。遇到 403 时,它会抛出异常,而不是返回响应。CapSkip 从 1.4.0 版开始支持 CaptchaFox。本指南讲这几件事:获取页面、调用、提交和重试,Guzzle 7 和 8 都适用。

你需要什么

  • 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。CaptchaFox 支持就是在该版本中加入的,同时加入的还有 Friendly Captcha 和 Capy Puzzle。
  • PHP 8.4 或更高版本,用于 Dom\HTMLDocument 及其 querySelector 方法。在 PHP 8.0 到 8.3 上,请改用 DOMDocument 和 DOMXPath 解析页面,具体做法见 PHP 中的 Friendly Captcha 识别指南.
  • capskip/capskip 1.3.0 或更高版本,这是第一个带有 captchafox 方法的版本;以及 Guzzle 7 或 8。本文的每个示例都能在 Guzzle 7.15 和 8.2 上原样运行。
  • 显示小组件的那个页面的 URL。sitekey、表单字段和小组件的构建版本,全都从这个页面上获取。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的 PHP 应用就能通过 API 调用它。两种模式都位于 连接设置中,第 4 步会说明何时该切换。
# PHP 8.4 or later, then the SDK and Guzzle 7 or 8
composer require capskip/capskip guzzlehttp/guzzle

请从命令行运行这个脚本。CapSkip 繁忙时,一次识别可能要花好几分钟,比一个 Web 请求通常能存活的时间还长。

第 1 步:用一个 Guzzle 客户端获取页面并读取 sitekey

Guzzle 客户端不会保留任何 cookie,除非你在创建它时把 cookies 选项设为 true。设了这个选项,客户端就会为它发出的每个请求维护同一个 cookie jar,在重定向过程中设置的 cookie 也包括在内。这样,带 CSRF 保护的表单背后的会话 cookie 就能一直保留到你提交为止。客户端只创建一次,两个请求都用它:

<?php
// composer require capskip/capskip guzzlehttp/guzzle
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\TransferStats;

const PAGE_URL = 'https://example.com/signup';

// cookies: one jar for every request, redirects included.
// http_errors: a 403 comes back as a response, not an exception.
$http = new Client(['cookies' => true, 'http_errors' => false]);

$pageUrl = PAGE_URL;
$page = $http->get(PAGE_URL, [
    // Runs for every hop, so it ends on the address the page came from.
    'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
        $pageUrl = (string) $stats->getEffectiveUri();
    },
]);
// With http_errors off, a blocked or missing page arrives as a response.
if ($page->getStatusCode() !== 200) {
    throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
}
$html = (string) $page->getBody();

// PHP 8.4's HTML5 parser; it reads UTF-8 without a meta charset.
$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

这里起关键作用的是两个选项和一个回调。把 http_errors 设为 false 后,403 会作为一个你可以读取的响应返回,而不是抛出 ClientException。这也是代码要自己检查状态码的原因:否则,拦截页或 404 页面会被当成表单来解析。Guzzle 每发出一个请求,on_stats 就运行一次,重定向的每一跳也包括在内,所以调用结束后,它保存的就是页面最终来自的地址。第 2 步会解释为什么这个地址对 CaptchaFox 如此重要。Dom\HTMLDocument 是 PHP 8.4 新增的 HTML5 解析器。和 DOMDocument 的 loadHTML 不同,它会把没有 meta charset 的页面按 UTF-8 读取,所以带重音符号文本的隐藏字段能完好无损地回到服务器。

sitekey 是公开的,对每个访问者都一样,按惯例以 sk_ 开头。大多数页面把它放在小组件的容器上:一个带有 captchafox 类和 data-sitekey 属性的 div。如果页面用自己的脚本构建小组件,就会改为把 key 传给 captchafox.render 调用。有些平台嵌入的是 s.uicdn.com/mampkg/ 下的一个包,而不是来自 cdn.captchafox.com 的标准小组件。这些站点期望的是以 MAM_ 开头的 token,它们的页面可能把 key 放在该脚本自己的地址里。

// The container, matched on the whole class name.
$widget = $doc->querySelector('.captchafox');
// MAM pages load this package and may carry the key in its src.
$mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');

$sitekey = $widget?->getAttribute('data-sitekey');
if ($sitekey === null && $mamSrc !== null) {
    parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
    $sitekey = $query['key'] ?? null;
}
if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
    $sitekey = $m[1];
}
$form = $widget?->closest('form') ?? $doc->querySelector('form');
if ($sitekey === null || $form === null) {
    throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
}
echo $sitekey, $mamSrc !== null ? ' (MAM page)' : '', PHP_EOL;

querySelector 匹配的是完整的类名,所以像 captchafox-wrapper 这样带有类名的外层元素不会被误选中。MAM 检查匹配的是 captchafox 包的路径,而不是主机名,因为同一个 CDN 还提供其他不相干的包,其中就有一个聊天小组件。在 Dom\HTMLDocument 的元素上,属性缺失时 getAttribute 返回 null,正是这一点让几个兜底方案能够依次串联。如果 key 位于打包后的脚本文件里,它根本就不在 HTML 中。这时要改为打开 DevTools:key 是发往 api.captchafox.com 的请求中 /captcha/ 之后的那段路径,在 MAM 页面上则是发往 mam-api.captchafox.com 的请求。没有容器时,代码会取页面上的第一个表单,所以如果页面上有搜索框或第二个表单,就按 id 选中注册表单。

第 2 步:调用 captchafox

在 PHP 中识别 CaptchaFox 只需要一个方法。它接受 sitekey、页面 URL 和一个选项数组;对于标准小组件,有前两个就够了:

use CapSkip\CapSkip;

// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

$result = $solver->captchafox($sitekey, $pageUrl, [
    // null is dropped, so the standard widget sends nothing extra.
    'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
]);

echo substr($result['token'], 0, 24), PHP_EOL;     // goes in cf-captcha-response
echo $result['userAgent'] ?? '(none)', PHP_EOL;    // the browser that minted it

结果数组在 token 键下保存 token,在 code 键下又保存一份;captchaId 键下是 CapSkip 给这个任务分配的 id;如果这次识别报告了 user agent,还会有 userAgent。这个 user agent 属于 CapSkip 用来识别的那个浏览器,与你发送的任何内容都无关,第 3 步就要靠它。这个方法在发送任何内容之前,会丢掉所有值为 null 的选项,所以 api_server 那一行可以在每次调用时都运行。

要发送小组件真正所在的那个页面。CaptchaFox 会为每个 key 登记一份允许的域名列表,并在签发任何东西之前检查主机,所以其他域名上的页面 URL,每次尝试都会被拒绝。这就是第 1 步要追踪重定向之后地址的原因。一个会重定向到 www 主机或本地化注册页面的链接,并不是小组件所在的地方。

该方法接受的选项

api_server 用来选择小组件构建版本,apiServer 是它的别名。一个带 type 和 uri 键的 proxy 数组,会让识别走你的代理。useragent 会被接受,但有意不生效,因为 CapSkip 在真实浏览器中识别,用的是该浏览器自己的身份。以秒为单位的 timeout 选项,会在这一次调用中取代客户端的 recaptchaTimeout。轮询间隔的上限也可以按单次调用设置,但只能用 snake case 写成 polling_interval。camelCase 的 pollingInterval 属于构造函数,作为单次调用的选项传入时会抛出 ValidationException。为空的 sitekey 或 URL、该方法不认识的选项,以及 HTTP、HTTPS、SOCKS5、SOCKS5H 之外的代理类型,同样会抛出 ValidationException,而且都发生在任何请求离开你的机器之前。

第 3 步:用签发 token 的那个 user agent 提交 token

在 PHP 中识别 CaptchaFox 时,站点是否接受 token,就取决于这一步。对于每个你没有指定 user agent 的请求,Guzzle 都会以 GuzzleHttp/8 作为 user agent 发送,较旧的主版本则是 GuzzleHttp/7。这不是生成该 token 的浏览器,站点如果期望 token 来自签发它的那个浏览器,就会拒绝这次提交。把返回的值复制到携带 token 的请求上:

use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;

// The fields a browser would send for this form, CSRF token included.
$fields = [];
foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
    $type = strtolower($input->getAttribute('type') ?? 'text');
    if (in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
}
$fields['email'] = 'YOUR_EMAIL';
$fields['cf-captcha-response'] = $result['token'];

// Browsers send the page as Referer; strict servers want one.
$headers = ['Referer' => $pageUrl];
// Without this, Guzzle announces itself as GuzzleHttp/8 (or /7).
if (isset($result['userAgent'])) {
    $headers['User-Agent'] = $result['userAgent'];
}

// Resolve the form's action against the page, as a browser would.
$action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));
$response = $http->post($action, ['form_params' => $fields, 'headers' => $headers]);
echo $response->getStatusCode(), PHP_EOL;

这个循环会收集浏览器针对这个表单本该发送的内容:隐藏的 CSRF token、文本字段和已勾选的复选框,但不包括被禁用的字段和按钮。它会跳过下拉菜单(select)和文本区域(textarea),所以如果表单里有这些,就按名称把它们加上;如果服务器会检查提交按钮的 name,也要把它加上。form_params 以 URL 编码方式发送字段,和浏览器提交这个表单的方式一样。UriResolver 随 Guzzle 的 PSR-7 包一起提供,它会像浏览器那样,以页面为基准解析表单的 action,空 action、只有查询字符串的 action 和带点号段的路径都能处理。

Guzzle 自己也不会发送 Referer。有些框架(Django 就是其中之一)会拒绝既没有 Referer 也没有 Origin 请求头的 HTTPS 表单提交,哪怕 cookie 和 token 都完全正确。Guzzle 8 还会直接拒绝值为 null 的请求头,抛出 InvalidArgumentException,所以只有当识别报告了 user agent 时,代码才会加上它。

另外两条规则,来自 CaptchaFox 在站点一侧对 token 的校验机制。每个 token 只能验证一次,而且只在很短的时间内有效,所以要在准备好之后再识别,识别完立刻提交。另外,要把 token 当作不透明的数据:永远不要裁剪或重新编码它。有些站点是用自己的脚本把它放在 JSON 请求体里发送,所以请打开 DevTools,手动提交一次表单,然后照搬页面发送的内容。CaptchaFox 的 入门文档 展示了小组件的 HTML 标记和服务器端的校验。

第 4 步:重试、超时,以及识别工具运行在哪里

CaptchaFox 弹出哪种挑战,不由你选择。大多数识别根本不会弹出任何挑战;出现滑块拼图时,CapSkip 通常也能完成它。图片选择和音频挑战很少见,也不予识别:它们会在几秒内以 ApiException 的形式返回,消息里包含 ERROR_CAPTCHA_UNSOLVABLE。重新尝试一次通常会换成别的挑战,所以值得重试:

use CapSkip\Exceptions\ApiException;

function solveCaptchaFox(\CapSkip\CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable;
            // redraw. Anything else, such as a wrong API key, is final.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
        }
    }
}

异常消息里带着 CapSkip 的原始回复,所以检查代码直接从中读取错误码。被站点拒绝的 key(比如用在了错误的域名上)看起来也是无法识别,同样会被重试。不过它每次尝试都在几秒内失败,这正是辨认它的线索。另一种方式是 CapSkip 设置中 CaptchaFox 那一行的 Retries 设置。它的取值为 0 到 3,默认为 0。调高之后,CapSkip 会自动重试不支持的挑战、超时的尝试和浏览器错误,但从不重试被拒绝的 key。

captchafox 按客户端的 recaptchaTimeout 轮询,默认 300 秒,因为它是一个真实的浏览器会话。CapSkip 自己也有一套计时:一个任务最多可以等待 250 秒(Wait Timeout),等 10 个 CaptchaFox 线程(Max. Threads)中的一个空出来,单次尝试则有 150 秒(Row Timeout)。有空闲线程时,失败的尝试会以 ApiException 的形式到达你这里,远在 SDK 的 300 秒用完之前。如果排队很久,SDK 就可能先到达它的时限,在 CapSkip 仍在工作时抛出 TimeoutException。所以,当你让大量识别排队或调高 Retries 时,要传入更长的 timeout,因为每次重试都可能再多出一个 Row Timeout。

要处理大量注册时,同时进行的识别最多保持十个,与 Max. Threads 一致,并给每次运行配一个独立的 Guzzle 客户端,这样各自的 cookie jar 就永远不会混在一起。captchafox 会一直阻塞到 token 返回,所以要在一个 PHP 进程里并行运行多个识别,就得用 SDK 的 send 和 getResult 方法。Friendly Captcha 指南的第 4 步给出了那个循环。对于 CaptchaFox,提交任务时把 method 设为 captchafox,并保留 getResult 返回数组中的 userAgent 字段,而不只是它的 request 字段,因为第 3 步提交时两者都要用。

示例使用 127.0.0.1,因为只要 PHP 和 CapSkip 在同一台 Windows 电脑上,这样写就是对的。一旦把脚本挪到 VPS、共享主机或 Linux 上的 Laravel 应用里,回环地址指向的就是那台机器,第一次调用就会抛出 NetworkException,提示 Failed to connect。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 访问到它。如果链路要经过公网,请使用静态公网 IP。Server 模式启动时,CapSkip 会向所有地址开放它的端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的机器,识别也仍然不计量。客户端不会自己读取环境变量,所以要用 getenv 读取 CAPSKIP_HOST 并传进去,就像完整示例那样。

完整可运行示例

下面是放在一个文件里的完整流程。signUp 会用它自己的客户端获取页面、读取 key、带重试地完成识别,然后带上页面的 cookie、Referer 和签发 token 的那个 user agent 提交表单。

<?php
// composer require capskip/capskip guzzlehttp/guzzle
// signup.php: run with php signup.php (PHP 8.4 or later)
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\TimeoutException;
use CapSkip\Exceptions\ValidationException;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\TransferStats;

const PAGE_URL = 'https://example.com/signup';
// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

function solveCaptchaFox(CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
            echo "attempt $attempt: ", $e->getMessage(), PHP_EOL;
        }
    }
}

function signUp(CapSkip $solver, string $url, string $email): int
{
    // A new client per run, so its cookie jar holds this session only.
    $http = new Client(['cookies' => true, 'http_errors' => false]);
    $pageUrl = $url;
    $page = $http->get($url, [
        'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
            $pageUrl = (string) $stats->getEffectiveUri();
        },
    ]);
    if ($page->getStatusCode() !== 200) {
        throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
    }
    $html = (string) $page->getBody();
    $doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

    $widget = $doc->querySelector('.captchafox');
    $mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');
    $sitekey = $widget?->getAttribute('data-sitekey');
    if ($sitekey === null && $mamSrc !== null) {
        parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
        $sitekey = $query['key'] ?? null;
    }
    if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
        $sitekey = $m[1];
    }
    $form = $widget?->closest('form') ?? $doc->querySelector('form');
    if ($sitekey === null || $form === null) {
        throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
    }

    $result = solveCaptchaFox($solver, $sitekey, $pageUrl, [
        'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
    ]);

    $fields = [];
    foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
        $type = strtolower($input->getAttribute('type') ?? 'text');
        if (in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
        $checkable = in_array($type, ['checkbox', 'radio'], true);
        if ($checkable && !$input->hasAttribute('checked')) continue;
        $fields[$input->getAttribute('name')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
    }
    $fields['email'] = $email;
    $fields['cf-captcha-response'] = $result['token'];

    $headers = ['Referer' => $pageUrl];
    if (isset($result['userAgent'])) {
        $headers['User-Agent'] = $result['userAgent'];
    }
    $action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));

    return $http->post($action, ['form_params' => $fields, 'headers' => $headers])->getStatusCode();
}

$solver = new CapSkip([
    'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
    'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);

try {
    $status = signUp($solver, PAGE_URL, 'YOUR_EMAIL');
    echo $status, PHP_EOL;
    // A 4xx from the site means the post was refused.
    exit($status < 400 ? 0 : 1);
} catch (ValidationException $e) {
    fwrite(STDERR, 'not sent: ' . $e->getMessage() . PHP_EOL);
    exit(1);
} catch (TimeoutException $e) {
    fwrite(STDERR, 'gave up after recaptchaTimeout' . PHP_EOL);
    exit(1);
} catch (CapSkipError | RuntimeException $e) {
    // Guzzle's transfer errors are RuntimeExceptions too.
    fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

如果每次运行都在几秒内失败,先检查页面 URL,因为在登记域名之外使用的 key,每次都会以同样的方式失败。如果 sitekey 检查抛出错误,说明页面是用打包后的脚本构建小组件的,key 在 Network 标签页里,具体见第 1 步。这个方法背后的原始接口,详见 API 参考中的 CaptchaFox 部分.

常见错误及其含义

你所看到的原因修复
CapSkip 返回识别成功的 token 被站点拒绝提交请求是以 GuzzleHttp/8 或 GuzzleHttp/7 的身份发出的,而不是签发 token 的那个浏览器在该请求上把结果中的 userAgent 作为 User-Agent 请求头发送
user agent 一致,却仍被拒绝页面加载的是 MAM 包,识别用的却是标准小组件,或者反过来读取 script 标签,并把 api_server 设成与之匹配
token 和 user agent 都有效,提交却返回 403创建客户端时没有把 cookies 设为 true,或者提交用的是另一个客户端,导致 CSRF 字段对不上;或者提交请求没有带 Referer用第 1 步的客户端提交,并把页面 URL 作为 Referer 发送
提交时抛出 GuzzleHttp\Exception\ClientExceptionhttp_errors 默认开启,所以任何 4xx 响应都会抛出异常创建客户端时把 http_errors 设为 false,然后读取状态码
抛出 InvalidArgumentException,消息中提到 headers.User-AgentGuzzle 8 拒绝值为 null 的请求头,而这次识别没有报告 user agent保留那个 isset 检查;如果站点拒绝 Guzzle 自己的名字,就自己发送一个当前桌面浏览器的 user agent
某个 key 每次都在几秒内抛出 ApiException页面 URL 不在该 key 登记的域名之内(常见原因是发送了重定向之前的地址),或者 key 本身就不对发送 on_stats 记录下的地址,并重新核对 key
偶尔出现包含 ERROR_CAPTCHA_UNSOLVABLE 的 ApiExceptionCaptchaFox 弹出了图片选择或音频挑战,或者 CapSkip 读不懂的滑块在代码里重试,或者在 CapSkip 的 CaptchaFox 设置中调高 Retries
抛出 RuntimeException,提示页面的 GET 请求返回了 403站点拒绝了页面获取请求,常见原因是 Guzzle 自己的 user agent获取页面时,在客户端上设置一个当前桌面浏览器的 user agent;提交时仍然带上签发 token 的那个
还没发送任何内容就抛出 ValidationExceptionsitekey 或 URL 为空、传了该方法不接受的选项(比如单次调用的 pollingInterval),或者代理类型不被 CapSkip 接受确认 sitekey 已经找到,并修正或去掉错误信息中指出的那个选项
五分钟后抛出 TimeoutException等待空闲 CaptchaFox 线程的时间加上识别本身,超过了 SDK 的 300 秒,或者 CapSkip 在识别中途停止了同时进行的识别最多保持十个,或者为这次调用传入更长的 timeout
Class "Dom\HTMLDocument" not foundPHP 版本低于 8.4升级 PHP,或者改用 DOMDocument 和 DOMXPath 解析
Fatal error: Cannot use GuzzleHttp\Exception\NetworkException as NetworkExceptionGuzzle 8 新增了自己的 NetworkException 类,而两个类导入时用了同一个短名称用别名导入其中一个,比如 GuzzleNetworkException
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip 没有在运行,主机或端口不对,或者 PHP 运行在另一台机器上启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式

常见问题

在 PHP 中识别 CaptchaFox,需要浏览器吗?

不需要。你的脚本只需要页面的 HTML,而 HTML 由 Guzzle 获取。浏览器相关的工作都在 CapSkip 内部完成:它在自己的浏览器里驱动真实的小组件,然后把 token 连同那个浏览器的 user agent 一起交还给你。你的 PHP 代码从不运行 JavaScript,所以它只需正确携带会话和请求头。

可以用原生 cURL 代替 Guzzle 吗?

可以。只用一个 cURL 句柄,把 CURLOPT_COOKIEFILE 设为空字符串,这会打开它的内存 cookie 引擎,然后在同一个句柄上提交。原生 cURL 根本不发送 User-Agent 请求头,除非你自己设置,所以提交前要把 CURLOPT_USERAGENT 设为结果中的 userAgent,并把 CURLOPT_REFERER 设为页面地址。 PHP 版 Friendly Captcha 指南 用 cURL 和 DOMXPath 搭建了这整套流程。

可以改为从 Symfony Panther 提交 token 吗?

可以,只要提交它的浏览器使用的是 CapSkip 返回的 user agent。先识别,然后启动 Panther 的 Chrome,把它的 user agent 参数设为这个值,加载表单,把 token 写入 cf-captcha-response 字段并提交。如果你的脚本在其他方面并不需要浏览器,Guzzle 方案更轻量。 Symfony Panther 指南 演示了如何用 PHP 把 token 写入页面。

VPS 上的 PHP 应用能使用我 Windows 电脑上的识别工具吗?

可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址。然后在应用运行的地方设置 CAPSKIP_HOST,并像完整示例那样把它传给客户端。VPS、共享主机和容器都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP,并为不应访问该端口的地址添加一条 Windows Firewall 阻止规则。识别工具始终运行在你自己的硬件上,所以识别次数再多,你付的费用也不会变。

简短版结论

要在 PHP 中识别 CaptchaFox,先用一个创建时打开 cookies、关闭 http_errors 的 Guzzle 客户端获取页面,并用 on_stats 记录最终地址。从 captchafox 容器、render 调用或 MAM 脚本中读取 sitekey,并留意页面加载的是哪个构建版本。用 sitekey 和这个地址调用 captchafox,只有 MAM 页面才加上 api_server。用 form_params 把 token 放进 cf-captcha-response 提交,以页面作为 Referer,以结果中的 userAgent 作为 User-Agent 请求头,只提交一次,并且立刻提交。遇到无法识别的挑战就重试,同时进行的识别不要超过 Max. Threads(默认 10 个),一旦脚本离开识别工具所在的机器,就切换到 Server 模式。

让 CaptchaFox 换一个挑战,通常是这个类型唯一需要的重试。当 本地验证码识别工具 就运行在你自己的 Windows 机器上时,每次换挑战只花这台机器几秒钟的时间,而不是又一次计费的识别。