如何用 PHP 和 Symfony BrowserKit 识别 Capy Puzzle

solve capy puzzle in php - How to Solve Capy Puzzle in PHP With Symfony BrowserKit

要在 PHP 中识别 Capy Puzzle,先用 Symfony 的 HttpBrowser 打开页面,从小组件脚本里读出 PUZZLE_ key。把它和页面 URL 一起传给 capskip/capskip 包的 capy 方法。然后把它返回的三个值分别放进 capy_captchakey、capy_challengekey 和 capy_answer 字段,在同一个浏览器会话里发送出去。HttpBrowser 是取代 Goutte 的那个类,大部分工作它都替你做了:它保留 cookie、跟随重定向,并把页面作为 Referer 发送。麻烦出在它的表单对象上。capy_ 字段只有在小组件的 JavaScript 运行之后才会出现,所以 DomCrawler 拒绝设置它们,并抛出 Unreachable field。CapSkip 从 1.4.0 版开始支持 Capy Puzzle。本指南讲这几件事:key、调用、两秒等待、绕开这种拒绝的提交方式、重试,以及放在另一台机器上的识别工具。

你需要什么

  • 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。Capy Puzzle 支持就是在该版本中加入的,同时加入的还有 CaptchaFox 和 Friendly Captcha。
  • PHP 8.4.1 或更高版本,用于 Symfony 8 组件。在 PHP 8.2 或 8.3 上,Composer 会改为选用 7.4 LTS 版本,下面的代码在两者上都能原样运行。capskip 包还需要 curl 和 json 扩展。
  • capskip/capskip 1.3.0 或更高版本,这是第一个带有 capy 方法的版本;另外还需要四个 Symfony 组件:BrowserKit、HttpClient、Mime 和 CssSelector。
  • 显示拼图的那个页面的 URL。key、Capy 主机和表单,全都从这个页面上获取。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两种模式的设置位置都是 连接设置.
# symfony/mime is needed for any POST, css-selector for filter()
composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector

Composer 会随 BrowserKit 一起安装 DomCrawler,但 BrowserKit 并不依赖另外三个组件,所以要你自己写明。没有 HttpClient,就无法创建 HttpBrowser;没有 CssSelector,下面的 filter() 调用会抛出 LogicException。Mime 是那个很晚才报错的组件。没有它,获取页面照样正常,只有提交表单时才会失败,并抛出一个提示你安装 symfony/mime 的 LogicException。

第 1 步:用 HttpBrowser 打开页面并找到 key

HttpBrowser 把 Symfony 的 HTTP 客户端包装成一个小型浏览器:有 cookie jar,有浏览历史,还会自行跟随重定向。Capy 登录表单通常带有的会话 cookie 和 CSRF token,它都能应付。不过,遇到错误状态码时它不会抛出异常。403 或 404 会像其他页面一样正常返回,所以要像代码块里那样,先检查状态码,再去找 key。它其余的 API,可以查阅 BrowserKit 组件文档 中的说明。

<?php
// composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector
require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

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

// HttpBrowser keeps the cookies, follows redirects and sends a Referer.
$browser = new HttpBrowser(HttpClient::create());
$page = $browser->request('GET', PAGE_URL);
$status = $browser->getInternalResponse()->getStatusCode();
if ($status !== 200) {
    throw new RuntimeException("The page answered {$status}, not the form.");
}

// The widget script's k= parameter is the key, and everything before
// /puzzle/get_js/ is the Capy host.
$script = $page->filter('script[src*="/puzzle/get_js/"]');
$src = $script->count() ? $script->attr('src') : '';
if (str_starts_with($src, '//')) {
    $src = 'https:' . $src;
}
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$key = $query['k'] ?? $page->filter('input[name="capy_captchakey"]')->attr('value', '');
$apiServer = strstr($src, '/puzzle/get_js/', true) ?: null;

// The form around the widget, or else the page's first POST form.
$widget = $page->filter('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]');
$formNode = ($widget->count() ? $widget->closest('form') : null)
    ?? $page->filter('form')->reduce(fn ($f) => strtolower((string) $f->attr('method')) === 'post')->first();
echo $key, ' ', $apiServer ?? 'default host', ' ', $formNode->count(), PHP_EOL;

