如何在 Node.js 中识别 ALTCHA 并用 Fetch 提交

在 Node.js 中识别 ALTCHA 只需要一次调用,整个技术栈里都不需要浏览器。ALTCHA 属于工作量证明,而不是图像辨认:站点下发一个挑战,客户端必须不断做哈希运算,直到找到满足条件的计数器。没有任何东西需要去看,所以既不涉及 WebDriver,也不涉及无头 Chrome 和 user agent,答案是算出来的,而不是猜出来的。CapSkip 在 1.2.6 版本中加入了这个类型,Node SDK 用一个方法就暴露了它。这让 ALTCHA 成为少见的一种验证码类型:整个流程就是一个普通的 HTTP 脚本,抓取页面、从中读出挑战、识别、把 token 回传,全部用全局 fetch 加一次 SDK 调用完成。
你需要什么
- 在 Windows 机器上运行的 CapSkip 1.2.6 或更高版本。ALTCHA 支持就是在该版本中加入的。
- Node 18 或更高版本,这是这个包的要求,下面示例中用到的全局 fetch 也来自它。TypeScript 类型定义已经内置在包里,不需要另外安装类型包。
- 小组件所在页面的 URL,以及小组件索取挑战的端点。
- 识别工具的地址。Local 模式只在本机的 127.0.0.1 上响应;Server 模式则监听你的网络地址或公网 IP,让另一台机器也能访问。第 4 步会说明该用哪一种,两者都位于 连接设置.
# npm install capskip npm install capskip
第 1 步:识别调用,以及挑战从哪里来
一个方法,两个参数:页面 URL,然后是携带挑战的选项对象。把端点交给它,CapSkip 就会自己去抓取挑战。
// npm install capskip
const { CapSkip } = require('capskip');
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });
// CapSkip fetches the challenge, then hashes until the counter fits.
const result = await solver.altcha('https://example.com/signup', {
challengeUrl: 'https://example.com/altcha/challenge',
});
console.log(result.token); // base64 payload for the form field
console.log(result.number); // the counter that satisfied it结果里有两个字段是 ALTCHA 独有的。token 是表单需要的 base64 载荷,number 则是解开挑战的那个计数器。code 字段装的是和 token 相同的字符串,所以用哪个都行,但 token 的名字对应它要填进去的表单字段,在调用处读起来更清楚。极验(GeeTest)的字段和 Turnstile 的 user agent 在这里不会出现。
这个选项名被接受的写法不止一种。challengeUrl 和 challenge_url 都指向同一个 API 参数,challengeJson 和 challenge_json 也是如此。驼峰写法是 Node 文档使用的写法,也和 SDK 其余部分保持一致,所以优先用它并保持统一;下划线别名的存在,是为了让从 PHP 或 Python 指南里复制过来的示例也能直接跑起来。
找出小组件索取挑战的端点
打开开发者工具,切到 Network 标签页,然后重新加载小组件所在的页面。小组件会发出一个索取挑战的请求,路径里通常带有 altcha。那个请求 URL 就是你要传的值,而它返回的 JSON 就是挑战文档,你也可以改传这个文档。
不要去猜指定它的那个属性名,因为它在不同的小组件版本之间变过。请直接看页面源码。
| 小组件版本 | 指定挑战的属性 |
|---|---|
| v1 和 v2 | 接口地址用 challengeurl,内联挑战另有一个 challengejson 属性 |
| v3 及以后 | challenge,同一个属性既可以放 URL,也可以放挑战数据 |
<!-- v1 and v2 name the endpoint on its own attribute --> <altcha-widget challengeurl="https://example.com/altcha/challenge"></altcha-widget> <!-- v3 and later put both forms behind one attribute --> <altcha-widget challenge="https://example.com/altcha/challenge"></altcha-widget>
三种显示样式(native、checkbox 和 switch)纯粹是视觉上的差别。它们提交的是同样的载荷,这点差别根本不会传到识别工具那里,所以你不必去分辨自己面对的是哪一种。这些属性的说明见 ALTCHA 官方集成指南.
改为直接传入挑战文档
如果你的爬虫已经从页面上读到了挑战,直接传挑战文档,就完全不会发生网络请求。
// No fetch happens: the document is already here.
const result = await solver.altcha('https://example.com/signup', {
challengeJson: {
algorithm: 'SHA-256',
challenge: 'YOUR_CHALLENGE_HASH',
salt: 'YOUR_SALT',
signature: 'YOUR_SIGNATURE',
maxnumber: 1000000,
},
});这个选项接受一个对象,序列化的工作由它替你完成,如果你手上已经有 JSON 字符串,也可以直接传字符串。端点和文档同时传是允许的,此时内联的文档优先,因为再去抓取也只是把你刚提供的东西重新取一遍。不过在高负载下,这两条路径的表现并不一样。已经过期的内联挑战会被直接拒绝,而不是白白去做哈希;而传端点则允许识别工具在第一个挑战排队期间失效之后,再抓一个新的挑战。
识别工具支持哪些算法
同一个方法就能处理两代 ALTCHA。旧版方案覆盖了 SHA-1、SHA-256、SHA-384 和 SHA-512,工作量证明 v2 则覆盖了 PBKDF2 和迭代 SHA。PBKDF2 是 ALTCHA 自己推荐的默认算法,所以覆盖到的范围已经是绝大多数线上站点。
Argon2id 和 scrypt 是例外,它们会被直接拒绝而不是尝试:用到其中任何一个的任务会在大约三分之一秒内返回 ERROR_CAPTCHA_UNSOLVABLE,并且永远不会重试。这是有意为之。内存密集型函数不是靠重试能解决的问题,所以立刻失败好过假装在忙。对 ALTCHA 来说,这个结果指向的是算法本身,而不是一张看不清的图片。
第 2 步:只用 fetch 跑完整个流程,不需要浏览器
因为没有任何东西需要渲染,那个带挑战的页面对你来说只是一份可以直接抓取的文档。这一点值得说明白,因为换成任何一种小组件类验证码,诚实的答案都少不了在某个环节用到浏览器。这里不需要。抓取页面,从标记中取出那个属性,然后直接传给识别工具。
// npm install capskip
const PAGE = 'https://example.com/signup';
// The page is only a document here: no browser, no rendering.
const html = await (await fetch(PAGE)).text();
// v1 and v2 use challengeurl; v3 and later use challenge.
const found = html.match(/(?:challengeurl|challenge)="([^"]+)"/i);
if (!found) throw new Error('no ALTCHA widget on this page');
const result = await solver.altcha(PAGE, { challengeUrl: found[1] });对一个已知页面用正则表达式没问题,对爬虫来说则是个坏主意,所以一旦你要处理的不是自己写的标记,就该换成真正的 HTML 解析器。这段示例的重点是流程形态,而不是解析本身:一个请求、一个字符串、一次识别,没有任何进程需要启动或销毁。这也是这个类型在无服务器函数或短生命周期 worker 里表现很好的原因,在那种环境下启动 Chromium 的开销会远远超过识别本身。
关于 v3 属性有一点要注意。它里面装的既可能是 URL,也可能是挑战文档本身,所以在传之前先确认你拿到的是哪一种。如果这个值以花括号而不是协议头开头,那它就是一个内联挑战,应该放进上一节讲的那个文档选项里。
在 TypeScript 中给结果标注类型
类型定义随包一起提供,不需要额外安装类型包。SDK 能识别的所有验证码类型共用一个结果类型,这意味着只属于其中某一种的字段都被声明为可选。token 和 number 是 ALTCHA 的字段,所以编译器把 token 的类型定为 string 或 undefined,不会允许你把它交给任何要求纯 string 的地方。
// npm install capskip
import { CapSkip, SolveResult, AltchaOptions } from 'capskip';
const options: AltchaOptions = { challengeUrl: found[1] };
const result: SolveResult = await solver.altcha(PAGE, options);
// One check, right after the call, and the type is settled.
if (!result.token) throw new Error('no ALTCHA token on this result');
const token: string = result.token;Turnstile 的 user agent 也会给你同样的提醒,见 Node.js Turnstile 指南,只是这里的后果更严重:缺少 user agent 会让提交被拒,而缺少 token 意味着你根本没有东西可提交。只有在你非常确定的时候才动用非空断言,因为它会让唯一能告诉你调错了方法的那道检查失声。
有一件事类型是抓不到的。选项接口带有索引签名,所以你多写的任何键都会被编译器接受。因此拼错的选项能顺利编译,却在运行时失败,因为 SDK 会拒绝一个 ALTCHA 并不接受的参数。像上面那样给选项对象标注类型,至少能校验它认识的那些键。
第 3 步:在 token 过期之前,把它原样回传
小组件把它的载荷提交在一个名为 altcha 的表单字段里,所以你的 token 也要填到那里。这一步最容易在无声无息中出错。
// Send it exactly as it came back: no trimming,
// no re-encoding, no reordering.
const response = await fetch('https://example.com/signup', {
method: 'POST',
body: new URLSearchParams({
email: '[email protected]',
altcha: token,
}),
});token 是一个 JSON 文档的 base64 编码,文档中的字段都被服务端自己的 HMAC 签名覆盖。任何改动都会让它失效,所以任何看起来像是在做整理的操作都会让提交失败:去掉空白字符、解码后再重新编码,或者按不同的键顺序重建这段 JSON。有些集成方式是从 JSON 请求体的某个字段里读取载荷,而不是从表单字段读取,所以要看清页面自身的提交发送了什么,然后照着做。
这一步失败的另一种方式是时机。挑战窗口很短,有些站点两分钟内就会关闭它。挑战一旦过期,站点会以一句干巴巴的验证失败拒绝你的答案,看起来和答错一模一样,响应里也没有任何东西能告诉你到底是哪一种。三个习惯可以避免它:在识别之前立刻抓取挑战,而不是在长流程一开始就抓;在完成识别的那个工作单元里就把 token 提交掉;永远不要一边攥着 token,一边等人把表单填完。
限制你的并不是客户端自己的轮询超时,因为挑战窗口早在这两个超时到期之前就关闭了。ALTCHA 是 CPU 运算,不是浏览器会话,所以它走的是默认轮询超时,而不是更长的 reCAPTCHA 超时。
| 构造函数选项 | 默认值 | 它的适用范围 |
|---|---|---|
| defaultTimeout | 120 秒 | ALTCHA 与图片验证码的轮询 |
| recaptchaTimeout | 300 秒 | reCAPTCHA、Turnstile 与极验(GeeTest)的轮询 |
| pollingInterval | 最长 5 秒 | 轮询从 0.25 秒开始,逐步退避到这个值 |
第 4 步:识别工具跑在哪里,以及这需要哪种连接模式
上面的示例用的是 127.0.0.1,因为当你的 Node 进程和识别工具在同一台机器上时,这个地址是对的。可一旦调用代码跑在别的地方,比如容器、CI 运行器、VPS 或托管主机,回环地址就不再指向识别工具,第一次识别就会以 NetworkException 被拒。
把 CapSkip 切到 Server 模式,它就会改为监听你的网络地址或公网 IP,上面那些环境都能通过同一套 HTTP API 访问它。如果链路要走公网,建议用固定公网 IP,并配一条只放行你预期地址的防火墙规则。Server 模式只改变识别工具在哪里监听,其他什么都不变:硬件依然是你自己的,也依然不限量。把主机和端口从环境变量里读出来,同一份构建就能在两种场景下都跑通。客户端不会自己读取 CAPSKIP_HOST 或 CAPSKIP_PORT,所以要把它们传给构造函数,就像下面的完整示例那样。
| Node 进程跑在哪里 | 用哪种连接模式 |
|---|---|
| 在 CapSkip 所在的机器上,作为脚本或本地服务器运行 | Local 模式。127.0.0.1 在这里确实是对的 |
| 在同一内网的另一台机器上 | Server 模式,使用那台机器的内网地址 |
| 在容器、VPS 或托管平台上 | Server mode,配一个固定公网 IP 加一条防火墙规则 |
关于代理,有一点是 ALTCHA 特有的。这里支持代理,但它只用于获取挑战那一次请求。没有浏览器会话需要转发,所以代理对工作量证明本身没有任何影响。
完整可运行示例
// npm install capskip
import { CapSkip, ApiException, TimeoutException, NetworkException } from 'capskip';
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST || '127.0.0.1',
port: Number(process.env.CAPSKIP_PORT || 8080),
});
export async function signUp(email: string) {
try {
// Fetch, solve and submit in one unit of work.
const result = await solver.altcha('https://example.com/signup', {
challengeUrl: 'https://example.com/altcha/challenge',
});
if (!result.token) throw new Error('not an ALTCHA result');
const response = await fetch('https://example.com/signup', {
method: 'POST',
body: new URLSearchParams({ email, altcha: result.token }),
});
console.log(response.status, 'after counter', result.number);
} catch (err) {
// ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
if (err instanceof ApiException) console.log('refused:', err.message);
else if (err instanceof TimeoutException) console.log('gave up waiting');
else if (err instanceof NetworkException) console.log('solver unreachable');
else throw err;
}
}其他类型是同样的形态,只是换一个方法。reCAPTCHA 的调用接收一个 sitekey 和一个页面 URL,Turnstile 也是一样,极验(GeeTest)则是在页面 URL 之外再接收一个 gt 值和一个 challenge,图片验证码识别接收的是文件路径、URL 或 base64。完整的方法列表见 Node.js 验证码识别页面,同样的方法在每个官方包里都有,见 SDK 页面.
Turnstile 是唯一一种在以完整挑战页形式出现时、光有 sitekey 还不够的类型。它需要的额外值见 Node.js Turnstile 指南.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 编译器拒绝 token,提示 string 或 undefined 不是 string | 所有验证码类型共用一个结果类型,所以 ALTCHA 独有的字段是可选的 | 识别之后先做一次类型收窄,再使用收窄后的值 |
| 拼错的选项能顺利编译,运行时才失败 | 选项接口带有索引签名,未知的键会被放行 | 用 ALTCHA 选项类型标注选项对象,并检查拼写 |
| 运行时读到的 token 是 undefined | 该字段只有 ALTCHA 才会填充 | 调用 ALTCHA 方法。在 ALTCHA 结果里,code 字段装的是同一个字符串 |
| 站点返回一句干巴巴的验证失败,而 token 看起来没问题 | 挑战在表单提交之前就已过期 | 在同一个工作单元里完成获取、识别和提交 |
| 大约三分之一秒后,在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE | 该挑战使用了 Argon2id 或 scrypt | 没什么可重试的。这两种算法是按设计直接拒绝的 |
| 调用时抛出 ValidationException | 两个挑战选项都没有提供,或者传入了 ALTCHA 不接受的选项 | 传挑战接口地址或挑战文档,其余的一律去掉 |
| 第一次识别时抛出 NetworkException | CapSkip 没有在运行,或者主机和端口不对 | 启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式 |
| 日志显示已经识别成功的 token 却被表单拒绝 | 某个环节对载荷做了重新编码、裁剪或键顺序调整 | 把字符串原样直接传过去,不要碰它 |
常见问题
ALTCHA 页面需要用 Puppeteer 或 Playwright 吗?
不需要,这正是它好用的地方。ALTCHA 给出的是一道哈希题,而不是一样需要去看的东西,所以整个工作只消耗 CPU,几毫秒就能完成。不涉及浏览器、WebDriver 和 user agent。一个用全局 fetch 的普通脚本就够了,这也意味着它能舒舒服服地跑在 worker、队列消费者或无服务器函数里,而在那些地方启动 Chromium 又慢又别扭。
跑在托管平台上的 Node 应用能连到识别工具吗?
可以。在连接设置里把 CapSkip 切到 Server 模式,让它监听网络地址而不是回环地址,然后把主机环境变量指向那个地址。容器、CI 运行器、VPS 和托管应用平台都用同样的方式、同一套 HTTP API 连接。如果链路要跨公网,就用固定公网 IP,并用防火墙规则做限制。在上述每一种情况里,识别工具都依然运行在你自己的硬件上,所以授权和识别次数都不会有任何变化。
异步客户端识别多个 ALTCHA 挑战会更快吗?
本身不会。在 Node 包里,异步客户端只是普通客户端的别名,不是第二套实现,所以导入它并不会改变工作的执行方式。每个方法本来就返回 Promise,所以并发来自把多个调用一起跑起来再统一 await。这样做的时候,要让每次抓取都紧挨着它自己的那次识别,因为挑战各自独立过期,提前批量抓来的挑战会在最前面几个还在做哈希时就已经失效。
一定要用 TypeScript 才能使用这个 SDK 吗?
不用。类型定义随包一起提供,你的项目读它就有,不读它也完全不碍事。普通的 CommonJS 就像第一段示例那样直接可用,唯一的区别是:可选的 token 变成了你自己写的运行时检查,而不是编译器强制要求的检查。无论哪种情况这道检查都值得写,因为 token 为 undefined 是调错方法最明确的信号。
简短版结论
从小组件上读出挑战端点,把它连同页面 URL 一起传给那个唯一的 ALTCHA 方法,然后原封不动地把 token 回填到名为 altcha 的字段里提交。在带类型的项目中,识别之后先对 token 做一次类型收窄,因为所有验证码类型共用一个结果类型,而 ALTCHA 的字段在它上面是可选的。把抓取、识别和提交放在同一段代码里,因为挑战窗口两分钟内就可能关闭,而过期的挑战看起来和答错一模一样。只要 Node 进程不再和识别工具共处一台机器,就立刻切到 Server 模式。
- 挑战是什么、这个类型怎么运作: ALTCHA 验证码识别页面.
- Node 包提供的其他全部方法: Node.js 识别工具页面.
最后还有一点会影响你设计重试的方式。因为 无限量验证码识别工具 是在你本来就拥有的机器上计算工作量证明,重试一个已过期的挑战只花掉你自己 CPU 的几毫秒,除此之外没有任何代价,所以你完全负担得起去取一个新的挑战,而不必守着一个已经失效的。
