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

solve captchafox in node.js - How to Solve CaptchaFox in Node.js With the Right User Agent

要在 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_ token

cheerio 的类选择器匹配的是完整的类名,所以像 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 it

result.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 的 ApiExceptionCaptchaFox 弹出了图片选择或音频挑战,或者 CapSkip 读不懂的滑块在代码里重试,或者在 CapSkip 的 CaptchaFox 设置中调高 Retries
第一次能用的 token,第二次失败每个 token 只能验证一次,而且很快过期每次提交都识别一个新的 token,并且识别完立刻提交
还没发送任何内容就抛出 ValidationExceptionsitekey 或 URL 为空、传了该方法不接受的选项(比如单次调用的 pollingInterval),或者代理类型不被 CapSkip 接受确认 sitekey 已经找到,并修正或去掉错误信息中指出的那个选项
五分钟后抛出 TimeoutException等待空闲 CaptchaFox 线程的时间加上识别本身,超过了 SDK 的 300 秒,或者 CapSkip 在识别中途停止了把并发池的上限设为 Max. Threads,或者为这次调用传入更长的 timeout
第一次调用就抛出带 ECONNREFUSED 或 ETIMEDOUT 的 NetworkExceptionCapSkip 没有在运行、主机或端口不对,或者防火墙丢弃了连接启动 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 换一个挑战,通常是这个类型唯一需要的重试;而当 无限量验证码识别工具 就运行在你自己的机器上时,每次换挑战只需几秒钟,而不是又一次计费的识别。