如何在 Node.js 中不借助浏览器识别 Friendly Captcha

solve friendly captcha in node.js - How to Solve Friendly Captcha in Node.js Without a Browser

要在 Node.js 中识别 Friendly Captcha,先获取页面并保存它设置的 cookie,用 cheerio 读取 frc-captcha 小组件及其 script 标签,根据这个脚本判断是 v1 还是 v2,然后把 sitekey、页面 URL、版本和脚本地址传给 CapSkip 的 friendlyCaptcha 方法。把它返回的 token 连同表单自身的隐藏字段一起提交:v2 填在 frc-captcha-response 里,v1 填在 frc-captcha-solution 里。大多数失败都出在两件事上。内置的 fetch 不保存 cookie,所以带 CSRF 保护的表单会拒绝一个完全正确的 token。而识别错了版本,返回的 token 看起来有效,却会一声不吭地失败。CapSkip 从 1.4.0 版开始支持 Friendly Captcha,本指南从头到尾都不需要你这边有浏览器。

你需要什么

  • 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。Friendly Captcha 支持就是在该版本中加入的,同时加入的还有 CaptchaFox 和 Capy Puzzle。
  • Node.js 22 或更高版本,以及 capskip 包 1.3.0 或更高版本,这是第一个带有 friendlyCaptcha 的版本。示例还用 cheerio 来读取 HTML。
  • 显示小组件的那个页面的 URL。sitekey、脚本地址和字段名都来自该页面的 HTML。
  • 识别工具的地址。Local 模式只在 127.0.0.1 上响应,仅供本机使用;Server 模式监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两者都位于 连接设置中,第 4 步会说明何时该切换。
# npm install capskip cheerio
npm install capskip cheerio

示例是使用顶层 await 的 ES 模块。把文件保存为 .mjs 扩展名,或者把 package.json 里的 type 字段改成 module(现在 npm init 会在那里写入 commonjs),import 就能像示例那样工作。如果你的项目是 CommonJS,capskip 包也可以用 require 加载。

第 1 步:获取页面并保存它的 cookie

Node 的 fetch 是个不错的 HTTP 客户端,只是有一个缺口在这里很要紧:它没有 cookie jar。每次调用都从零开始,所以等你提交表单时,页面设置的会话 cookie 早就没了。如果站点把 CSRF token 绑定在这个会话上,它就会拒绝这次提交,常见的是返回 403,不管验证码 token 有多正确。Python 的 requests.Session 替你把这个问题挡掉了,但在 Node 里,cookie 得由你自己带着走。

要做到这一点,靠的是 Headers.getSetCookie(),它会把每一行 Set-Cookie 单独返回。普通的 headers.get 调用办不到,因为它会用逗号把这些行拼在一起,而 cookie 的过期日期里本身就带逗号。

// npm install capskip cheerio
import * as cheerio from "cheerio";

const PAGE_URL = "https://example.com/signup";

// fetch() keeps no cookies between calls, so carry them by hand.
const jar = new Map();
function remember(res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");

const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());

这个 cookie jar 是有意写得这么小的。它只保存名称和值,忽略路径和过期时间,对一个站点、一个表单来说,这就够了。

第 2 步:读取小组件,选定 v1 或 v2,并调用 friendlyCaptcha

两个版本渲染出的元素完全相同:一个带有 frc-captcha 类和 data-sitekey 属性的 div,所以光看小组件本身,看不出用的是哪套协议。script 标签能告诉你。v2 站点加载的是 @friendlycaptcha/sdk 包,文件名是 site.min.js;v1 站点加载的是 friendly-challenge,文件名是 widget.module.min.js。用 Cheerio 几行代码就能把两者都取出来:

const widget = $(".frc-captcha").first();
const form = widget.closest("form");

// Every script except nomodule fallbacks, as full addresses.
const scripts = $("script[src]").not("[nomodule]")
  .map((_, el) => new URL($(el).attr("src"), PAGE_URL).href)
  .get();

对于自行托管的小组件,把页面 URL 作为基准传进去很重要。它会把 /vendor/v2/site.min.js 这样的相对 src 变成完整地址,而在 v2 上,CapSkip 会在自己的浏览器里加载这个确切的脚本来完成识别。裸路径永远加载不出来。

CapSkip 按固定顺序选定版本,拿到第一个答案就停下:先看你传入的版本,再看你作为 moduleScript 传入的脚本地址,最后默认为 v1。陷阱就在这个默认值上。像 /assets/app.4f2a.js 这样的打包脚本什么也告诉不了 CapSkip,于是它按 v1 识别,而 v2 站点会拒绝每一个 token。所以要改在你自己的代码里判定,在那里你可以拒绝去猜:

const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];