key 是公开的,对每个访问者都一样,拼图类的 key 以 PUZZLE_ 开头。大多数页面把它放在小组件脚本 URL 的 k 参数里,而这个 URL 就在 HttpBrowser 下载到的 HTML 中。有些页面还会在表单里渲染一个隐藏的 capy_captchakey input,找不到脚本时,代码块就读取这个 input。script URL 中 /puzzle/get_js/ 之前的部分,就是这个 key 背后的 Capy API,CapSkip 把它叫作 api_server。它的默认值是 https://jp.api.capy.me,也就是线上服务实际运行的地方。为其他识别工具写的示例常常写着没有地区前缀的 api.capy.me,而这个主机已经无法解析了,所以如果你复制过这样的示例,就把它删掉。如果 src 以两个斜杠开头,就在前面补上 https:,因为 Capy API 只通过 HTTPS 提供服务。

最后几行用来选取表单。closest() 调用会从小组件开始向上查找包含它的那个表单。如果脚本位于 head 里或表单之后,就没有表单包含它,这时代码块会退而选用页面上的第一个 POST 表单。如果页面上有两个 POST 表单,请改用 filter() 按 id 选中你要的那个。

第 2 步:调用 capy()

在 PHP 中识别 Capy Puzzle 只需要一个方法。它接受 key、页面 URL 和一个可选的选项数组;对于使用默认主机的页面,有前两个就够了。

// composer require capskip/capskip
use CapSkip\CapSkip;

$solver = new CapSkip([
    'host' => '127.0.0.1',
    'port' => 8080,
    // Capy answers after a two second hold; poll often enough to catch it.
    'pollingInterval' => 0.5,
]);

// The page's real URL, after any redirect. A null api_server (no widget
// script in the HTML) is dropped, so CapSkip uses its default host.
$pageUrl = $browser->getHistory()->current()->getUri();
$result = $solver->capy($key, $pageUrl, ['api_server' => $apiServer]);

echo $result['captchakey'], PHP_EOL;     // goes in capy_captchakey
echo $result['challengekey'], PHP_EOL;   // goes in capy_challengekey
echo $result['answer'], PHP_EOL;         // goes in capy_answer

返回结果是一个数组。读取那三个具名键即可。code 键里是服务器返回的完整答案,一个包含全部四个字段的数组,方便记日志。respKey 是一个空字符串,只是为了与其他服务兼容而存在。答案是一个很长的字符串,开头大致像 0xax8ex0xax84x 这样。它是小组件在拼图块移动时本会记录下来的拖动轨迹,而不是一个坐标。

要像代码块那样,传入浏览器最终停留的那个 URL,而不是你输入的地址。站点可能会把那个地址重定向,加上结尾斜杠或语言前缀。CapSkip 拉取拼图时会把页面 URL 作为 Referer 发送,所以它应该就是表单真正来自的那个页面。

该方法接受的选项

除了 api_server(也可以写成 apiServer),这个方法还接受一个带 type 和 uri 键的 proxy 数组。它还接受 useragent,CapSkip 拉取拼图时发出的那唯一一个请求会带上它。值为 null 的选项都会被丢弃,所以上面的调用每次都可以直接传入解析出的主机。还有一个 version 选项,但只接受 puzzle。Capy 的另一个系列 avatar 是另一种挑战,背后是另一个接口,所以 SDK 会拒绝它,而不是返回一个站点会拒收的答案。

有两个单次调用选项值得了解。以秒为单位的 timeout 会在这一次识别中取代客户端的 defaultTimeout。轮询间隔的上限也可以按单次调用设置,但只能写成 polling_interval。驼峰写法的 pollingInterval 属于构造函数,传给 capy() 时会抛出 ValidationException,并在消息里点出这个选项。为空的 key 或 URL、未知的选项,以及 HTTP、HTTPS、SOCKS5、SOCKS5H 之外的代理类型,同样会抛出 ValidationException,而且是在任何请求离开你的机器之前。

为什么一次 Capy 识别大约要两秒

找到缺口很快。CapSkip 拉取拼图图片,用一些像素运算定位缺口,再生成拖动轨迹,整个过程不涉及浏览器。然后它会有意地等一等。

Capy 会计算从下发拼图到收到答案之间的时间,凡是比真人拖动拼图块还快的答案,一律拒绝。这种拒绝的表现和答错一模一样,所以一个正确但送得太早的识别结果,看起来和识别工具坏了没有任何区别。因此,CapSkip 会把每个 Capy 结果压住,直到拉取拼图满两秒之后才放行。这段等待只是 sleep,所以只增加延迟,不消耗 CPU。

