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

solve altcha in node.js - How to Solve ALTCHA in Node.js and Submit It with 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 超时。

构造函数选项默认值它的适用范围
defaultTimeout120 秒ALTCHA 与图片验证码的轮询
recaptchaTimeout300 秒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 不接受的选项传挑战接口地址或挑战文档,其余的一律去掉
第一次识别时抛出 NetworkExceptionCapSkip 没有在运行,或者主机和端口不对启动 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 模式。

最后还有一点会影响你设计重试的方式。因为 无限量验证码识别工具 是在你本来就拥有的机器上计算工作量证明,重试一个已过期的挑战只花掉你自己 CPU 的几毫秒,除此之外没有任何代价,所以你完全负担得起去取一个新的挑战,而不必守着一个已经失效的。