如何用 cURL 和 DOMXPath 在 PHP 中识别 Friendly Captcha

solve friendly captcha in php - How to Solve Friendly Captcha in PHP With cURL and DOMXPath

要在 PHP 中识别 Friendly Captcha,先用一个打开了 cookie 引擎的 cURL 句柄获取页面。用 DOMXPath 读取 frc-captcha 小组件及其 script 标签,并根据脚本判断是 v1 还是 v2。然后把 sitekey、页面 URL、版本和脚本地址传给 capskip/capskip 包的 friendlyCaptcha 方法。把它返回的 token 通过同一个句柄提交,和表单的隐藏字段放在一起:v2 填在 frc-captcha-response 里,v1 填在 frc-captcha-solution 里。有两个 PHP 习惯会让这套流程失效。每个请求都重新 curl_init 一次,会把会话 cookie 丢掉,于是带 CSRF 保护的表单会拒绝一个完全正确的 token。另外,这个调用会一直阻塞到识别结束,所以接连处理五十个表单会花很长时间,除非你把这些表单的识别一起提交。CapSkip 从 1.4.0 版开始支持 Friendly Captcha,本指南从头到尾都不需要你这边有浏览器。

你需要什么

  • 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。Friendly Captcha 支持就是在该版本中加入的,同时加入的还有 CaptchaFox 和 Capy Puzzle。
  • PHP 8.0 或更高版本,并启用 curl 和 dom 扩展;以及 capskip/capskip 1.3.0 或更高版本,这是第一个带有 friendlyCaptcha 的版本。如果 Composer 因为缺少 ext-curl 而拒绝安装这个包,就在你的 php.ini 里取消 extension=curl 这一行的注释。
  • 显示小组件的那个页面的 URL。sitekey、脚本地址和字段名都来自该页面的 HTML。
  • 识别工具的地址。Local 模式只在 127.0.0.1 上响应,仅供本机使用;Server 模式监听你的网络地址或公网 IP,这样另一台机器上的 PHP 应用就能通过 API 调用它。两者都位于 连接设置中,第 4 步会说明何时该切换。
# composer require capskip/capskip
composer require capskip/capskip

请从命令行运行这些脚本。运气不好的时候,一次 Friendly Captcha 识别可能要花好几分钟,而 Web 请求很少能活那么久;第 4 步会解释原因。

第 1 步:用一个 cURL 句柄获取页面

PHP 的 cURL 扩展不会保存任何 cookie,除非你明确要求。一个句柄刚创建时没有 cookie 存储,而每个请求都用新句柄,就等于每次都从零开始,所以等你提交表单时,页面设置的会话 cookie 早就没了。如果站点把 CSRF token 绑定在这个会话上,它就会拒绝这次提交,常见的是返回 403,不管验证码 token 有多正确。

解决办法只是一个选项。把 CURLOPT_COOKIEFILE 设为空字符串,就会为该句柄打开 cURL 的内存 cookie 引擎,而且不读也不写任何文件,正如 curl_setopt 参考文档 所述。保留这个句柄,提交时继续用它,站点设置过的每个 cookie 都会一并带上,包括在重定向过程中设置的 cookie。

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

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

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

// One handle for the whole run. An empty COOKIEFILE switches on the
// in-memory cookie engine, so the session survives until the post.
$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    // PHP's cURL sends no User-Agent at all unless you set one.
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    throw new RuntimeException('fetch failed: ' . curl_error($http));
}
// The address the page finally came from, after any redirects.
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

// Without a meta charset, loadHTML reads UTF-8 as Latin-1.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

这里面有三行能帮你省掉日后的调试。PHP 的 cURL 不会发送 User-Agent 头,除非你自己设置,而有些站点会拒绝没有 User-Agent 的请求。CURLINFO_EFFECTIVE_URL 给出页面最终来自的地址,当第一个地址发生重定向时,这才是该交给识别工具、也该用作 Referer 的正确 URL。还有那个编码前缀:页面没有声明字符集时,它能防止 loadHTML 把页面当成 Latin-1 来读,否则带重音符号的隐藏字段值在回传时就会变成乱码。

第 2 步:读取小组件,选定 v1 或 v2,并调用 friendlyCaptcha

两个版本渲染出的元素完全相同:一个带有 frc-captcha 类和 data-sitekey 属性的 div,所以光看小组件本身,看不出用的是哪套协议。script 标签能告诉你。v2 站点加载的是 @friendlycaptcha/sdk 包,文件名是 site.min.js;v1 站点加载的是 friendly-challenge,文件名是 widget.module.min.js。DOMXPath 可以找出小组件、包住它的表单以及每一个脚本:

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    throw new RuntimeException('No frc-captcha widget in the HTML; it may be built by JavaScript.');
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);