function friendlyVersion(scripts, widget) {
  // Package names are unambiguous, so check them first.
  for (const src of scripts) {
    if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
    if (src.includes("friendly-challenge")) return { version: "v1", script: src };
  }
  // Self-hosted builds usually keep the file name. Themes ship their own
  // site.min.js too, so only trust a path that says friendly.
  for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
    const file = new URL(src).pathname.split("/").pop();
    if (V2_FILES.includes(file)) return { version: "v2", script: src };
    if (V1_FILES.includes(file)) return { version: "v1", script: src };
  }
  // Last resort: v2 and v1 name their widget options differently.
  if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
  if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
  throw new Error("v1 or v2? Read the page and set it by hand.");
}

按文件名判断时,只信任路径里提到 friendly 的那些,因为主题可能会加载一个它自己的 site.min.js,跟小组件毫无关系。

然后发起调用。friendlyCaptcha 接受 sitekey、页面 URL 和一个选项对象。SDK 在发送任何内容之前会丢掉值为 undefined 的项,所以页面上没有的属性就不会出现在请求里:

import { CapSkip } from "capskip";

const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });
const { version, script } = friendlyVersion(scripts, widget);

const result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
  version,
  moduleScript: script,
  // EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
  apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
});

console.log(result.token.slice(0, 40));   // v2 tokens start with AQQA.

apiServer 这一行用于使用 Friendly Captcha EU 端点的站点。全球端点照样会为 EU 的 sitekey 签发 token,所以在那里识别,只会在站点自己的校验环节失败,和版本出错一样,是无声无息的失败。result.token 是要提交的字符串,result.code 存的是同一个字符串,result.captchaId 则是 CapSkip 给这次任务的 id。版本不是 v1、v2、1 或 2,sitekey 为空,或者传了该方法不认识的选项,都会在任何请求离开你的机器之前抛出 ValidationException。

第 3 步:把 token 连同表单自身的字段一起提交

你获取到的 HTML 里没有 token 字段,因为它是小组件脚本在浏览器里创建的。所以要由你自己加上,字段名用该版本和小组件所使用的那个。表单携带的其他所有内容,包括隐藏的 CSRF token,都直接取自表单本身:

// A renamed field wins; otherwise the default for the version.
const field = widget.attr("data-form-field-name")
  ?? widget.attr("data-solution-field-name")
  ?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");

// 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(field, result.token);

const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
  method: "POST",
  // Browsers send the page as Referer; some servers refuse a post without it.
  headers: { cookie: cookieHeader(), referer: PAGE_URL },
  body,
});
console.log(res.status);

serializeArray 会收集浏览器本该发送的那些字段,CSRF token 就是这样回到服务器的,你不必点名它。它不包含提交按钮,所以如果站点会检查按钮的 name,就用 body.set 把它加上。还要把页面 URL 作为 Referer 发送:fetch 不会发送 Referer 或 Origin 头,而有些框架(Django 就是其中之一)会拒绝两者都没有的 HTTPS 表单提交,哪怕 cookie 和 token 都完全正确。用 URLSearchParams 作为请求体,fetch 就会发送一个普通的表单提交,并替你设置好 content type。v2 的 token 大约 6 KB,所以它应该放在这个请求体里,绝不能放进查询字符串;v1 的 token 则是由点号分隔的四段,长几百个字符。原样传递,不要改动。

有两种情况需要到 DevTools 里看一看。在 v1 上,如果 data-solution-field-name 被设为单个连字符,就表示小组件根本不写入字段,站点自己的脚本会用别的方式发送 token。另外,有些站点是用 JavaScript 提交 JSON,而不是提交表单。无论哪种情况,都手动提交一次,然后照搬页面实际发出的请求。

每个 token 只够提交一次。Friendly Captcha 的验证会拒绝已被使用过或已过期的响应,所以每个表单都要重新识别。

第 4 步:同时处理多个表单,以及识别工具运行在哪里

在 Node 里,CapSkip 的每个方法本来就返回 Promise,而 AsyncCapSkip 只是同一个类的另一个名字,所以并发就是普通的 JavaScript。你需要的是一个上限。如果用 Promise.all 一口气发出一百个调用,超出 CapSkip 的 Friendly Captcha Max. Threads 设置(默认 10)的部分都会被排进队列,任务在那里最多等 250 秒来获得空闲线程,等不到就会被 CapSkip 判为失败。一个小小的 worker 池能让同时进行的调用最多保持十个:

// Run fn over items with at most `limit` in flight. A failed item
// becomes its Error instead of rejecting the whole batch.
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;
}

// jobs: { sitekey, url, version, script, apiServer } objects from Step 2
const tokens = await mapLimited(jobs, 10, (job) =>
  solver.friendlyCaptcha(job.sitekey, job.url, {
    version: job.version,
    moduleScript: job.script,
    apiServer: job.apiServer,
  }).then((r) => r.token));

