如何在 Node.js 中用正确的 user agent 识别 CaptchaFox

要在 Node.js 中识别 CaptchaFox,就用 sitekey 和页面 URL 调用 capskip 包的 captchafox 方法,然后把它返回的 token 放进 cf-captcha-response 字段提交。发送这个提交请求时,要带上三样 fetch 不会替你加上的东西:页面设置的 cookie、一个 Referer,以及 CapSkip 随 token 一起返回的 user agent。大多数 Node 代码就栽在 user agent 上。Node 内置的 fetch 会告诉每个服务器,它的 user agent 是 node,而站点期望 token 来自签发它的那个浏览器,于是会拒绝这次提交,不管识别本身做得多好。CapSkip 从 1.4.0 版开始支持 CaptchaFox。本指南讲这几件事:读取 key、调用、提交、重试,以及同时运行多个注册流程。
你需要什么
- 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。CaptchaFox 支持就是在该版本中加入的,同时加入的还有 Friendly Captcha 和 Capy Puzzle。
- Node.js 20.18.1 或更高版本,这是 cheerio 支持的最旧版本。新项目请用 22 或 24,因为 Node 20 已于 2026 年 4 月结束生命周期。
- capskip 包 1.3.0 或更高版本,这是第一个带有 captchafox 方法的版本;另外还需要 cheerio 来读取 HTML。
- 显示小组件的那个页面的 URL。sitekey、表单字段和小组件的构建版本,全都从这个页面上获取。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用;在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两种模式的设置位置都是 连接设置.
# capskip 1.3.0 added captchafox(); cheerio reads the HTML npm install capskip cheerio
示例是使用顶层 await 的 ES 模块。把文件保存为 .mjs 扩展名,或者把 package.json 里的 type 字段设为 module,因为现在 npm init 会在那里写入 commonjs。如果你的项目是 CommonJS,capskip 包也可以用 require 加载。
第 1 步:获取页面,保留它的 cookie 并读取 sitekey
Node 的 fetch 不会在多次调用之间保留 cookie,所以等你提交表单时,页面设置的会话 cookie 早已不在了。如果站点把 CSRF token 绑定在这个会话上,就会拒绝这次提交。用 Headers.getSetCookie() 自己携带 cookie,它会把每一行 Set-Cookie 单独返回。 在 Node.js 中识别 Friendly Captcha 的指南 更详细地讲解了这个简易的 cookie jar。
// npm install capskip cheerio
import * as cheerio from "cheerio";
const PAGE_URL = "https://example.com/signup";
// fetch() keeps no cookies between calls, so carry the page's own.
const jar = new Map();
const page = await fetch(PAGE_URL);
for (const line of page.headers.getSetCookie()) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
const html = await page.text();
const $ = cheerio.load(html);sitekey 是公开的,对每个访问者都一样,按惯例以 sk_ 开头。大多数页面把它放在小组件的容器上:一个带有 captchafox 类和 data-sitekey 属性的 div。如果页面用自己的脚本构建小组件,就会改为把 key 传给 captchafox.render 调用。如果你获取到的 HTML 里两者都没有,就打开 DevTools:key 是发往 api.captchafox.com 的请求中 /captcha/ 之后的那段路径,在 MAM 页面上则是发往 mam-api.captchafox.com 的请求。
顺便检查一下加载小组件的是哪个构建版本。大多数站点从 cdn.captchafox.com 加载它,期望的是普通 token。有些平台则嵌入 s.uicdn.com/mampkg/ 下的一个包,这些站点期望的是以 MAM_ 开头的 token。MAM 页面可能把 key 放在该脚本自己的地址里,位于 ?key= 之后。同一个 CDN 还提供其他不相干的包,所以要匹配 captchafox 包的路径,而不是主机名。
// The container, matched on the whole class name.
const widget = $(".captchafox").first();
// MAM pages may carry the key in the package's script src instead.
const mamSrc = $('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]').attr("src");
const sitekey = widget.attr("data-sitekey")
?? (mamSrc && new URL(mamSrc, PAGE_URL).searchParams.get("key"))
?? html.match(/captchafox\.render\([\s\S]{0,300}?sitekey\s*:\s*["']([^"']+)/)?.[1];
const form = widget.length ? widget.closest("form") : $("form").first();
console.log(sitekey, Boolean(mamSrc)); // a MAM page needs a MAM_ tokencheerio 的类选择器匹配的是完整的类名,所以像 captchafox-wrapper 这样带有类名的外层元素不会被误选中。最后一个兜底方案读取的是以内联方式传给 render 调用的 key。如果 key 位于打包后的脚本文件里,它根本就不在 HTML 中,要靠 DevTools 才能找到。没有容器时,代码会取页面上的第一个表单。如果页面上还有搜索框或第二个表单,就改为按 id 或 action 选中注册表单。
第 2 步:调用 captchafox
在 Node.js 中识别 CaptchaFox 只需要一个方法。它接受 sitekey、页面 URL 和一个可选的选项对象;对于标准小组件,有前两个就够了。
import { CapSkip } from "capskip";
const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });
// Copied from the script tag on MAM pages.
const MAM_PACKAGE = "https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/";
const result = await solver.captchafox(sitekey, PAGE_URL, {
// undefined is dropped, so the standard widget sends nothing extra.
apiServer: mamSrc ? MAM_PACKAGE : undefined,
});
console.log(result.token.slice(0, 24)); // goes in cf-captcha-response
console.log(result.userAgent); // the browser that minted itresult.token 和 result.code 里是同一个字符串,result.captchaId 是 CapSkip 给这个识别任务分配的 id。result.userAgent 是签发该 token 的浏览器的身份,也就是 CapSkip 自己的浏览器,而不是你发送的任何内容。只有当这次识别报告了 user agent 时,它才会被设置,所以使用之前要先检查它。
页面 URL 必须是小组件真正所在的那个页面。CaptchaFox 会为每个 key 登记一份允许的域名列表,并在签发任何东西之前检查主机。其他任何域名上的页面 URL,每次尝试都会被拒绝,所以短链接、搜索结果或来自其他域名的重定向都不行。
该方法接受的选项
apiServer 是 api_server 的别名,用来指定要识别的小组件构建版本。这个方法会丢弃所有值为 undefined 或 null 的选项,所以上面那行代码可以在每次调用时都传入它。它还接受一个带 type 和 uri 键的 proxy 对象,以及 useragent;useragent 会被接受,但有意不生效,因为 CapSkip 在真实浏览器中识别,用的是该浏览器自己的身份。以秒为单位的单次调用 timeout,会在这一次识别中取代客户端的 recaptchaTimeout。轮询间隔的上限也可以按单次调用设置,但只能写成 polling_interval:驼峰写法的 pollingInterval 属于构造函数,按单次调用传入时会抛出 ValidationException。为空的 sitekey 或 URL、未知的选项,以及 HTTP、HTTPS、SOCKS5、SOCKS5H 之外的代理类型,同样会抛出 ValidationException,而且是在任何请求离开你的机器之前。
在 TypeScript 中,包自带的类型仍然把 token 描述为可选、且是给 ALTCHA 用的,所以在严格模式下,需要对 result.token 使用非空断言。每次 CaptchaFox 识别都会有这个值。
第 3 步:用签发 token 的那个 user agent 提交
在 Node.js 中识别 CaptchaFox 时,站点是否接受 token,就取决于这一步。默认情况下,Node 的 fetch 发送的 User-Agent 请求头里只有一个词:node,在 Node 20 和 Node 25 上都是如此。这不是生成该 token 的浏览器,所以要把返回的值复制到携带 token 的请求上:
// The form's own fields, hidden CSRF token included.
const body = new URLSearchParams(
form.serializeArray().map((f) => [f.name, f.value]),
);
body.set("email", "YOUR_EMAIL");
body.set("cf-captcha-response", result.token);
const headers = {
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
// Browsers send the page as Referer; strict servers want one.
referer: PAGE_URL,
};
// Without this line, fetch announces itself as user-agent: node.
if (result.userAgent) headers["user-agent"] = result.userAgent;
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers,
body,
});
console.log(res.status);serializeArray 会收集浏览器会发送的那些字段,包括隐藏的 CSRF token 和任何已勾选的复选框,所以你不必逐个点名。它不包含提交按钮,所以如果服务器会检查按钮的 name,就用 body.set 把它加上。Referer 和 cookie 同样重要。fetch 既不发送 Referer,也不发送 Origin,而有些框架(比如 Django)会拒绝两者都没有的 HTTPS 表单提交,即使 cookie 和 token 都是对的。
另外两条规则,来自 CaptchaFox 在站点一侧对 token 的校验机制。每个 token 只能验证一次,而且只在很短的时间内有效,所以要在准备好之后再识别,识别完立刻提交。另外,要把 token 当作不透明的数据:永远不要裁剪或重新编码它。有些站点是用自己的脚本把它放在 JSON 请求体里发送,所以请打开 DevTools,手动提交一次表单,然后照搬页面发送的内容。CaptchaFox 的 入门文档 展示了小组件的 HTML 标记和服务器端的校验。
第 4 步:重试、挑战类型和超时
CaptchaFox 弹出哪种挑战,不由你选择。大多数识别根本不会弹出任何挑战,因为浏览器层面的证据本身就足以过关;出现滑块拼图时,CapSkip 通常也能完成它。图片选择和音频挑战很少见,也不予识别:它们会以 ApiException 的形式返回,消息里包含 ERROR_CAPTCHA_UNSOLVABLE,而且几秒内就会返回,不会等到超时结束。重新尝试一次通常会换成别的挑战,所以值得重试,在 CapSkip 里设置或在你的代码里实现都可以。
import { ApiException } from "capskip";
async function solveCaptchaFox(sitekey, url, options = {}, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await solver.captchafox(sitekey, url, options);
} catch (err) {
// Select, audio and unreadable slide draws come back unsolvable;
// redraw. Anything else, such as a wrong API key, is final.
const redraw = err instanceof ApiException && err.message.includes("UNSOLVABLE");
if (!redraw || attempt === attempts) throw err;
}
}
}ApiException 的消息就是 CapSkip 的原始回复,所以检查代码直接从中读取错误码。有一个问题:被站点拒绝的 key(比如用在了错误的域名上)看起来也是无法识别,所以它同样会被重试。它每次尝试都在几秒内失败,而且从不成功,这正是辨认它的线索。另一种方式是 CapSkip 设置中 CaptchaFox 那一行的 Retries 设置。它的取值为 0 到 3,默认为 0。调高之后,CapSkip 会自动重试不支持的挑战、超时的尝试和浏览器错误,但从不重试被拒绝的 key。
captchafox 按客户端的 recaptchaTimeout 轮询,默认 300 秒,因为它是一个真实的浏览器会话。CapSkip 自己也有一套计时:一个识别任务最多可以等待 250 秒(Wait Timeout),等 10 个 CaptchaFox 线程(Max. Threads)中的一个空出来,单次尝试则有 150 秒(Row Timeout)。有空闲线程时,失败的尝试会以 ApiException 的形式到达你这里,远在 SDK 的 300 秒用完之前。如果排队很久,SDK 就可能先到达它的时限,在 CapSkip 仍在工作时抛出 TimeoutException。所以,当你让大量识别排队或调高 Retries 时,要传入更长的 timeout,因为每次重试都可能再多出一个 Row Timeout。SDK 在轮询时还会无视连接错误,照样继续轮询,所以如果 CapSkip 在识别中途停止,调用最终会以 TimeoutException 结束,而不是 NetworkException。
完整可运行示例
下面是放在一个文件里的完整流程。signUp 会获取页面、读取 key、带重试地完成识别,然后带上页面的 cookie、Referer 和签发 token 的那个 user agent 提交表单。
// npm install capskip cheerio
// signup.mjs: run with node signup.mjs
import * as cheerio from "cheerio";
import {
CapSkip, ApiException, CapSkipError, TimeoutException, ValidationException,
} from "capskip";
const PAGE_URL = "https://example.com/signup";
// Copied from the script tag on MAM pages.
const MAM_PACKAGE = "https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/";
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY ?? "capskip",
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});
async function solveCaptchaFox(sitekey, url, options = {}, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await solver.captchafox(sitekey, url, options);
} catch (err) {
// Select, audio and unreadable slide draws come back unsolvable.
const redraw = err instanceof ApiException && err.message.includes("UNSOLVABLE");
if (!redraw || attempt === attempts) throw err;
console.log(`attempt ${attempt}: ${err.message}`);
}
}
}
async function signUp(pageUrl, email) {
// One jar per run, so runs side by side never share a session.
const jar = new Map();
const page = await fetch(pageUrl);
for (const line of page.headers.getSetCookie()) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
const html = await page.text();
const $ = cheerio.load(html);
const widget = $(".captchafox").first();
const mamSrc = $('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]').attr("src");
const sitekey = widget.attr("data-sitekey")
?? (mamSrc && new URL(mamSrc, pageUrl).searchParams.get("key"))
?? html.match(/captchafox\.render\([\s\S]{0,300}?sitekey\s*:\s*["']([^"']+)/)?.[1];
if (!sitekey) throw new Error("No sitekey in the HTML; find it in DevTools.");
const form = widget.length ? widget.closest("form") : $("form").first();
const result = await solveCaptchaFox(sitekey, pageUrl, {
apiServer: mamSrc ? MAM_PACKAGE : undefined,
});
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("cf-captcha-response", result.token);
const headers = {
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
referer: pageUrl,
};
if (result.userAgent) headers["user-agent"] = result.userAgent;
const action = new URL(form.attr("action") || pageUrl, pageUrl);
return fetch(action, { method: "POST", headers, body });
}
try {
const res = await signUp(PAGE_URL, "YOUR_EMAIL");
console.log(res.status, (await res.text()).slice(0, 80));
} catch (err) {
if (err instanceof ValidationException) console.error(`not sent: ${err.message}`);
else if (err instanceof TimeoutException) console.error("gave up after recaptchaTimeout");
else if (err instanceof CapSkipError) console.error(`solve failed: ${err.name}: ${err.message}`);
else throw err;
process.exitCode = 1;
}如果每次运行都在几秒内失败,先检查页面 URL,因为在登记域名之外使用的 key,每次都会以同样的方式失败。如果 sitekey 检查抛出错误,说明页面是用打包后的脚本构建小组件的,key 在 Network 标签页里,具体见第 1 步。这个方法背后的原始接口,详见 API 参考中的 CaptchaFox 部分.
并行运行多个注册流程
在 Node 中,CapSkip 的每个方法都返回 Promise,而 AsyncCapSkip 只是同一个类的另一个名字,所以并行运行多个识别就是普通的 JavaScript。你需要的是一个上限。CapSkip 默认同时运行 10 个 CaptchaFox 识别,其余的要等待空闲线程,所以一个始终保持十个任务在进行中的小并发池正好与之匹配。用它替换完整示例末尾的 try 代码块:
// At most `limit` calls in flight; a failure becomes that item's Error.
async function mapLimited(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]).catch((err) => err);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const emails = ["YOUR_EMAIL_1", "YOUR_EMAIL_2", "YOUR_EMAIL_3"];
const results = await mapLimited(emails, 10, (email) => signUp(PAGE_URL, email));
for (const r of results) console.log(r instanceof Error ? r.message : r.status);signUp 就是完整示例里的那个函数。它每次调用都会建立自己的 cookie jar,这正是它能安全并行的原因:如果十次运行共用一个 jar,它们的会话和 CSRF token 就会混在一起。它还会让每个 token 和它自己的 user agent 待在一起,直到提交请求发出。想要更大的并发池,就在 CapSkip 的 CaptchaFox 设置中调高 Max. Threads。CaptchaFox 不仅给浏览器打分,也给网络打分,所以长时间从同一个地址大量识别,会推高挑战率。用同一行里的 Configure Proxies 按钮设置代理,或者在每次调用时传入一个 proxy 对象。
把识别工具跑在另一台机器上
只要脚本和 CapSkip 在同一台 Windows 电脑上,127.0.0.1 就是对的。一旦 Node 代码挪到 VPS、容器、CI runner 或 serverless 函数上,回环地址就指向了错误的机器,第一次识别就会抛出带 ECONNREFUSED 的 NetworkException。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 调用它。如果链路要经过公网,请使用静态公网 IP,打开 API 密钥校验,并用一条 Windows Firewall 规则把端口限制在你预期的地址上。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
客户端不会自己读取环境变量。像完整示例那样,在你的代码里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY 并传给构造函数,这样同一个脚本在你的桌面机和服务器上都能运行。
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| CapSkip 返回识别成功的 token 被站点拒绝 | 提交请求带着 fetch 的默认值 user-agent: node 发出,而不是以签发 token 的那个浏览器的身份发出 | 在该请求上把 result.userAgent 作为 User-Agent 请求头发送 |
| user agent 一致,却仍被拒绝 | 页面加载的是 MAM 包,识别用的却是默认小组件,或者反过来 | 读取 script 标签,并把 apiServer 设成与之匹配 |
| token 和 user agent 都有效,提交却返回 403 | 没有发送会话 cookie,导致 CSRF 字段对不上;或者提交请求没有带 Referer | 带上获取页面时拿到的 cookie,并把页面 URL 作为 Referer 发送 |
| result.userAgent 为 undefined | 本次识别没有报告 user agent,所以 fetch 发送的是它自己的 | 保留那个 if 检查;如果站点拒绝 node,就自己发送一个当前桌面浏览器的 user agent |
| 某个 key 每次都在几秒内抛出 ApiException | 页面 URL 不在该 key 登记的域名之内,或者 key 本身就不对 | 发送小组件所在的页面,而不是重定向地址或搜索结果,并重新核对 key |
| 偶尔出现包含 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException | CaptchaFox 弹出了图片选择或音频挑战,或者 CapSkip 读不懂的滑块 | 在代码里重试,或者在 CapSkip 的 CaptchaFox 设置中调高 Retries |
| 第一次能用的 token,第二次失败 | 每个 token 只能验证一次,而且很快过期 | 每次提交都识别一个新的 token,并且识别完立刻提交 |
| 还没发送任何内容就抛出 ValidationException | sitekey 或 URL 为空、传了该方法不接受的选项(比如单次调用的 pollingInterval),或者代理类型不被 CapSkip 接受 | 确认 sitekey 已经找到,并修正或去掉错误信息中指出的那个选项 |
| 五分钟后抛出 TimeoutException | 等待空闲 CaptchaFox 线程的时间加上识别本身,超过了 SDK 的 300 秒,或者 CapSkip 在识别中途停止了 | 把并发池的上限设为 Max. Threads,或者为这次调用传入更长的 timeout |
| 第一次调用就抛出带 ECONNREFUSED 或 ETIMEDOUT 的 NetworkException | CapSkip 没有在运行、主机或端口不对,或者防火墙丢弃了连接 | 启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式 |
常见问题
axios 或 got 能避开 user agent 问题吗?
不能。它们只是换了个名字。axios 发送的是 axios 加上它的版本号,got 发送的是 got 加上它的 GitHub 仓库地址。除非你专门配置,否则两者都不会保留 cookie,也不会发送 Referer,所以同样需要那三个请求头。不管用哪个客户端,都要在携带 token 的请求上,用 result.userAgent 设置 user agent。
我可以改为在 Puppeteer 或 Playwright 中使用这个 token 吗?
可以,只要提交它的页面使用的是 CapSkip 返回的 user agent。先识别,然后用 browser.newContext({ userAgent: ua }) 打开一个 Playwright context,或者在 Puppeteer 中于页面加载之前调用 page.setUserAgent(ua)。加载表单,把 token 写入 cf-captcha-response 字段并提交。如果你的自动化流程在其他方面并不需要浏览器,上面的 fetch 方案更轻量。
这和 Python 版本有什么不同?
调用和规则都一样,主要的陷阱也一样:HTTP 客户端会把自己的名字当作 user agent 发送。在 Python 中,这个名字是 python-requests 或 python-httpx,而 requests.Session 会替你保留 cookie。在 Node 中,这个名字是 node,cookie 要你自己携带。Python 这一侧的内容见 在 Python 中识别 CaptchaFox 的指南.
简短版结论
要在 Node.js 中识别 CaptchaFox,先获取页面并保留它的 cookie,从 captchafox 容器、render 调用或 MAM 脚本中读取 sitekey,并留意页面加载的是哪个构建版本。用 sitekey 和真实的页面 URL 调用 solver.captchafox(),只有 MAM 页面才加上 apiServer。把 result.token 放进 cf-captcha-response 提交,同时带上页面的 cookie,以页面作为 Referer,以 result.userAgent 作为 User-Agent 请求头,只提交一次,并且立刻提交。遇到无法识别的挑战就重试,同时进行的识别不要超过 Max. Threads(默认 10 个),一旦脚本离开识别工具所在的机器,就切换到 Server 模式。
- CaptchaFox 如何运作、识别工具覆盖哪些内容: CaptchaFox 识别页面.
- Node.js 包能处理的所有验证码类型: Node.js 验证码识别页面.
让 CaptchaFox 换一个挑战,通常是这个类型唯一需要的重试;而当 无限量验证码识别工具 就运行在你自己的机器上时,每次换挑战只需几秒钟,而不是又一次计费的识别。