// Every script except nomodule fallbacks, as full addresses.
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

对于自行托管的小组件,完整地址很重要。在 v2 上,CapSkip 会在自己的浏览器里加载这个确切的脚本来完成识别,而像 /vendor/v2/site.min.js 这样的裸路径永远加载不出来。PHP 的 parse_url 能把 URL 拆成各个部分,却无法以页面为基准解析相对 URL,所以下面给出一个小小的解析函数,它能处理绝对路径、协议相对路径、根相对路径和带点号段的路径,还能处理 # 或 ?step=2 这类指向同一页面的表单 action:

// Resolve a script src against the page URL, as a browser would.
function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;                                   // already absolute
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');                     // same page
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;             // same path, new query
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;              // protocol-relative
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

CapSkip 按固定顺序选定版本,拿到第一个答案就停下:先看你传入的版本,再看你作为 module_script 传入的脚本地址,最后默认为 v1。陷阱就在这个默认值上。像 /assets/app.4f2a.js 这样的打包脚本什么也告诉不了 CapSkip,于是它按 v1 识别,而 v2 站点会拒绝每一个 token。所以要改在你自己的代码里判定,在那里你可以拒绝去猜:

// Returns [version, script src or null], or throws if the page
// does not say which protocol it uses.
function friendlyVersion(array $scripts, DOMElement $widget): array
{
    // Package names are unambiguous, so check them first.
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    // Self-hosted builds usually keep the file name. Themes ship their
    // own site.min.js too, so only trust a path that says friendly.
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    // Last resort: v2 and v1 name their widget options differently.
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

然后发起调用。friendlyCaptcha 接受 sitekey、页面 URL 和一个选项数组,并且在发送任何内容之前,会丢掉值为 null 的选项:

use CapSkip\CapSkip;

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
[$version, $script] = friendlyVersion($scripts, $widget);
// EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');

$result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
    'version' => $version,
    'module_script' => $script,
    'api_server' => $endpoint ?: null,
]);

echo substr($result['token'], 0, 40), PHP_EOL;   // v2 tokens start with AQQA.

注意 endpoint 后面的 ?: null。对于页面上不存在的属性,DOMElement 的 getAttribute 返回的是空字符串,永远不会是 null,而 SDK 只丢掉 null,所以少了它,一个空的 api_server 就会随请求一起发出去。这个选项用于使用 Friendly Captcha EU 端点的站点。全球端点照样会为 EU 的 sitekey 签发 token,所以在那里识别,只会在站点自己的校验环节失败,和版本出错一样,是无声无息的失败。结果中的 token 项是要提交的字符串,code 项存的是同一个字符串,captchaId 则是 CapSkip 给这次任务的 id。版本不是 v1、v2、1 或 2,sitekey 为空,或者传了该方法不认识的选项,都会在任何请求离开你的机器之前抛出 ValidationException。PHP 8.4 还自带了 Dom\HTMLDocument,它支持 querySelector,并带有真正的 HTML5 解析器。不过它的元素不是 DOMElement,而且对于缺失的属性,它的 getAttribute 返回 null,所以本指南的代码要先做些修改,才能在它上面运行。

第 3 步:把 token 连同表单自身的字段一起提交

你获取到的 HTML 里没有 token 字段,因为它是小组件脚本在浏览器里创建的。所以要由你自己加上,字段名用该版本和小组件所使用的那个。表单携带的其他所有内容,包括隐藏的 CSRF token,都直接取自表单本身,提交则通过第 1 步的句柄发出:

// The fields a browser would send: hidden ones, text, checked boxes.
$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || 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')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';

// A renamed field wins; otherwise the default for the version.
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    // A string, not the array: an array makes cURL send multipart.
    CURLOPT_POSTFIELDS => http_build_query($fields),
    // Browsers send the page as Referer; some servers refuse a post without it.
    CURLOPT_REFERER => $pageUrl,
]);
$response = curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), PHP_EOL;