识别耗时会有波动,要有心理准备。Friendly Captcha 按每个请求设定工作量,并对见过很多次的地址调高这个量,而且 CapSkip 会在真实浏览器里识别每一个 v2 小组件。这就是为什么这个方法最多按 recaptchaTimeout(默认 300 秒)轮询,而不是按 120 秒的 defaultTimeout。CapSkip 自己也有一套计时:在它的 Friendly Captcha 设置中,一次识别有 120 秒的 Row Timeout,超时就会被判为失败。你可以在选项里传入 timeout,改变 SDK 对单次调用的等待时长。SDK 的等待必须长于等待线程的时间加上识别的时间,而在 CapSkip 的默认设置下,这可能长达 370 秒。所以要让 worker 池的大小与 Max. Threads 保持一致,或者传入更长的 timeout,并且每次调高 Row Timeout 时都把 timeout 跟着再调高,否则调用会在 CapSkip 还在干活的时候以 TimeoutException 结束。轮询间隔也可以按调用单独设置,但只能用 snake case 写成 polling_interval。camelCase 的 pollingInterval 属于构造函数,作为单次调用的选项传入会抛出 ValidationException。如果在长时间运行中识别变慢,通常是某个地址上的难度在上升,这时可以传入一个带 type 和 uri 键的代理对象,或者在 CapSkip 里配置代理池。

示例使用 127.0.0.1,因为当 Node 和识别工具在同一台机器上时,这样写是对的。一旦脚本跑在别处,比如 VPS、容器或 CI runner,回环地址指向的就是那台机器,第一次调用就会抛出带 ECONNREFUSED 的 NetworkException。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 访问到它。如果链路要经过公网,请使用静态公网 IP,并为你预期的地址配置防火墙规则。硬件仍然是你自己的,用量也仍然不计量。客户端不会自己读取环境变量,所以要在你的代码里读取 CAPSKIP_HOST 并传进去,就像下面的完整示例那样。

完整可运行示例

下面是整套流程,全部放在一个文件里:只凭一个页面 URL,在 Node.js 中识别 Friendly Captcha 的最短完整写法。

// npm install capskip cheerio
// solve-friendly.mjs: run with node solve-friendly.mjs
import * as cheerio from "cheerio";
import { CapSkip, CapSkipError, ValidationException } from "capskip";

const PAGE_URL = "https://example.com/signup";
const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];

const jar = new Map();
function remember(res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");

function friendlyVersion(scripts, widget) {
  for (const src of scripts) {
    if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
    if (src.includes("friendly-challenge")) return { version: "v1", script: src };
  }
  for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
    const file = new URL(src).pathname.split("/").pop();
    if (V2_FILES.includes(file)) return { version: "v2", script: src };
    if (V1_FILES.includes(file)) return { version: "v1", script: src };
  }
  if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
  if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
  throw new Error("v1 or v2? Read the page and set it by hand.");
}

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),
});

const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());
const widget = $(".frc-captcha").first();
if (!widget.attr("data-sitekey")) {
  throw new Error("No frc-captcha widget in the HTML; it may be built by JS.");
}
const form = widget.closest("form");
const scripts = $("script[src]").not("[nomodule]")
  .map((_, el) => new URL($(el).attr("src"), PAGE_URL).href).get();

const { version, script } = friendlyVersion(scripts, widget);
let result;
try {
  result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
    version,
    moduleScript: script,
    apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
  });
} catch (err) {
  if (err instanceof ValidationException) throw new Error(`not sent: ${err.message}`);
  if (err instanceof CapSkipError) throw new Error(`solve failed: ${err.name}: ${err.message}`);
  throw err;
}

const field = widget.attr("data-form-field-name")
  ?? widget.attr("data-solution-field-name")
  ?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");

// Post wherever the form's action points, with its other fields.
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", "YOUR_EMAIL");
body.set(field, result.token);

const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
  method: "POST",
  // Browsers send the page as Referer; some servers refuse a post without it.
  headers: { cookie: cookieHeader(), referer: PAGE_URL },
  body,
});
console.log(res.status, version, field);

版本只判定一次,却用在两处:识别和字段名,所以两者永远不会对不上。如果 cheerio 找不到小组件,通常是因为页面用 JavaScript 动态生成它,这时你需要从渲染后的页面或者创建它的脚本里读取 sitekey。原始接口接受的全部参数,详见 Friendly Captcha API 参考文档.

常见错误及其含义

