如何在 PHP 的同步请求中识别 ALTCHA

在 PHP 中识别 ALTCHA 只需要一次调用,不需要浏览器。ALTCHA 属于工作量证明,而不是图像辨认:站点下发一个挑战,客户端必须不断做哈希运算,直到找到满足条件的计数器。没有任何东西需要去看,所以既不涉及 WebDriver,也不涉及无头浏览器,答案是算出来的,而不是猜出来的。CapSkip 在 1.2.6 版本中加入了这个类型,PHP 包用一个方法就暴露了它。在四个 SDK 里,PHP 是唯一完全没有并发能力的那个,这也决定了本文的重点:调用期间会阻塞你的请求,所以要做对的事情是确保 PHP 自身的各种限制不会在识别工具给出答案之前就掐断请求。
你需要什么
- 在 Windows 机器上运行的 CapSkip 1.2.6 或更高版本。ALTCHA 支持就是在该版本中加入的。
- PHP 8.0 或更新版本,并启用 curl 和 json 扩展,大多数安装包都自带这两个扩展。这个包没有其他运行时依赖,所以放进普通脚本、Laravel 或 Symfony 都一样能用。
- 小组件所在页面的 URL,以及小组件索取挑战的端点。
- 识别工具的地址。Local 模式只在本机的 127.0.0.1 上响应;Server 模式则监听你的网络地址或公网 IP,让另一台机器也能访问。第 4 步会说明该用哪一种,两者都位于 连接设置.
# composer require capskip/capskip composer require capskip/capskip
第 1 步:识别调用,以及挑战从哪里来
一个方法,两个参数:页面 URL,然后是携带挑战的选项数组。把端点交给它,CapSkip 就会自己去抓取挑战。
// composer require capskip/capskip
require 'vendor/autoload.php';
use CapSkip\CapSkip;
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
// CapSkip fetches the challenge, then hashes until the counter fits.
$result = $solver->altcha('https://example.com/signup', [
'challenge_url' => 'https://example.com/altcha/challenge',
]);
echo $result['token']; // base64 payload for the form field
echo $result['number']; // the counter that satisfied it返回数组里有两个键是 ALTCHA 独有的。token 是表单需要的 base64 载荷,number 则是解开挑战的那个计数器。code 键装的是和 token 相同的字符串,所以用哪个都行,但 token 的名字对应它要填进去的表单字段,在调用处读起来更清楚。极验(GeeTest)的键和 Turnstile 的 user agent 在这里不会出现。
number 值得记进日志。尽管两代 ALTCHA 的载荷结构不同,这个值在两代里都会被报告:旧版 token 把计数器放在顶层,而工作量证明 v2 的 token 不这样做,而是把它放在一个 solution 对象里。CapSkip 会从服务器自身响应里的 solution 对象中把它读出来,所以两代的报告方式完全一致。
找出小组件索取挑战的端点
打开开发者工具,切到 Network 标签页,然后重新加载小组件所在的页面。小组件会发出一个索取挑战的请求,路径里通常带有 altcha。那个请求 URL 就是你要传的值,而它返回的 JSON 就是挑战文档,你也可以改传这个文档。
不要去猜指定它的那个属性名,因为它在不同的小组件版本之间变过。请直接看页面源码。
| 小组件版本 | 指定挑战的属性 |
|---|---|
| v1 和 v2 | 接口地址用 challengeurl,内联挑战另有一个 challengejson 属性 |
| v3 及以后 | challenge,同一个属性既可以放 URL,也可以放挑战数据 |
三种显示样式(native、checkbox 和 switch)纯粹是视觉上的差别。它们提交的是同样的载荷,这点差别根本不会传到识别工具那里,所以你不必去分辨自己面对的是哪一种。这些属性的说明见 ALTCHA 官方集成指南.
改为直接传入挑战文档
如果你的代码已经抓到了挑战,直接传挑战文档,就完全不会发生网络请求。当挑战是内嵌在页面里而不是来自某个端点时,或者抓取它需要你的脚本才有、而识别工具没有的 cookie 时,就该走这条路径。
// No fetch happens: the document is already here.
$result = $solver->altcha('https://example.com/signup', [
'challenge_json' => [
'algorithm' => 'SHA-256',
'challenge' => 'YOUR_CHALLENGE_HASH',
'salt' => 'YOUR_SALT',
'signature' => 'YOUR_SIGNATURE',
'maxnumber' => 1000000,
],
]);这个选项接受一个数组,序列化的工作由它替你完成,如果你手上已经有 JSON 字符串,也可以直接传字符串。端点和文档同时传是允许的,此时内联的文档优先,因为再去抓取也只是把你刚提供的东西重新取一遍。不过在高负载下,这两条路径的表现并不一样。已经过期的内联挑战会被直接拒绝,而不是白白去做哈希;而传端点则允许识别工具在第一个挑战排队期间失效之后,再抓一个新的挑战。
识别工具支持哪些算法
同一个方法可以处理两代方案。旧版方案支持 SHA-1、SHA-256、SHA-384 和 SHA-512,工作量证明 v2 支持 PBKDF2 和迭代式 SHA。PBKDF2 是 ALTCHA 官方推荐的默认算法,因此这已经覆盖了线上绝大多数站点。
Argon2id 和 scrypt 是例外,它们会被直接拒绝而不是尝试:用到其中任何一个的任务会在大约三分之一秒内返回 ERROR_CAPTCHA_UNSOLVABLE,并且永远不会重试。这是有意为之。内存密集型函数不是靠重试能解决的问题,所以立刻失败好过假装在忙。对 ALTCHA 来说,这个结果指向的是算法本身,而不是一张看不清的图片,而且这个错误代码还有 一篇专门的指南.
第 2 步:让识别过程留在执行时间限制之内
这是 PHP 特有的部分,也是最容易造成令人困惑的失败的一环。调用会阻塞。PHP 在这里没有后台任务,包里的异步客户端也只是为了和其他 SDK 保持一致而留下的别名,所以识别工具干活的时候,你的请求就一直在那里等着。现在有两个计时器在互相赛跑,而它们失败的方式截然不同。
一次顺利的 ALTCHA 识别只要几毫秒,所以正常运行时两个计时器都无所谓。它们在出问题时才要紧:识别工具正忙于一堆排队的 reCAPTCHA 任务,调用就只能等。客户端对 ALTCHA 的上限是 120 秒的默认轮询超时,ALTCHA 走的是这个超时而不是更长的 reCAPTCHA 超时,因为它是 CPU 运算,不是浏览器会话。
| 构造函数选项 | 默认值 | 它的适用范围 |
|---|---|---|
| defaultTimeout | 120 秒 | ALTCHA 与图片验证码的轮询 |
| recaptchaTimeout | 300 秒 | reCAPTCHA、Turnstile 与极验(GeeTest)的轮询 |
| pollingInterval | 最长 5 秒 | 轮询从 0.25 秒开始,逐步退避到这个值 |
与之相对,PHP 自身的 max_execution_time 在 Web 请求中默认是 30 秒,在命令行下则是 0,也就是没有限制。它会不会在识别过程中触发要看平台,这正是很多人栽跟头的地方。在类 Unix 系统上,它不计算脚本等待套接字的时间,所以长时间等待识别工具可能完全绕过它。在 Windows 上,同一个设置是按真实时间计的,所以它会触发。无论哪种情况,它上面还有别的上限并不理会这些:PHP-FPM 有 request_terminate_timeout,前面的 Web 服务器也有自己的读取超时。
真正要紧的区别在于谁先赢、你会得到什么。如果先到的是 SDK 的超时,你会收到一个 TimeoutException,你的 catch 块可以处理它并转成一个像样的响应。如果赢的是另外两个中的任何一个,脚本会被直接杀掉,没有 catch 块会运行。这两者之间也有区别:PHP 自身的限制会以一个致命错误结束请求,这个错误会记进你的错误日志,你注册的收尾函数也照样会执行;而进程管理器或 Web 服务器杀掉 worker 时,什么都不会运行,留给访客的只有一个 502 或 504,里面没有任何有用的信息。所以要有意识地把客户端的上限设在那些会掐断请求的限制之下。
// Keep the client's ceiling under whatever kills the request.
$solver = new CapSkip([
'host' => '127.0.0.1',
'port' => 8080,
'defaultTimeout' => 20, // ALTCHA and image CAPTCHA polling
]);对一个通常几毫秒就结束的类型来说,20 秒已经很宽裕了,而且在默认 30 秒的 Web 限制之下还给请求的其余部分留出了余地。在没有执行时间限制的命令行下,保持默认值就行。如果某次识别经常逼近这些数字中的任何一个,问题就不在超时,而在于识别工具连不上或者已经饱和,把上限调高只会让请求挂得更久才给出同样的答案。
第 3 步:在 token 过期之前,把它原样回传
小组件把它的载荷提交在一个名为 altcha 的表单字段里,所以你的 token 也要填到那里。这一步最容易在无声无息中出错。
// Send it exactly as it came back: no trimming,
// no re-encoding, no reordering.
$body = http_build_query([
'email' => '[email protected]',
'altcha' => $result['token'],
]);
$ch = curl_init('https://example.com/signup');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);token 是一份 JSON 文档的 base64 编码,文档里的字段都被服务器自己的 HMAC 签名覆盖。任何改动都会让它失效,所以一切看起来像是在整理格式的操作都会让提交失败:去掉空白、解码后重新编码,或者按不同的键顺序重建 JSON。要当心框架里那些好意的输入过滤器,因为对外发出的表单数据一旦被净化处理,很可能被抹掉一个字符,留给你一份再也对不上签名的载荷。有些集成是从 JSON 请求体的字段里读取载荷,而不是表单字段,所以先看看页面自己的提交发的是什么,照着做。
这一步失败的另一种方式是时机。挑战窗口很短,有些站点两分钟内就会关闭它。挑战一旦过期,站点会以一句干巴巴的验证失败拒绝你的答案,看起来和答错一模一样,响应里也没有任何东西能告诉你到底是哪一种。三个习惯可以避免它:在识别之前立刻抓取挑战,而不是在长流程一开始就抓;在完成识别的那个请求里就把 token 提交掉;永远不要把 token 留在会话里,一边等人把表单填完。
第 4 步:识别工具跑在哪里,以及这需要哪种连接模式
上面的示例用的是 127.0.0.1,因为当 PHP 和识别工具在同一台机器上时,这个地址是对的。可一旦代码跑在别的地方,比如容器、Web 主机、VPS 或 CI 运行器,回环地址就不再指向识别工具,第一次识别就会抛出 NetworkException。
把 CapSkip 切到 Server 模式,它就会改为监听你的网络地址或公网 IP,上面那些环境都能通过同一套 HTTP API 访问它。如果链路要走公网,建议用固定公网 IP,并配一条只放行你预期地址的防火墙规则。Server 模式只改变识别工具在哪里监听,其他什么都不变:硬件依然是你自己的,也依然不限量。把主机和端口从环境变量里读出来,同一套部署就能在两种场景下都跑通。客户端不会自己读取 CAPSKIP_HOST 或 CAPSKIP_PORT,所以要把它们传给构造函数,就像下面的完整示例那样。
| PHP 跑在哪里 | 用哪种连接模式 |
|---|---|
| 在 CapSkip 所在的机器上,在本地开发服务器或 CLI 脚本里 | Local 模式。127.0.0.1 在这里确实是对的 |
| 在同一内网的另一台机器上 | Server 模式,使用那台机器的内网地址 |
| 在虚拟主机、VPS 或容器平台上 | Server mode,配一个固定公网 IP 加一条防火墙规则 |
关于代理,有一点是 ALTCHA 特有的。这里支持代理,但它只用于获取挑战那一次请求。没有浏览器会话需要转发,所以代理对工作量证明本身没有任何影响。
完整可运行示例
// composer require capskip/capskip
require 'vendor/autoload.php';
use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;
$solver = new CapSkip([
'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
'defaultTimeout' => 20,
]);
try {
// Fetch, solve and submit inside the one request.
$result = $solver->altcha('https://example.com/signup', [
'challenge_url' => 'https://example.com/altcha/challenge',
]);
$body = http_build_query([
'email' => '[email protected]',
'altcha' => $result['token'],
]);
// POST $body to the form here, while the challenge is still fresh.
echo 'solved at counter ' . $result['number'];
} catch (ApiException $e) {
// ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
echo 'refused: ' . $e->getMessage();
} catch (TimeoutException $e) {
echo 'gave up waiting, before anything could kill the request';
} catch (NetworkException $e) {
echo 'solver unreachable: check the host and the connection mode';
}这四个异常都继承自同一个基类,所以改为捕获基类,就能在一个块里处理 SDK 可能抛出的全部失败。响应需要区分时就像上面那样捕获具体异常,不需要区分时捕获基类即可。
其他类型是同样的形态,只是换一个方法。reCAPTCHA 的调用接收一个 sitekey 和一个页面 URL,Turnstile 也是一样,极验(GeeTest)则是在页面 URL 之外再接收一个 gt 值和一个 challenge,图片验证码识别接收的是文件路径、URL 或 base64。完整的方法列表见 PHP 验证码识别工具页面.
Turnstile 是唯一一种在以完整挑战页形式出现时、光有 sitekey 还不够的类型。它需要的额外值见 PHP Turnstile 指南.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 出现 502 或 504,日志里什么都没有,也没有 catch 块运行 | 在 SDK 放弃之前,进程管理器或 Web 服务器就杀掉了请求 | 把 defaultTimeout 设得低于 FPM 的终止超时和前面 Web 服务器的读取超时 |
| PHP 日志里出现一条超出最大执行时间的致命错误,也没有 catch 块运行 | PHP 自身的限制先一步结束了请求 | 把 defaultTimeout 设得低于 max_execution_time |
| 抛出 TimeoutException,并指出它等待了多少秒 | 识别工具没有在客户端设定的上限内作出响应 | 检查识别工具是否在运行且没有饱和。调高上限只会让同样的结果来得更晚 |
| 站点返回一句干巴巴的验证失败,而 token 看起来没问题 | 挑战在表单提交之前就已过期 | 在同一个请求里完成抓取、识别和提交 |
| 大约三分之一秒后,在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE | 该挑战使用了 Argon2id 或 scrypt | 没什么可重试的。这两种算法是按设计直接拒绝的 |
| 调用时抛出 ValidationException | 两个挑战选项都没有提供,或者传入了 ALTCHA 不接受的选项 | 传挑战接口地址或挑战文档,其余的一律去掉 |
| 第一次识别时抛出 NetworkException | CapSkip 没有在运行,或者主机和端口不对 | 启动 CapSkip,然后判断它该用 Local 模式还是 Server 模式 |
| 数组里没有 token 这个键 | 该键只有 ALTCHA 才会填充 | 调用 ALTCHA 方法。在 ALTCHA 结果里,code 键装的是同一个字符串 |
| 日志显示已经识别成功的 token 却被表单拒绝 | 某个环节对载荷做了重新编码、裁剪或键顺序调整 | 把字符串原样直接传过去,不要碰它 |
常见问题
在 PHP 中识别 ALTCHA 需要浏览器吗?
不需要,这正是它很适合 PHP 的原因。ALTCHA 给出的是一道哈希题,而不是一样需要去看的东西,所以整个工作只消耗 CPU,几毫秒就能完成。不用安装 WebDriver,也不用在 Web 服务器旁边一直养着一个 Chromium,而这恰恰是浏览器驱动类验证码在 PHP 里最难受的地方。一个用 curl 的普通脚本就够了。
跑在虚拟主机或 VPS 上的 PHP 能连到识别工具吗?
可以。在连接设置里把 CapSkip 切到 Server 模式,让它监听网络地址而不是回环地址,然后把主机环境变量指向那个地址。虚拟主机、VPS、容器平台和 CI 运行器都用同样的方式、同一套 HTTP API 连接。如果链路要跨公网,就用固定公网 IP,并用防火墙规则做限制。在上述每一种情况里,识别工具都依然运行在你自己的硬件上,所以授权和识别次数都不会有任何变化。
在 PHP 里能同时识别多个 ALTCHA 挑战吗?
在一个脚本里不行。PHP 客户端是同步的,包里那个异步名字只是为了让四个 SDK 读起来一致而保留的别名,并不是第二套实现,所以调用是一个接一个跑的。这里说的并发指的是跑多个 worker 进程,PHP 一般也是这么做的。对这个类型来说这点很少要紧,因为一次识别不过是几毫秒的哈希运算,但在围绕它规划批量任务之前,还是值得先知道。
该在 Web 请求中识别,还是放进队列任务里?
当提交就发生在同一个请求里时(这也是常见情况),就在请求中识别,因为挑战窗口很短,放进队列只会平白增加延迟。如果周边的工作本来就是异步的,比如一个要走很多页面的爬虫,那就把它挪到 worker 里。绝对不能做的是把两者拆开:在一个请求里抓挑战,再在之后的任务里识别,这种安排最容易把一个已经过期的答案交给站点。
简短版结论
从小组件上读出挑战端点,把它连同页面 URL 一起传给那个唯一的 ALTCHA 方法,然后原封不动地把 token 回填到名为 altcha 的字段里提交。把客户端的默认超时设在那些会先杀掉请求的限制之下,因为一个你能捕获的 TimeoutException,价值远远高于一个被进程管理器直接终止的请求。把抓取、识别和提交放在同一个请求里,因为挑战窗口两分钟内就可能关闭,而过期的挑战看起来和答错一模一样。只要 PHP 不再和识别工具共处一台机器,就立刻切到 Server 模式。
- 挑战是什么、这个类型怎么运作: ALTCHA 验证码识别页面.
- PHP 包提供的其他全部方法: PHP 识别工具页面.
最后还有一点会影响你设计重试的方式。因为这种 验证码绕过 是在你本来就拥有的机器上计算工作量证明,重试一个已过期的挑战只花掉你自己 CPU 的几毫秒,除此之外没有任何代价,所以你完全负担得起去取一个新的挑战,而不必守着一个已经失效的。