这个循环会收集浏览器针对这个表单本该发送的内容,CSRF token 就是这样回到服务器的,你不必点名它。它会跳过提交按钮、被禁用的字段、下拉菜单(select)和文本区域(textarea),所以如果站点会检查它们,就按名称把按钮、下拉菜单和文本区域加上。传给 CURLOPT_POSTFIELDS 的应该是 http_build_query 生成的字符串,而不是数组本身:拿到数组时,cURL 会发送 multipart/form-data,而浏览器提交这个表单时用的是 URL 编码。还要把页面 URL 作为 Referer 发送。有些框架(Django 就是其中之一)会拒绝既没有 Referer 也没有 Origin 的 HTTPS 表单提交,哪怕 cookie 和 token 都完全正确。v2 的 token 大约 6 KB,所以它应该放在请求体里,绝不能放进查询字符串;v1 的 token 则是由点号分隔的四段,长几百个字符。原样传递,不要改动。

有两种情况需要到浏览器的 DevTools 里看一看。在 v1 上,如果 data-solution-field-name 被设为单个连字符,就表示小组件根本不写入字段,站点自己的脚本会用别的方式发送 token。另外,有些站点是用 JavaScript 提交 JSON,而不是提交表单。无论哪种情况,都手动提交一次,然后照搬页面实际发出的请求。

每个 token 只够提交一次。Friendly Captcha 的验证会拒绝已被使用过或已过期的响应,所以每个表单都要重新识别。

第 4 步:在一个 PHP 进程里处理多个表单,以及识别工具运行在哪里

friendlyCaptcha 会一直阻塞到 token 返回。PHP 在这里没有事件循环,而包里的 AsyncCapSkip 只是同一个类的另一个名字,保留它是为了让四个 SDK 的写法保持一致。处理一个表单时这没问题。可要处理五十个表单,一个接一个地调用,就意味着每次识别运行时,CapSkip 的十个 Friendly Captcha 线程里有九个闲着。

SDK 的手动流程能在单个进程里解决这个问题。send() 提交一个任务并立即返回它的 id,getResult() 则对一个 id 只轮询一次,答案还没就绪时抛出 NetworkException。同时进行的任务最多保持十个,因为这是 CapSkip 中 Friendly Captcha 的 Max. Threads 默认值,超出的任务会排队,最多等 250 秒来获得空闲线程,等不到就会被 CapSkip 判为失败:

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;

// Keep up to $limit solves in flight and call $done($i, $outcome) as
// each ends: $outcome is the token, or the exception that ended it.
function solveAll(\CapSkip\CapSkip $solver, array $jobs, callable $done, int $limit = 10, int $timeout = 300): void
{
    $running = [];                                      // index => [id, started]
    while ($jobs || $running) {
        while ($jobs && count($running) < $limit) {
            $i = array_key_first($jobs);
            $job = $jobs[$i];
            unset($jobs[$i]);
            try {
                $running[$i] = [$solver->send([
                    'method' => 'friendly_captcha',
                    'sitekey' => $job['sitekey'],
                    'url' => $job['url'],
                    'version' => $job['version'],
                    'module_script' => $job['script'] ?? null,
                    'api_server' => $job['endpoint'] ?? null,
                ]), time()];
            } catch (CapSkipError $e) {
                $done($i, $e);
            }
        }
        sleep(1);
        foreach ($running as $i => [$id, $started]) {
            try {
                $token = $solver->getResult($id, 1)['request'];
            } catch (NetworkException $e) {
                // Not ready yet. Give up only once the deadline passes.
                if (time() - $started < $timeout) continue;
                // An empty message means not ready; anything else is a connection error.
                $token = $e->getMessage() !== '' ? $e : new TimeoutException("no answer in {$timeout} s");
            } catch (ApiException $e) {
                $token = $e;                            // e.g. ERROR_CAPTCHA_UNSOLVABLE
            }
            unset($running[$i]);
            $done($i, $token);
        }
    }
}

// $jobs: one array per page from Step 2 (sitekey, url, version, script,
// endpoint), plus that page's cURL handle and fields for the post.
solveAll($solver, $jobs, function (int $i, $outcome) use ($jobs) {
    if ($outcome instanceof Throwable) {
        echo "form $i failed: ", $outcome->getMessage(), PHP_EOL;
        return;
    }
    // Post straight away, as in Step 3, on $jobs[$i]'s own handle.
});

给每个页面配一个独立的 cURL 句柄,并把它保存在任务里,因为每个表单的 CSRF token 都属于获取它的那个会话。在回调里提交,意味着每个 token 一拿到就立刻发出,而不必等最慢的那次识别。NetworkException 表示还没就绪,但它也表示连不上 CapSkip。所以,如果识别工具在运行中途停了,尚未发送的任务会在 send() 处以 Failed to connect 失败,而已经发送的任务要等到超过截止时间才会失败。每个答案只能读取一次;之后 CapSkip 返回空响应体,SDK 同样会把它报告为未就绪,所以一个 id 一旦读到结果,就要立刻把它从循环中移除。