你所看到的原因修复
用的是 CapSkip 返回的 token,却得到 403 或 CSRF 错误提交时没有带上页面的 cookie、隐藏字段或 Referer发送第 1 步得到的 cookie 头和 Referer,并用 serializeArray 构建请求体
站点拒绝了 token,却没有任何其他错误识别了错误的版本,常见的是默认套用了 v1像第 2 步那样在代码里判定版本并传入
在小组件带有 data-api-endpoint(v2)或 data-puzzle-endpoint(v1)的站点上被拒绝token 来自全球端点,而站点用的是 EU 端点把该属性的值作为 apiServer 传入
在自行托管小组件的站点上,v2 识别失败moduleScript 传的是相对路径,所以小组件根本没有加载用 new URL 以页面 URL 为基准把 src 解析成完整地址
抛出 ValidationException,里面点名了 pollingInterval单次调用的选项要写成 polling_interval单次调用时用 snake case,或者在构造函数上设置 pollingInterval
抛出与 await 或 import 有关的 SyntaxError文件是按 CommonJS 运行的使用 .mjs 扩展名,或者把包的 type 设为 module
TypeError: getSetCookie is not a functionNode.js 版本低于 18.15 或 19.7升级到 Node.js 22 或更高版本
每次都在几秒内抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLEFriendly Captcha 拒绝了该 sitekey 或页面的来源,或者该账户没有启用 v2 或 EU 端点检查 sitekey、页面 URL 和 apiServer;重试无济于事
两分钟或更久之后抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLE某次识别超过了 CapSkip 120 秒的 Row Timeout,或者等待空闲线程的时间超过了 250 秒的 Wait Timeout让 worker 池的大小与 Max. Threads 保持一致,添加代理,或者在 Friendly Captcha 设置中调高 Row Timeout
300 秒后抛出 TimeoutException某个任务先等待空闲线程,再进行识别,耗时超过了 SDK 的轮询时长让 worker 池的大小与 Max. Threads 保持一致,或者传入更长的 timeout
抛出 NetworkException,提示 ECONNREFUSEDCapSkip 没有在运行,或者主机和端口不对启动 CapSkip,然后确认该用 Local 模式还是 Server 模式

常见问题

在 Node.js 中识别 Friendly Captcha,需要 Puppeteer 或 Playwright 吗?

不需要。你的脚本只需要 HTML,而 HTML 由 fetch 获取。浏览器相关的工作都在 CapSkip 内部完成:它在自己的浏览器里识别 v2 小组件,v1 谜题则直接识别。如果你的脚本本来就因为别的原因在驱动浏览器,同样的调用照样可用:改为从实时页面读取 sitekey 和脚本地址,而不是从获取到的 HTML 里读取。

在 TypeScript 里能用吗?

能。capskip 包自带类型定义,选项对象的类型同时涵盖了这里使用的 camelCase 名称和 API 的 snake_case 名称。有一个小坑:共用的结果类型把 token 声明为可选,而且仍然把它描述成 ALTCHA 字段,因为所有方法共用同一种结果结构。friendlyCaptcha 总会填充它,所以对 result.token 做非空断言是安全的。cheerio 的 attr 同样返回 string 或 undefined,所以在 strict 模式下,对 sitekey 和脚本 src 也要用同样的方式做断言。

VPS 上的 Node 应用能使用我 Windows 电脑上的识别工具吗?

可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,在应用运行的地方设置 CAPSKIP_HOST,然后像完整示例那样把它传给客户端。VPS、容器和托管 runner 都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP 并配上防火墙规则。识别工具始终运行在你自己的硬件上,所以识别次数再多,你付的费用也不会变。

这和 Python 指南有什么不同?

接口、选项和结果在每个 CapSkip SDK 里都一样;不一样的是它们周围的代码。Python 的 requests.Session 会替你保存 cookie,而 Node 的 fetch 需要第 1 步里那个小小的 cookie jar。Python 的 AsyncCapSkip 是一个独立的异步客户端,而在 Node 里,每个方法本来就是异步的,所以唯一要加的,就是给同时运行的数量设个上限。这套流程的 Python 版本见 Python Friendly Captcha 指南.

简短版结论

要在 Node.js 中识别 Friendly Captcha,先获取页面并用 getSetCookie 保存它的 cookie,再用 cheerio 读取 frc-captcha 元素及其 script 标签,并把每个 src 解析成完整地址。根据包名或文件名,或者小组件自身的属性,判断是 v1 还是 v2,两者都看不出来时就抛出异常。用 sitekey、页面 URL、这个版本和脚本地址调用 friendlyCaptcha,EU 站点再加上 apiServer。token 只提交一次,放在请求体里,和表单自身的字段放在一起,填进该版本指定的字段。

Friendly Captcha 的难度以 CPU 时间计价,而不是以金钱计价,所以忙碌的一天只花掉你自己机器上的识别时间,别无其他。运行一款 本地验证码识别工具 之后,唯一的限制就只有那台机器和你分配给它的线程。