在默认设置下,一次调用要接近四秒,原因在于 SDK 的轮询方式。任务一提交,它就立刻请求一次结果,四分之一秒后再请求一次,之后每次把间隔翻倍,直到达到它的 pollingInterval,即 5 秒。各次轮询落在 0、0.25、0.75、1.75 和 3.75 秒,所以两秒时就绪的答案要等到第五次轮询才被取回。在我们的测试中,默认客户端在 3.8 秒后返回,而像上面那样把 pollingInterval 设为 0.5 的客户端,2.3 秒后就返回了。提交前不要自己再加延迟,也不要想办法把这段等待省掉。答案能通过,靠的正是它。

第 3 步:加上三个 capy_ 字段并提交

BrowserKit 代码通常就栽在这一步。最自然的做法是填写表单对象再提交它:用 setValues(),用 submit() 的 values 参数,或者像给数组元素赋值那样给字段赋值。这三种做法都会抛出 InvalidArgumentException,消息是 Unreachable field 后面跟着字段名。DomCrawler 是根据它解析的 HTML 构建表单的。Capy 小组件在真实浏览器里运行时,会用 JavaScript 把它的三个 input 写进表单。HttpBrowser 不运行 JavaScript,所以在 DomCrawler 看来,这些字段根本不存在。

// The form's own fields, the hidden CSRF token included.
$form = $formNode->form();

// $form->setValues() would throw Unreachable field: the widget never
// ran, so the form has no capy_ fields. Send them through request().
$fields = array_replace($form->getPhpValues(), [
    // Use the page's own field names, password included.
    'username' => 'YOUR_USERNAME',
    'capy_captchakey' => $result['captchakey'],
    'capy_challengekey' => $result['challengekey'],
    'capy_answer' => $result['answer'],
]);
$browser->request($form->getMethod(), $form->getUri(), $fields);

// After a redirect, this is where the login landed.
echo $browser->getInternalResponse()->getStatusCode(), ' ',
    $browser->getHistory()->current()->getUri(), PHP_EOL;

解决办法是:表单只用来提供它知道的那些字段,请求由你自己发送。getPhpValues() 方法会按浏览器提交时的样子返回表单字段,不含按钮,但包括隐藏的 CSRF token,并且采用 PHP 处理带方括号字段名时的嵌套结构。然后用 array_replace() 在此基础上加入你的登录字段和那三个值。它还会覆盖页面自己渲染出来的 capy_captchakey input,所以 key 永远不会发出两次。最后,request() 用表单自己的 method 和地址把这一切发送出去。

绕开表单对象,在浏览器这一侧不会让你损失任何东西。submit() 底层调用的正是 request() 方法。它照样会发送 cookie jar 里的 cookie,并把当前页面作为 Referer,而 Django 这类严格的框架在 HTTPS 表单提交时检查的正是这个请求头。它照样会跟随重定向,并且在跟随之前先保存 302 设置的 cookie。因此,即使登录成功时站点轮换了会话,请求也会落到账户页面上,而新的会话已经在 cookie jar 里了。从 form 节点构建的表单会漏掉一样东西:提交按钮。如果服务器会检查按钮的 name,请改为用 selectButton() 加上按钮的文字选中按钮,再从按钮构建表单。

答案要按返回的原样提交。站点后端会拿它和当时下发的拼图进行比对,所以截断它或重新拼装它,都会让它失效。然后要尽快提交。CapSkip 每次识别都会生成一个新的挑战 key,Capy 会把它下发的拼图绑定到这个 key 上。所以这个 key 只能用一次,而且寿命很短,一次识别只对应一次提交。有些页面通过自己的脚本用 JSON 请求体提交,而不是表单提交。打开 DevTools 手动提交一次,然后把三个值放到页面放它们的位置。

第 4 步:识别失败时重试

只要在 PHP 中识别 Capy Puzzle 有一定的量,就总会有几次识别失败。失败的识别会以 ApiException 的形式返回,其消息就是 CapSkip 的原始回复,默认情况下其中包含 ERROR_CAPTCHA_UNSOLVABLE。通过 SDK 轮询的接口 res.php,每一次失败的识别看起来都一样,而这里要关注的原因有两种。没找到缺口只是偶尔发生,下一次尝试会拿到一张基于不同照片的全新拼图,所以通常会成功。被 Capy API 拒绝的 key 则每次尝试都会失败,CapSkip 的 Monitor 标签页里 Capy 卡片上的 Bad Keys 计数会随之上升。第一种情况重试即可,第二种要去修正。不响应的 api_server 也会每次都失败,但 Bad Keys 计数不会变化。