识别耗时会有波动。Friendly Captcha 按每个请求设定工作量,并对见过很多次的地址调高这个量,而且 CapSkip 会在真实浏览器里识别每一个 v2 小组件。这就是为什么 friendlyCaptcha 最多按 recaptchaTimeout(默认 300 秒)轮询,而不是按 120 秒的 defaultTimeout,这也是上面的循环采用同一数值的原因。CapSkip 自己也有一套计时:在它的 Friendly Captcha 设置中,一次识别有 120 秒的 Row Timeout,超时就会以 ERROR_CAPTCHA_UNSOLVABLE 判为失败。如果 CapSkip 的十个线程已经被其他任务占满,调用还得等待一个空闲线程,所以 SDK 的等待必须长于这段等待时间加上识别的时间:在 CapSkip 的默认设置下,最长可达 370 秒。这就是上面的循环最多同时保持十个任务的原因,也是为什么与其他工作共用识别工具的脚本,应该在选项里传入 timeout,延长 SDK 对单次 friendlyCaptcha 调用的等待时长。轮询间隔同样可以按调用单独设置,但只能用 snake case 写成 polling_interval;camelCase 的 pollingInterval 属于构造函数,作为单次调用的选项传入会抛出 ValidationException。如果在长时间运行中识别变慢,通常是某个地址上的难度在上升。这时可以传入一个带 type 和 uri 键的代理数组,或者在 CapSkip 里配置代理池。

这些等待也是脚本要从命令行运行的原因。PHP 的 max_execution_time 对 Web 请求默认是 30 秒,在 CLI 下则不启用。在 Windows 上,它把等待的时间也计算在内,所以在页面请求里做识别,最终会以一个任何 catch 块都捕获不到的致命错误告终。在 Linux 上,它只计算 CPU 时间,切断请求的会是 Web 服务器自己的超时。无论哪种情况,都应该在 CLI 脚本或队列 worker 里做识别。

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

完整可运行示例

下面是整套流程,全部放在一个文件里:只凭一个页面 URL,在 PHP 中识别 Friendly Captcha 的最短完整写法。

<?php
// composer require capskip/capskip
// solve-friendly.php: run with php solve-friendly.php
declare(strict_types=1);

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

use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;

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

function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

function friendlyVersion(array $scripts, DOMElement $widget): array
{
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

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

$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    exit('fetch failed: ' . curl_error($http) . PHP_EOL);
}
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    exit('No frc-captcha widget in the HTML; it may be built by JavaScript.' . PHP_EOL);
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);
if (!$form instanceof DOMElement) {
    exit('The widget is not inside a form; copy the request the page makes instead.' . PHP_EOL);
}
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

try {
    [$version, $script] = friendlyVersion($scripts, $widget);
} catch (RuntimeException $e) {
    exit($e->getMessage() . PHP_EOL);
}
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');
try {
    $result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
        'version' => $version,
        'module_script' => $script,
        'api_server' => $endpoint ?: null,
    ]);
} catch (ValidationException $e) {
    exit('not sent: ' . $e->getMessage() . PHP_EOL);
} catch (CapSkipError $e) {
    exit('solve failed: ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
}

$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || 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')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

// Post wherever the form's action points, on the same handle.
curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($fields),
    CURLOPT_REFERER => $pageUrl,
]);
curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), ' ', $version, ' ', $field, PHP_EOL;

版本只判定一次,却用在两处:识别和字段名,所以两者永远不会对不上。如果 DOMXPath 找不到小组件,通常是因为页面用 JavaScript 动态生成它,这时你需要从渲染后的页面或者创建它的脚本里读取 sitekey。原始接口接受的全部参数,详见 Friendly Captcha API 参考文档.

常见错误及其含义