function solveCapy(\CapSkip\CapSkip $solver, string $key, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->capy($key, $url, $options);
        } catch (\CapSkip\Exceptions\ApiException $e) {
            // A missed hole reads as unsolvable, and the next try draws a
            // new puzzle. Anything else, such as a wrong API key, is final.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt >= $attempts) {
                throw $e;
            }
        }
    }
}

代码块里的 CapSkip 类都写了完整名称,所以无论目标文件导入了什么,这个函数都能直接粘贴进去。被拒的 key 看起来同样是无法识别,所以这里它也会被重试,而它每次尝试都以同样的方式失败,你正是靠这一点区分这两种情况。另一种方式是 CapSkip 设置中 Capy 部分的 Retries 设置,取值为 0 到 3,默认为 0。设置之后,CapSkip 会自行重试没找到缺口的情况,但从不重试被拒的 key,因为它会以同样的方式再被拒一次。

capy 方法按客户端的 defaultTimeout 轮询,即 120 秒,因为一次 Capy 识别只是一次拉取加上一些运算,而不是一个浏览器会话。CapSkip 自己也有一套计时。在它的 Capy 设置中,一个任务最多可以等待 250 秒(Wait Timeout)来获得 10 个线程(Max. Threads)中的一个,而单次尝试有 60 秒(Row Timeout)。一次正常的识别,在这些时限内都绰绰有余。SDK 遇到连接错误也会照常继续轮询,所以如果 CapSkip 在识别中途停止,调用最终会以 TimeoutException 结束,而不是 NetworkException。

完整可运行示例

下面是放在一个文件里的完整流程。logIn() 函数用它自己的 HttpBrowser 打开页面,读取 key 和主机,带重试地完成识别,然后连同三个值一起发送表单。它会返回这个浏览器实例,这样你在登录之后发出的请求都会带上新的会话。

<?php
// composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector
// login.php: run with php login.php
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 Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

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

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

function solveCapy(CapSkip $solver, string $key, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->capy($key, $url, $options);
        } catch (ApiException $e) {
            // A missed hole reads as unsolvable; the next try draws a new puzzle.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt >= $attempts) {
                throw $e;
            }
            echo "attempt {$attempt}: {$e->getMessage()}", PHP_EOL;
        }
    }
}

function logIn(CapSkip $solver, string $pageUrl, array $credentials): HttpBrowser
{
    // One browser per login: it owns the cookie jar and the history.
    $browser = new HttpBrowser(HttpClient::create());
    $page = $browser->request('GET', $pageUrl);
    $status = $browser->getInternalResponse()->getStatusCode();
    if ($status !== 200) {
        throw new RuntimeException("The page answered {$status}, not the form.");
    }
    $pageUrl = $browser->getHistory()->current()->getUri();

    $script = $page->filter('script[src*="/puzzle/get_js/"]');
    $src = $script->count() ? $script->attr('src') : '';
    if (str_starts_with($src, '//')) {
        $src = 'https:' . $src;
    }
    parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
    $key = $query['k'] ?? $page->filter('input[name="capy_captchakey"]')->attr('value', '');
    if (!str_starts_with($key, 'PUZZLE_')) {
        throw new RuntimeException('No PUZZLE_ key in the HTML; find it in DevTools.');
    }
    $apiServer = strstr($src, '/puzzle/get_js/', true) ?: null;

    $widget = $page->filter('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]');
    $formNode = $widget->closest('form')
        ?? $page->filter('form')->reduce(fn ($f) => strtolower((string) $f->attr('method')) === 'post')->first();
    if (!$formNode->count()) {
        throw new RuntimeException('No POST form on the page; select it by id.');
    }
    $form = $formNode->form();

    $result = solveCapy($solver, $key, $pageUrl, ['api_server' => $apiServer]);

    // All three together, straight away: the challenge key is single-use.
    $browser->request($form->getMethod(), $form->getUri(), array_replace(
        $form->getPhpValues(),
        $credentials,
        [
            'capy_captchakey' => $result['captchakey'],
            'capy_challengekey' => $result['challengekey'],
            'capy_answer' => $result['answer'],
        ],
    ));

    return $browser;
}

try {
    // Use the page's own field names.
    $browser = logIn($solver, PAGE_URL, ['username' => 'YOUR_USERNAME', 'password' => 'YOUR_PASSWORD']);
    // A redirect to the account page means it worked; $browser keeps that session.
    $status = $browser->getInternalResponse()->getStatusCode();
    echo $status, ' ', $browser->getHistory()->current()->getUri(), PHP_EOL;
    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 defaultTimeout' . PHP_EOL);
    exit(1);
} catch (CapSkipError $e) {
    fwrite(STDERR, 'solve failed: ' . get_class($e) . ": {$e->getMessage()}" . PHP_EOL);
    exit(1);
} catch (RuntimeException $e) {
    // The page itself, or the network on the way to it.
    fwrite(STDERR, get_class($e) . ": {$e->getMessage()}" . PHP_EOL);
    exit(1);
}

这几个 catch 块是按两类异常来安排的。CapSkip 的异常不继承 RuntimeException,而 HTTP 客户端的传输错误继承它。所以站点宕机时会落进最后一个 catch 块。识别工具没有运行时会落进第三个,而在识别中途停止的识别工具,则会以 TimeoutException 的形式落进第二个。缺少 Mime 组件时,BrowserKit 会抛出 LogicException,这里有意不捕获它:这是一次修好就行的安装配置错误,不是要在运行时处理的情况。如果提交返回 4xx 或 5xx,脚本以 1 退出。不过,单凭一个 200 并不能证明登录成功,因为很多站点会对失败的登录再返回一次表单。要检查最终落到的 URL,或者页面本身。这个方法背后的原始接口,详见 API 参考文档的 Capy 部分.

同时运行大量登录流程

PHP 一次只处理一个请求,每次 capy() 调用都会阻塞两秒左右。想同时运行更多登录,最简单的办法是开更多进程:每个账号运行一次脚本,各自使用自己的 HttpBrowser。绝不要让多个登录共用一个浏览器实例。它只有一个 cookie jar 和一份浏览历史,所以两个会话的 cookie、CSRF token 和 Referer 会混在一起。同时运行的不要超过 10 个,因为 CapSkip 默认同时识别 10 个 Capy 拼图,其余的会排队等待空闲线程。

如果想在一个进程里完成,就用 SDK 的 send() 方法提交多个任务,再用 getResult() 收取结果,这也是 Friendly Captcha PHP 指南 搭建并发池时所用的模式。对于 Capy,send() 接受值为 capy 的 method,以及 captchakey 和 pageurl;开启 JSON 时,getResult() 会以数组形式返回那三个值。每个结果一到就立刻提交对应的答案,不要等整批完成后再提交,因为最早拿到的挑战 key 会在等最后几次识别完成时不断变旧。量大时还要加代理。每次识别都会从 Capy API 拉取一张新拼图,而从同一个地址源源不断地拉取,正是限流机制要抓的那种模式。在 CapSkip 的 Capy 部分配置代理池,或者在每次调用时传入一个 proxy 数组;代理只作用于拉取拼图这一步,也就是一次 Capy 识别唯一会发出的请求。

把识别工具跑在另一台机器上

只要脚本和 CapSkip 在同一台 Windows 电脑上,127.0.0.1 就是对的。一旦 PHP 代码挪到 VPS、容器宿主机、CI runner 或共享主机上,回环地址就指向了错误的机器。这时第一次识别就会抛出 NetworkException,消息中带有 Failed to connect。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 调用它。如果链路要经过公网,请使用静态公网 IP,并打开 API 密钥校验。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。

客户端不会自己读取环境变量。像完整示例那样,在你的代码里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY 并传给构造函数,这样同一个脚本在你的桌面机和服务器上都能运行。

常见错误及其含义