你所看到的原因修复
用的是 CapSkip 返回的 token,却得到 403 或 CSRF 错误提交用的是新句柄,没有带上页面的 cookie,没有带上隐藏字段,或者没有带 Referer用第 1 步那个设了空 COOKIEFILE 的句柄提交,从表单构建字段,并设置 CURLOPT_REFERER
站点拒绝了 token,却没有任何其他错误识别了错误的版本,常见的是默认套用了 v1像第 2 步那样在代码里判定版本并传入
在小组件带有 data-api-endpoint(v2)或 data-puzzle-endpoint(v1)的站点上被拒绝token 来自全球端点,而站点用的是 EU 端点把该属性的值作为 api_server 传入
在自行托管小组件的站点上,v2 识别失败module_script 传的是相对路径,所以小组件根本没有加载用 absoluteUrl 以页面 URL 为基准把 src 解析成完整地址
报告缺少字段,而且请求是以 multipart/form-data 发出的传给 CURLOPT_POSTFIELDS 的是数组传入 http_build_query($fields),也就是一个 URL 编码的字符串
带重音符号的隐藏字段值到达时变成了乱码页面没有 meta charset,loadHTML 把它当成了 Latin-1 来读保留第 1 步中的编码前缀
抛出 ValidationException,里面点名了 pollingInterval单次调用的选项要写成 polling_interval单次调用时用 snake case,或者在构造函数上设置 pollingInterval
每次都在几秒内抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLEFriendly Captcha 拒绝了该 sitekey 或页面的来源,或者该账户没有启用 v2 或 EU 端点检查 sitekey、页面 URL 和 api_server;重试无济于事
两分钟或更久之后抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLE某次识别超过了 CapSkip 120 秒的 Row Timeout,或者等待空闲线程的时间超过了 250 秒的 Wait Timeout同时进行的识别最多保持十个,添加代理,或者在 Friendly Captcha 设置中调高 Row Timeout
300 秒后抛出 TimeoutException某个任务先等待空闲线程,再进行识别,耗时超过了 SDK 的轮询时长同时进行的识别最多保持十个,或者传入更长的 timeout
出现 Fatal error: Maximum execution time of 30 seconds exceeded,而且没有任何 catch 块执行识别是在 Web 请求里运行的,而在 Windows 上,PHP 的 30 秒时限会把等待时间也计算在内在命令行或队列 worker 中做识别
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip 没有在运行,主机或端口不对,或者 PHP 运行在另一台机器上启动 CapSkip,然后确认该用 Local 模式还是 Server 模式

常见问题

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

不需要。你的脚本只需要 HTML,而 HTML 由 cURL 获取。浏览器相关的工作都在 CapSkip 内部完成:它在自己的浏览器里识别 v2 小组件,v1 谜题则直接识别。如果你的 PHP 代码本来就因为别的原因在驱动浏览器,同样的调用照样可用:改为从实时页面读取 sitekey 和脚本地址,而不是从获取到的 HTML 里读取。

可以用 Guzzle 代替原生 cURL 吗?

可以。创建一个把 cookies 选项设为 true 的客户端,它就会为自己发出的每个请求(包括重定向)维护一个 cookie jar,作用和空的 COOKIEFILE 相同。用 form_params 发送字段,它会以 URL 编码方式提交,再把页面 URL 作为 Referer 头加上。Guzzle 在 User-Agent 里自称 GuzzleHttp,所以如果站点比较挑剔,就设置一个浏览器的 User-Agent。CapSkip 的调用完全不用改,因为 SDK 是自己和识别工具通信的。

可以在 Laravel 队列任务里运行这套代码吗?

可以,而且队列 worker 比控制器更适合放这套代码,因为 worker 是从命令行运行的。把获取页面、识别和提交放进同一个任务,这样 token 就永远不会比它的会话活得更久。这样一来,任务的超时必须足以覆盖一次慢识别,而在 Windows 上,worker 根本无法强制执行这个超时。 Laravel 队列指南 讲解了任务超时、retry_after,以及配合 CapSkip 在 Windows 上运行的情况。

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

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

简短版结论

要在 PHP 中识别 Friendly Captcha,先用一个设了空 CURLOPT_COOKIEFILE 的 cURL 句柄获取页面,再用 DOMXPath 读取 frc-captcha 元素及其 script 标签,并把每个 src 解析成完整地址。根据包名或文件名,或者小组件自身的属性,判断是 v1 还是 v2,两者都看不出来时就抛出异常。用 sitekey、页面 URL、这个版本和脚本地址调用 friendlyCaptcha,EU 站点再加上 api_server。token 只提交一次,以 URL 编码方式通过同一个句柄发出,和表单自身的字段放在一起,填进该版本指定的字段。表单很多时,用 send 提交、用 getResult 轮询,每次十个。

Friendly Captcha 的难度以 CPU 时间计价,而不是以金钱计价,所以忙碌的一天只花掉你自己机器上的识别时间,别无其他。在你掌控的硬件上运行一款 无限量验证码识别工具 之后,唯一的限制就只有那台机器和你分配给它的线程。