你所看到的原因修复
InvalidArgumentException: Unreachable field "capy_answer"对小组件在浏览器中才创建的字段使用 setValues()、带 values 的 submit() 或数组赋值像第 3 步那样,通过 request() 发送 getPhpValues() 加上那三个值
只在提交表单时抛出 LogicException,提示你安装 symfony/mime没有 Mime 组件,BrowserKit 无法构建请求体composer require symfony/mime
抛出 LogicException,提示你安装 CssSelector 组件用 CSS 选择器调用了 filter(),但缺少 symfony/css-selectorcomposer require symfony/css-selector
capy() 返回了全部三个值,站点却提示会话已过期提交是从一个新的浏览器实例或一次普通的 HTTP 调用发出的,没有带上页面的 cookie用获取页面的那个 HttpBrowser 发送提交
站点拒绝了答案本身答案被改动过,或者只有其中一两个值到达了表单把答案原样连同另外两个值,放在一个请求里发送
第一次成功的提交,第二次却失败挑战 key 只能用一次,而且寿命很短每次提交都重新识别,并且识别完立刻提交
某个 key 每次尝试都抛出 ApiExceptionCapy API 拒绝了这个 key,或者 api_server 指向了错误的主机重新复制 key,连前缀一起,并检查 script 标签的主机
偶尔出现包含 ERROR_CAPTCHA_UNSOLVABLE 的 ApiExceptionCapSkip 没能在那张拼图里定位到缺口重试;下一次尝试会拿到另一张拼图
还没发送任何内容就抛出 ValidationExceptionkey 或 URL 为空、传了这个方法不接受的选项(比如单次调用的 pollingInterval)、version 设成了 avatar,或者用了 CapSkip 不接受的代理类型确认 key 确实找到了,并修正或去掉错误信息中指出的那个选项
第一次调用就抛出带有 ERROR_KEY_DOES_NOT_EXIST 或 ERROR_WRONG_USER_KEY 的 ApiExceptionCapSkip 打开了 API 密钥校验,而脚本发送的是默认密钥,或者根本没有发送把 CAPSKIP_API_KEY 设为 CapSkip 中配置好的密钥
每次调用都要大约四秒轮询间隔不断翻倍,直到默认的 5 秒上限,所以两秒时就绪的答案要等到 3.75 秒那次轮询才被取回在构造函数里把 pollingInterval 设为 0.5
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip 没有在运行,或者主机或端口不对;在远程机器上,防火墙也可能丢弃连接启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式
RuntimeException: The page answered 403, not the form还没出现任何验证码,站点就拒绝了获取页面的请求从同一个地址用真实浏览器打开页面;站点可能在拦截 Symfony BrowserKit 这个 user agent

常见问题

这件事还能用 Goutte 来做吗?

可以,但没有理由这么做。Goutte 的最后一个版本 4.0.3 发布于 2023 年 4 月,Packagist 已把这个包标记为已废弃,并建议改用 symfony/browser-kit。在 4.0.3 中,它的 Client 类是 HttpBrowser 的一个已弃用子类,没有增加任何东西。把它换成 HttpBrowser 只需改一行,本指南的所有内容都照样适用。用 setValues() 填写表单的 Goutte 代码,也会因为同样的原因遇到同样的 Unreachable field 错误。

Symfony BrowserKit 这个 user agent 会导致答案被拒吗?

Capy 不会因此拒绝。Capy 的答案不绑定浏览器,CapSkip 不会为它返回 user agent,而你可以传入的那个可选 user agent,也只影响拉取拼图的那个请求。不过,HttpBrowser 确实会自称 Symfony BrowserKit,站点可能出于自己的原因拒绝它。如果遇到这种情况,就在第一个请求之前,用这个浏览器实例的 setServerParameter() 方法和 HTTP_USER_AGENT 键设置一个真实的 user agent。

能改用 Guzzle 做同样的事吗?

可以。capy 调用和那三个字段都一样。不过,除非你给它一个 cookie jar,否则 Guzzle 不会保留 cookie;它不发送 Referer,遇到 403 还会抛出异常。HttpBrowser 替你做的事,在 Guzzle 里都要你手动配置。 CaptchaFox PHP 指南 详细讲解了这套 Guzzle 配置,cookie jar 和 Referer 都包括在内。

共享主机上的 PHP 能连到识别工具吗?

可以,前提是主机商允许向你的端口发起出站连接。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,然后在你的脚本里从 CAPSKIP_HOST 读取这个地址,并传给构造函数。VPS、容器宿主机、CI runner 和共享主机都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP,并为不该访问该端口的地址添加一条 Windows Firewall 阻止规则。识别工具始终运行在你自己的硬件上,因此识别次数的计算方式没有任何变化。

简短版结论

要在 PHP 中识别 Capy Puzzle,先用一个 HttpBrowser 打开页面,从小组件脚本的 k 参数中取出 PUZZLE_ key。同时记下脚本的主机,以防它不是默认主机。用把 pollingInterval 设为 0.5 的客户端,以 key 和浏览器最终停留的 URL 调用 capy(),让它花上两秒左右。然后跳过表单对象的赋值方法:通过 request() 发送 getPhpValues() 加上 captchakey、challengekey 和 answer,原样、一起、立刻发送,并且用同一个浏览器实例发送。偶尔失手就重试,同时运行的登录不要超过 10 个,一旦脚本离开识别工具所在的机器,就切换到 Server 模式。

关于重试,最后再说一点。每次尝试都会拿到一张不同的拼图,所以遇到失手,再试一次就是正确的解决办法;而当 无限量验证码识别工具 就在你自己的机器上时,第二次尝试只多花几秒钟,而不是再多一次计费的识别。