如何用 Node.js 构建验证码检测器并识别验证码

captcha detector in node.js - How to Build a CAPTCHA Detector in Node.js and Solve It

Node.js 验证码检测器会读取页面 HTML 中小组件的脚本和容器,从中取出 sitekey,再把这一类映射到能识别它的 CapSkip 方法。reCAPTCHA v2 方框、v3 脚本和 Turnstile 小组件需要三种不同的调用和不同的选项,猜错会白白浪费一次识别,还返回一个你用不了的结果。本指南构建一个约 165 行的检测器,除了 capskip 之外不需要任何包。它返回类型、方法和参数,或者明确说它无法判断。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用,所以检查一百个页面的脚本不会产生额外开销。同样的检测器的 Python 版本见 验证码类型检测指南.

你需要什么

  • 在 Windows PC 上运行的 CapSkip,并已开启你需要的识别功能。 设置指南 详细介绍了首次启动的步骤。
  • Node.js 20 或更高版本。下面的代码在 Node.js 24.22.0 上运行过,检测器和识别步骤也在 20.11.1 和 25.2.1 上运行过,使用 capskip 1.3.0。该包本身需要 Node.js 18 或更高版本,但这里没有试过 Node.js 18。
  • 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
  • 一个你拥有或被允许自动化操作的页面,页面上有验证码。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的脚本就能通过 API 访问它。两种模式的设置位置都是 连接设置.

测试方式:检测器在与 Python 版本相同的 66 个保存页面上运行,每个页面都返回了相同的结果。其中 59 个是手工构建的(约 40 个遵循各厂商的文档,其余是我自己编出来的边缘情况),7 个是 2026 年 10 月 29 日抓取的公开页面快照。识别通过真实的 capskip 包,发往一个照搬了应用输入检查逻辑的模拟 CapSkip。真实的应用没有运行,所以下面的任何数字都不是识别耗时。

为什么要先检测验证码类型再识别

CapSkip 为每一类验证码提供不同的方法:SDK 中有 recaptcha、turnstile、geetest、altcha、capy、captchafox、friendlyCaptcha,以及用于图片的 normal;HTTP API 上还有 hcaptcha,SDK 目前尚未封装它。在 reCAPTCHA 内部,选项也很重要。Invisible、Enterprise 和 v3 是同一个调用上的标志。传错了,最好的情况是任务失败,最坏的情况是得到一个被站点拒绝、却不给任何提示的 token。我无法确认,面对错误的 reCAPTCHA 版本,真实的应用到底属于哪一种情况。无论哪种,这次识别都白费了。

其他错误会很快、很明显地失败。从以显式方式渲染 reCAPTCHA 的页面读取 sitekey,得到的是单词 explicit,CapSkip 会用 ERROR_GOOGLEKEY 拒绝它。 ERROR_GOOGLEKEY 与 ERROR_PAGEURL 指南 从另一个角度讲了这个错误。要可靠地检测验证码类型,检测器必须按固定的顺序检查各种信号,而这个顺序能避开这两种错误。在构建要面对多个站点的爬虫之前,检测器也是首先要做的东西。如果你想了解每种类型的背景,可以看 什么是验证码 这一页,里面不写代码,只讲各个类别。

第 1 步:安装并抓取页面

创建一个文件夹,初始化项目,并安装 capskip。检测器只用正则表达式,抓取调用使用 Node.js 自带的 fetch。

npm init -y
npm install capskip

Node.js 验证码检测器处理的是文本,所以只需要两个输入:页面的 HTML 和它的地址。普通的 fetch 看到的是服务器在任何 JavaScript 运行之前发来的内容。Google 的 reCAPTCHA 演示页面、Cloudflare 的 Turnstile 演示和 hCaptcha 演示,都把各自的小组件放在这样的 HTML 里。通过 JavaScript 添加小组件的页面则不是这样,“当 HTML 什么都没说时”一节会讲这种情况。Node.js 的 fetch 不保存 cookie,这对图片验证码步骤很重要,所以下面的脚本会手动把它们传下去。

第 2 步:编写辅助函数

把从这里到第 9 步的所有内容,一个代码块接一个代码块,保存在一个名为 detect.js 的文件里。第一个代码块写了几个小的读取函数。没有匹配时它们都返回 null,所以缺失的属性不会抛出异常。

'use strict';
const SITEKEY_JS = /sitekey['"]?\s*:\s*['"]([^'"]+)/; // sitekey: '...' inside a render() call
const NAMED = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
const unescapeHtml = (s) => s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|[a-z]+);/gi, (m, hex, dec) =>
  hex || dec ? String.fromCodePoint(parseInt(hex || dec, hex ? 16 : 10)) : NAMED[m.toLowerCase()] || m);
function grab(re, text) { // first capture group that matched, or null
  const m = new RegExp(re.source, 'is').exec(text || '');
  return m ? m.slice(1).find((g) => g !== undefined) ?? null : null;
}
const tag = (cls, h) => grab(new RegExp( // opening tag that has the CSS class cls
  `(<\\w+[^>]*\\sclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["'][^>]*>)`), h);
function attr(t, name) { // attribute value from a tag string, either quote style, unescaped
  const v = grab(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`), t);
  return v === null ? null : unescapeHtml(v);
}
const renderOpts = (call, js) => grab(new RegExp(`${call}\\([^,]+,\\s*\\{(.{0,400})`), js);

grab 函数返回第一个匹配到的捕获组。tag 函数按 CSS 类查找开始标签,并拒绝那些只是在更长的类名里包含该名称的类,所以名为 cf-turnstile-response 的类不是 Turnstile 小组件。attr 函数读取用任一种引号写出的属性,并对 HTML 实体做反转义,这对放在属性里的挑战数据很重要。不带引号的属性不会被读取。

第二个代码块把页面变成各项检查可以共用的东西。它会去掉注释和样式块,这样提到小组件类名的 CSS 规则就不会产生匹配,并且把脚本地址和内联 JavaScript 分别收集起来。parse 之后的两个辅助函数构建答案:hit 和 browser,后者携带 needsBrowser 的原因。第三种答案 unsupported 由本代码块末尾的检查构建,下一步会连同 hCaptcha 检查一起讲解。

function parse(html, url) {
  const h = html.replace(/<!--.*?-->|<style\b.*?<\/style>/gis, '');
  const srcs = [...h.matchAll(/<script\b[^>]*?\ssrc\s*=\s*["']([^"']+)/gi)].map((m) => m[1]).join(' ');
  const js = [...h.matchAll(/<script\b[^>]*>(.*?)<\/script>/gis)].map((m) => m[1]).join('\n');
  return { h, srcs: unescapeHtml(srcs), js, url };
}
const hit = (type, method, args) => ({ type, method, args });
const browser = (type, method, p, why) => ({ type, method, args: { url: p.url }, needsBrowser: why });

const UNSUPPORTED = { arkose: /arkoselabs\.com\/v2\/|funcaptcha\.com\/fc\//, geetest_v4: /gt4\.js|initGeetest4/ };
function unsupported(p) { // CapSkip cannot solve these: say so instead of misleading the reader
  const type = Object.keys(UNSUPPORTED).find((k) => UNSUPPORTED[k].test(p.srcs + ' ' + p.js));
  if (type) return { type, method: null, args: {}, unsupported: true };
}
const cfChallenge = (p) => /_cf_chl_opt\s*=\s*\{/.test(p.js) // the full-page "Just a moment..." page
  ? browser('turnstile_challenge', 'turnstile', p, 'data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time') : null;
function hcaptcha(p) { // before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class
  if (!/hcaptcha\.com\/1\/api\.js/.test(p.srcs + ' ' + p.js)) return null;
  if (/\.setData\s*\(|rqdata/.test(p.js)) // Enterprise rqdata is issued per challenge
    return browser('hcaptcha_enterprise', 'hcaptcha', p, 'rqdata is issued for one challenge at run time');
  const w = tag('h-captcha', p.h) || tag('g-recaptcha', p.h);
  const key = attr(w, 'data-sitekey') || grab(SITEKEY_JS, renderOpts('hcaptcha\\.render', p.js));
  if (!key) return browser('hcaptcha', 'hcaptcha', p, 'no sitekey in the static HTML');
  const args = { sitekey: key, url: p.url };
  if (attr(w, 'data-size') === 'invisible') args.invisible = 1;
  return hit('hcaptcha', 'hcaptcha', args);
}

第 3 步:hCaptcha,以及 CapSkip 无法识别的类型

这三项检查位于上面第二个代码块的末尾,所以这一步没有需要粘贴的新内容。先处理正确答案是“否”的页面。CapSkip 不识别 FunCaptcha 或 Arkose,GeeTest v4 也不在 GeeTest 方法的范围内,该方法针对的是 v3。如果检测器跳过这些,带有 Arkose 方框的页面就会落到下一项检查,还可能被判成它并不是的东西。所以这项检查最先运行,并返回一个没有方法的结果。

同一个代码块也处理 Cloudflare 的整页检查,也就是 “Just a moment” 中间页。这个页面不是 Turnstile 小组件。turnstile 方法需要从中取得两个值,data 和 pagedata,而普通的 fetch 拿不到它们。检测器会查找页面内联的挑战选项,并回答 needsBrowser。 cf_clearance 指南 介绍了通过挑战页面之后要做什么。

接下来是 hCaptcha,排在 reCAPTCHA 之前。无论小组件是复选框、invisible 还是 Enterprise,CapSkip 都能识别,但从 reCAPTCHA 迁移到 hCaptcha 的站点常常在小组件上保留 g-recaptcha 类,只有 hCaptcha 脚本才能区分两者。这项检查从小组件或 hcaptcha.render 调用中读取 sitekey,当小组件的 data-size 设为 invisible 时加上 invisible。如果 Enterprise 页面通过 hcaptcha.setData 给小组件传入 rqdata 值,结果则会是 needsBrowser,因为这个值只针对一次挑战签发,而且很快过期。

第 4 步:reCAPTCHA,按正确的顺序

reCAPTCHA 需要最细致的处理,因为好几个 CapSkip 变体共用两个脚本名。问题的先后顺序,才能把它们区分开。先看脚本名:enterprise.js 表示 Enterprise 标志,api.js 则不是。然后看脚本地址里的 render 参数。render= 后面跟一个 key 表示 v3,但 render= 后面跟单词 explicit,表示这是一个用代码渲染小组件的 v2 页面,而且它绝不能当作 key 使用。然后看标记。带有 g-recaptcha 类和 data-action 属性的按钮,是 Google 的 v3 按钮绑定。没有 data-action 的按钮,或者 data-size 设为 invisible 的任何元素,是 invisible 的 v2 小组件。带有该类的其他任何元素,都是复选框。

function recaptcha(p) { // Enterprise flag first, then v3 (render=KEY), then invisible, then v2
  const m = /\/recaptcha\/(api|enterprise)\.js(\?\S*)?/.exec(p.srcs);
  if (!m) return null;
  const ent = m[1] === 'enterprise' ? { enterprise: 1 } : {};
  const render = grab(/[?&]render=([^&\s]+)/, m[2]); // a v3 sitekey, or "explicit" (a v2 page)
  const t = tag('g-recaptcha', p.h), o = renderOpts('\\.render', p.js);
  const button = (t || '').toLowerCase().startsWith('<button'), dataAction = attr(t, 'data-action');
  const v3 = Boolean(render && render !== 'explicit') || Boolean(button && dataAction);
  const key = (render !== 'explicit' ? render : null) || attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);
  if (!key) return browser('recaptcha', 'recaptcha', p, 'no sitekey in the static HTML (set by JavaScript)');
  const small = attr(t, 'data-size') === 'invisible' || grab(/size['"]?\s*:\s*['"](invisible)/, o);
  const invisible = !v3 && Boolean(button || small);
  const action = dataAction || grab(/execute\(\s*[^,)]+,\s*\{\s*['"]?action['"]?\s*:\s*['"]([^'"]+)/, p.js);
  const args = { sitekey: key, url: p.url, ...ent };
  if (v3) Object.assign(args, { version: 'v3' }, action ? { action } : {});
  if (invisible) args.invisible = 1;
  const kind = 'recaptcha_' + (v3 ? 'v3' : invisible ? 'v2_invisible' : 'v2');
  return hit(kind + (ent.enterprise ? '_enterprise' : ''), 'recaptcha', args);
}

每种结果都对应一个你已经熟悉的调用。复选框是带 sitekey、页面 URL 和一个空选项对象的 solver.recaptcha。Invisible 要加上设为 1 的 invisible,Enterprise 要加上设为 1 的 enterprise,v3 要加上设为 v3 的 version 和 action。action 的名称取自 data-action 属性或页面的 execute 调用;如果 HTML 中两者都没有,就不传这个选项,CapSkip 使用它的默认值 verify。请参阅 reCAPTCHA 识别工具页面 了解它处理的每一种变体。

第 5 步:Turnstile 和 GeeTest

Turnstile 小组件在页面上需要两样东西:Cloudflare 的 api.js 和一个 sitekey。key 位于带有 cf-turnstile 类的 div 上的 data-sitekey 属性中,或者位于 turnstile.render 调用中。检查要求必须有这个脚本,所以无关页面上偶然出现的类名不会触发它。当 data-action 和 data-cdata 存在时,它也会读取并传下去。 Turnstile 识别工具页面 列出了这些选项。

GeeTest v3 会给你两个值,其中一个是陷阱。gt 值对站点是固定的,可以放在页面里。challenge 值只能用一次,站点在运行时从一个 register 端点获取它。如果页面同时带有两者,检测器就把它们返回。如果页面只带有 initGeetest 调用,它就回答 needsBrowser,因为在旧 HTML 里找到的 challenge 早已失效。

function turnstile(p) { // widget only: implicit (div.cf-turnstile) or explicit (turnstile.render)
  if (!p.srcs.includes('challenges.cloudflare.com/turnstile/v0/api.js')) return null;
  const t = tag('cf-turnstile', p.h), o = renderOpts('turnstile\\.render', p.js);
  const sitekey = attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);
  if (!sitekey) return browser('turnstile', 'turnstile', p, 'no sitekey in the static HTML');
  const action = attr(t, 'data-action') || grab(/\baction['"]?\s*:\s*['"]([^'"]+)/, o);
  const data = attr(t, 'data-cdata') || grab(/\bcData['"]?\s*:\s*['"]([^'"]+)/, o);
  return hit('turnstile', 'turnstile', { sitekey, url: p.url, ...(action && { action }), ...(data && { data }) });
}
function geetest(p) { // v3 only: gt is static, challenge comes from the register endpoint
  if (!(/initGeetest\s*\(/.test(p.js) || /\/gt(\.[\d.]+)?\.js/.test(p.srcs))) return null;
  const gt = grab(/\bgt['"]?\s*:\s*['"]([0-9a-f]{32})/, p.js);
  const challenge = grab(/\bchallenge['"]?\s*:\s*['"]([0-9a-f]{32,34})/, p.js);
  if (!(gt && challenge)) return browser('geetest', 'geetest', p, 'gt and challenge come from the register endpoint');
  return hit('geetest', 'geetest', { gt, challenge, url: p.url });
}

第 6 步:ALTCHA 和 Capy

ALTCHA 在不同代之间改过属性名。版本 1 和 2 使用 challengeurl,内联挑战则用 challengejson。版本 3 及更高版本使用一个叫 challenge 的属性,它既可以是 URL,也可以是挑战数据。检查会读取这三个,并把相对 URL 与页面地址拼合。在 Node.js 中,选项名是 challengeUrl 和 challengeJson。

Capy 在这项检查中不需要小组件 div。它的 key 以 PUZZLE_ 开头,位于小组件脚本地址的 k 参数中,而非默认的主机则作为 apiServer 传入。

function altcha(p) { // challengeurl / challengejson (v1, v2) or challenge (v3+)
  const t = grab(/(<altcha-widget\b[^>]*>)/, p.h);
  if (!t) return null;
  const c = attr(t, 'challenge');
  const asJson = attr(t, 'challengejson') || ((c || '').trimStart().startsWith('{') ? c : null);
  const asUrl = attr(t, 'challengeurl') || (asJson ? null : c);
  if (asJson) return hit('altcha', 'altcha', { url: p.url, challengeJson: asJson });
  if (asUrl) return hit('altcha', 'altcha', { url: p.url, challengeUrl: new URL(asUrl, p.url).href });
  return browser('altcha', 'altcha', p, 'no challenge attribute');
}
function capy(p) { // the widget script is .../puzzle/get_js/?k=PUZZLE_...
  const m = /((?:https?:)?\/\/[^\s"']+?)\/puzzle\/get_js\/\S*?(PUZZLE_[\w-]+)/.exec(p.srcs);
  if (!m) return null;
  const host = m[1].startsWith('http') ? m[1] : 'https:' + m[1];
  const more = host === 'https://jp.api.capy.me' ? {} : { apiServer: host };
  return hit('capy', 'capy', { sitekey: m[2], url: p.url, ...more });
}

第 7 步:CaptchaFox 和 Friendly Captcha

CaptchaFox 有两种构建。普通版从 cdn.captchafox.com 加载。另一种由托管平台打包,放在共享的 CDN 路径下,所以检测器是在该路径中查找 CaptchaFox 的包名,而不是只看路径本身,因为其他小组件也用这个路径。

Friendly Captcha 是最棘手的。版本 1 和版本 2 使用同样的 div 和同一个 sitekey 命名空间,但协议不同,错误版本的 token 可能被站点拒绝。可以通过脚本把两者区分开:版本 2 加载 @friendlycaptcha/sdk 包,版本 1 加载 friendly-challenge。当页面没有给出任何迹象时,检测器拒绝猜测,并回答 needsBrowser。

const MAM = 'mampkg/@mamdev/core.frontend.libs.captchafox';
function captchafox(p) { // plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com
  const t = tag('captchafox', p.h), key = grab(/captchafox[^"']*\/api\.js\?key=([^"'&]+)/, p.srcs);
  if (!t && !key) return null;
  const more = p.srcs.includes(MAM) ? { apiServer: 'https://s.uicdn.com/' + MAM + '/' } : {};
  const sitekey = attr(t, 'data-sitekey') || key;
  if (!sitekey) return browser('captchafox', 'captchafox', p, 'no sitekey in the static HTML');
  return hit('captchafox', 'captchafox', { sitekey, url: p.url, ...more });
}
function friendly(p) { // v2 loads @friendlycaptcha/sdk (site.min.js), v1 loads friendly-challenge
  const t = tag('frc-captcha', p.h);
  if (!t) return null;
  const v2End = attr(t, 'data-api-endpoint'), v1End = attr(t, 'data-puzzle-endpoint');
  const v2 = v2End !== null || /friendlycaptcha\/sdk|site(\.compat)?\.min\.js/.test(p.srcs);
  const v1 = v1End !== null || /friendly-challenge|widget(\.module)?\.min\.js/.test(p.srcs);
  if (v2 === v1) return browser('friendly_captcha', 'friendlyCaptcha', p, 'cannot tell v1 from v2 in the HTML');
  const server = v2End || v1End;
  if (!attr(t, 'data-sitekey')) return browser('friendly_captcha', 'friendlyCaptcha', p, 'no sitekey in the static HTML');
  return hit('friendly_captcha_' + (v2 ? 'v2' : 'v1'), 'friendlyCaptcha',
    { sitekey: attr(t, 'data-sitekey'), url: p.url, version: v2 ? 'v2' : 'v1', ...(server && { apiServer: server }) });
}

其中每一种都有自己的识别页面,介绍该方法所接受的选项:

第 8 步:图片验证码,一次猜测,以及各项检查

图片验证码没有可以查找的厂商脚本,所以这项检查是一种猜测,只在其他检查都没有匹配时才运行。这项检查要找一张标签里带有 captcha 的图片,以及页面上其他位置一个标签里带有 captcha 或 security code 的文本框。凡是标记里没有用到这些词的情况,它每次都漏掉,而且它看不见绘制出来的 canvas。请把命中视为可能,而不是确定。

同一个代码块按顺序列出各项检查,并定义 detect,它在第 9 步末尾导出。这个顺序就是逻辑:先是不支持的厂商,然后是 Cloudflare 页面,然后是 hCaptcha,再是其他各个类别,只有当列表一无所获时才用图片猜测。detect 函数返回第一个结果,当页面带有不止一类时(例如 v3 脚本加一个 Turnstile 方框),会附加一个 also 列表。这个列表列出的是类别,而不是带着另一个 key 的第二个 reCAPTCHA 小组件。

function image(p) { // a guess: an <img> that says captcha, next to a text box that says captcha
  const box = /<input\b(?![^>]*type\s*=\s*["']?(?:hidden|checkbox|submit))[^>]*(?:captcha|security.?code)/i;
  const img = (p.h.match(/<img\b[^>]*>/gi) || []).find((i) => box.test(p.h) && /captcha/i.test(i) && attr(i, 'src'));
  if (img) return hit('image', 'normal', { image: new URL(attr(img, 'src'), p.url).href });
}
const CHECKS = [unsupported, cfChallenge, hcaptcha, recaptcha, turnstile, geetest, altcha, capy, captchafox, friendly];

function detect(html, pageUrl) {
  const p = parse(html, pageUrl);
  const found = CHECKS.map((check) => check(p)).filter(Boolean);
  const best = found[0] || image(p) || null; // the image guess comes last
  if (found.length > 1) best.also = found.slice(1).map((r) => r.type);
  return best;
}

第 9 步:识别

最后一个代码块把结果分发给正确的 CapSkip 方法。对于 unsupported 或标记为 needsBrowser 的结果,它会抛出 Error 来拒绝。CapSkip 的方法以位置方式接收主要的值,其余的放在一个选项对象里,所以用一张小表说明哪些值放在哪里。Node.js 的选项是 camelCase,只有一个例外:如果你添加每次调用的轮询间隔,名字是 polling_interval,因为每次调用的 pollingInterval 会抛出 ValidationException。图片方法完全不接受每次调用的选项,所以对它要在 CapSkip 构造函数上设置 pollingInterval。图片是用你传入的 cookie 下载的,因为 fetch 没有 cookie 容器,而 SDK 自己下载 URL 时不发送任何 cookie。这些 cookie 会发往图片所在的任何主机,所以如果你的验证码图片在另一个主机上,就不要传 cookie。

SDK 还没有 hCaptcha 调用,所以 hCaptcha 结果改为交给 solveHcaptcha。它以 method=hcaptcha 把 sitekey 和页面地址提交到 in.php,然后每 5 秒轮询一次 res.php,直到 token 就绪,正如 API 文档的 hCaptcha 部分 所述。把识别工具的地址作为 api 放在 solve 的最后一个参数中传入;如果开启了 API key 验证,再把你的 API key 作为 key 传入。结果在 code 中携带 token,在 userAgent 中携带获取它时使用的 User-Agent。

const POSITIONAL = { normal: ['image'], recaptcha: ['sitekey', 'url'], turnstile: ['sitekey', 'url'],
  geetest: ['gt', 'challenge', 'url'], altcha: ['url'], capy: ['sitekey', 'url'], captchafox: ['sitekey', 'url'],
  friendlyCaptcha: ['sitekey', 'url'] };

async function solveHcaptcha(args, api, key) { // no SDK call yet: post to in.php, poll res.php
  const form = { method: 'hcaptcha', sitekey: args.sitekey, pageurl: args.url, invisible: args.invisible || 0, json: 1 };
  if (args.data) form.data = args.data;
  if (key) form.key = key;
  const job = await (await fetch(`${api}/in.php`, { method: 'POST', body: new URLSearchParams(form) })).json();
  if (job.status !== 1) throw new Error(job.request);
  for (let i = 0; i < 36; i++) { // a solve takes 10 to 30 seconds, so give up after 3 minutes
    await new Promise((done) => setTimeout(done, 5000));
    const q = new URLSearchParams({ action: 'get', id: job.request, json: 1, ...(key ? { key } : {}) });
    const r = await (await fetch(`${api}/res.php?${q}`)).json();
    if (r.request !== 'CAPCHA_NOT_READY') {
      if (r.status !== 1) throw new Error(r.request);
      return { code: r.request, userAgent: r.useragent };
    }
  }
  throw new Error('hCaptcha was not solved within 3 minutes');
}

async function solve(info, solver, fetchOpts = {}, { api = 'http://127.0.0.1:8080', key } = {}) {
  if (info.unsupported || info.needsBrowser) throw new Error(info.needsBrowser || `${info.type} is not supported`);
  const args = { ...info.args };
  if (info.method === 'hcaptcha') return solveHcaptcha(args, api, key);
  if (info.method === 'normal' && !args.image.startsWith('data:')) { // fetch has no cookie jar: pass a Cookie header
    const res = await fetch(args.image, fetchOpts);
    if (!res.ok) throw new Error(`image download failed: HTTP ${res.status}`);
    args.image = 'data:image/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64');
  }
  const names = POSITIONAL[info.method];
  const options = Object.fromEntries(Object.entries(args).filter(([k]) => !names.includes(k)));
  return solver[info.method](...names.map((k) => args[k]), options);
}
module.exports = { detect, solve };

运行 Node.js 验证码检测器

把它保存为 use.js,放在 detect.js 旁边。它抓取你的页面,打印检测器的发现,并且只有当有方法可调用、且没有缺失项时才调用 CapSkip。它发送类似浏览器的 User-Agent,因为许多站点会拒绝 Node.js 的默认值。当页面不是以 200 响应时,它会打印 HTTP 状态并继续,因为 Cloudflare 挑战页面是以 403 或 503 到达的。运行时请把页面地址放在引号里,因为含有 & 符号的地址在 PowerShell 里会解析出错。端口 8080 是默认值;如果你的不同,请设置 CAPSKIP_PORT 环境变量,这个脚本会自己读取它。这个脚本里主机保持 127.0.0.1,所以要使用 Server 模式,请修改主机那一行。

$env:CAPSKIP_PORT = "9090"   # only if your port is not 8080
node use.js "https://example.com/login"

注意,fetch 不保存 cookie,而这个脚本只从最终响应读取 Set-Cookie,所以在重定向时设置的 cookie 会丢失。要保留它,就把 fetch 的 redirect 选项设为 manual,从重定向响应中读取 Set-Cookie,然后自己请求 Location 地址。脚本本身在下面。

'use strict';
const { CapSkip } = require('capskip');
const { detect, solve } = require('./detect');

async function main(pageUrl) {
  const res = await fetch(pageUrl, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36' } });
  if (!res.ok) console.error(`page answered HTTP ${res.status}`); // a challenge page is a 403 or 503, so keep going
  const html = await res.text();
  const info = detect(html, res.url); // res.url is the final address after any redirect
  console.log(JSON.stringify(info, null, 2));
  if (!info || info.unsupported || info.needsBrowser) return;
  const cookie = res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
  const host = '127.0.0.1', port = Number(process.env.CAPSKIP_PORT || 8080);
  const solver = new CapSkip({ host, port });
  console.log(await solve(info, solver, cookie ? { headers: { cookie } } : {}, { api: `http://${host}:${port}` }));
}

main(process.argv[2]).catch((err) => {
  console.error(`${err.constructor.name}: ${err.message}`);
  process.exit(1);
});

在带有 reCAPTCHA v2 复选框的页面上,第一次打印显示类型、方法和参数,第二次显示结果。对 reCAPTCHA 来说,token 在 code 字段里;其他方法使用不同的字段,例如 Turnstile 和 hCaptcha token 旁边的 userAgent。reCAPTCHA token 要放进表单的 g-recaptcha-response 字段,hCaptcha token 则放进 h-captcha-response。在模拟运行中,检测器返回了这些结果:

页面detect 的返回值
reCAPTCHA v2 复选框类型 recaptcha_v2,方法 recaptcha,以及 sitekey 和 url
hCaptcha 小组件类型 hcaptcha,方法 hcaptcha,sitekey 和 url
Cloudflare “Just a moment” 页面类型 turnstile_challenge,已设置 needsBrowser
v3 脚本加 Turnstile 小组件类型 recaptcha_v3,also 列出 turnstile

当 HTML 什么都没说时:浏览器控制台

有些页面会在加载之后用 JavaScript 添加小组件。这时普通的抓取根本找不到验证码,检测器会对确实有验证码的页面返回 null。已知有两种预期中的情况:由代码添加的 Google 或 Cloudflare 脚本,以及由代码设置的 sitekey。遇到这些,就在 Chrome 中打开页面,按 F12,把下面的代码粘贴到 Console 中。如果 Chrome 阻止粘贴,请先输入 allow pasting 并按 Enter。它从页面已加载的资源中读取 sitekey,所以能看到浏览器构建出来的内容。

(() => {
  const iframes = [...document.querySelectorAll('iframe')].map((f) => f.src);
  const urls = performance.getEntriesByType('resource').map((r) => r.name).concat(iframes);
  const found = new Map(), add = (o) => found.set(JSON.stringify(o), o);
  for (const u of urls) {
    let m;
    if ((m = u.match(/recaptcha\/(?:api2|(enterprise))\/anchor\?.*?\bk=([\w-]+)/)))
      add({ type: 'recaptcha', sitekey: m[2], enterprise: !!m[1], invisible: /size=invisible/.test(u) });
    else if ((m = u.match(/recaptcha\/(?:api|enterprise)\.js\?.*?\brender=([\w-]+)/)) && m[1] !== 'explicit')
      add({ type: 'recaptcha v3', sitekey: m[1], enterprise: /enterprise\.js/.test(u) });
    else if ((m = u.match(/challenges\.cloudflare\.com\/.*turnstile\/.*\/(0x4[\w-]+|[123]x0+[A-Za-z]+)\//)))
      add({ type: 'turnstile', sitekey: m[1] });
    else if ((m = u.match(/hcaptcha\.com\/captcha\/.*[#&?]sitekey=([\w-]+)/))) add({ type: 'hcaptcha', sitekey: m[1] });
    else if (/arkoselabs\.com\/fc\//.test(u)) add({ type: 'arkose: CapSkip cannot solve' });
  }
  if (window._cf_chl_opt) add({ type: 'cloudflare challenge page' });
  document.querySelectorAll('altcha-widget').forEach((e) => add({ type: 'altcha', challenge: e.getAttribute('challenge') || e.getAttribute('challengeurl') }));
  document.querySelectorAll('.h-captcha[data-sitekey]').forEach((e) => add({ type: 'hcaptcha', sitekey: e.dataset.sitekey }));
  document.querySelectorAll('.frc-captcha, .captchafox').forEach((e) => add({ type: e.classList.contains('captchafox') ? 'captchafox' : 'frc-captcha', sitekey: e.dataset.sitekey }));
  const v3 = [...found.values()].filter((o) => o.type === 'recaptcha v3').map((o) => o.sitekey);  // a v3 badge also adds an invisible anchor frame
  const res = [...found.values()].filter((o) => !(o.type === 'recaptcha' && v3.includes(o.sitekey)));
  return res.length ? res : 'no known CAPTCHA found (GeeTest, Capy and CaptchaFox scripts are not checked)';
})();

它在 reCAPTCHA v2 页面、invisible 页面、v3 页面、Turnstile 页面和 hCaptcha 演示页面上找到了 sitekey 和标志,并在没有验证码的页面上打印了一条消息。有三个局限:浏览器默认只保留大约 250 条资源记录,所以长时间运行的单页应用可能丢掉最早的记录;这段代码无法区分 Friendly Captcha 版本 1 和版本 2;它也不会查找 GeeTest、Capy 或 CaptchaFox 的脚本。在 Cloudflare 的整页检查上,它能从资源地址显示 sitekey,但显示不了 data 和 pagedata 的值,这需要在 render 调用上挂一个钩子。那个钩子没有在这里测试。

检测失败的情形

场景detect 的返回值该怎么做
由 JavaScript 添加的小组件脚本null在浏览器中加载页面,并运行控制台代码
由 JavaScript 设置的 sitekeyneedsBrowser从实时页面读取 key
Cloudflare 整页检查needsBrowser在浏览器中读取这些值,请参阅 cf_clearance 指南。页面以 403 或 503 到达,use.js 会报告这一点,然后仍会继续检查
HTML 中没有小组件的 CaptchaFox、Friendly Captcha 或 Capy 脚本null运行控制台代码,或查看页面源代码
没有小组件的封锁页面(403、503)nulluse.js 先打印状态:封锁页面里没有验证码可找
Arkose、GeeTest v4unsupportedCapSkip 无法识别这些
设置了 rqdata 的 hCaptcha EnterpriseneedsBrowser针对每个任务从实时页面读取新的 rqdata,并将其作为 data 传给 solveHcaptcha
没有脚本线索的 Friendly CaptchaneedsBrowser检查页面加载的是哪个脚本
不含 captcha 这个词的图片验证码null为那个站点添加你自己的规则

常见错误

错误原因修复
ERROR_GOOGLEKEYkey 是单词 explicit,或者是不像 reCAPTCHA key 的 key从小组件读取 key,而不是从 render 参数读取
ERROR_CAPTCHA_UNSOLVABLE失败的任务就是这样的表现,而答案不会指出原因。错误的 reCAPTCHA 版本可能终止在这里,但我没有在真实的应用上确认这一点先检查检测到的类型,然后参阅 API 文档
ValidationException方法不接受的选项,例如 v2 调用上的 action、每次调用的 pollingInterval,或图片方法上的任何每次调用选项使用检测器返回的参数
NetworkExceptionCapSkip 没有在运行,或者主机或端口不对启动 CapSkip 并检查连接设置
solve 抛出的错误结果是 unsupported,或者需要浏览器,或者 CapSkip 拒绝了 hCaptcha 任务,消息就是它的错误代码调用 solve 之前先处理前两种情况,第三种情况请检查 sitekey 和页面地址

把识别工具跑在服务器上

检测器只需要 CapSkip 提供两样东西:主机和端口。在 Local 模式下,那就是 127.0.0.1 和你自己的 PC。在 Server 模式下,CapSkip 监听你的网络地址或公网 IP,所以另一台 PC、VPS 或托管平台上运行的检测器,可以通过同样的 API 调用它。在 use.js 中更改主机和端口,代码里的其他部分都不用变。建议使用静态公网 IP,以获得稳定的远程访问。Server 模式仍然是你自己的硬件,仍然不计量。请注意,Server 模式会对任何地址开放这个端口,所以请为不应访问它的地址添加 Windows Firewall 阻止规则。请参阅 连接设置 了解如何切换模式。

常见问题

不写代码,能判断一个站点用的是哪种验证码吗?

可以。打开 DevTools,进入 Network 标签页,重新加载页面,查看脚本地址。google.com/recaptcha 或 recaptcha.net 的脚本是 reCAPTCHA,challenges.cloudflare.com 是 Turnstile,js.hcaptcha.com 是 hCaptcha。上面的 Console 代码做的是同样的查找。

为什么 detect 返回 needsBrowser,而不是猜一个?

因为猜错会白白浪费一次完整的识别,还会得到一个被站点拒绝、却不给任何提示的 token。当静态 HTML 中没有 sitekey、GeeTest 的 challenge 或 Friendly Captcha 的版本时,直接说明,比让任务失败更快。

CapSkip 能识别 hCaptcha 吗?

可以。CapSkip 通过其 HTTP API(method=hcaptcha)识别 hCaptcha 的复选框、invisible 和 Enterprise 小组件,正如 API 文档 所述。Node.js SDK 还没有 hCaptcha 调用,所以上面的 solveHcaptcha 会自己把任务提交到 in.php。FunCaptcha、Arkose 和 GeeTest v4 仍然不受支持,检测器会把它们报告为 unsupported,这样脚本就可以跳过该页面,而不是在上面失败。

托管平台能用我的 CapSkip 来运行检测器吗?

可以,通过 Server 模式。把 CapSkip 设置为监听你的网络 IP 或公网 IP,把 use.js 中的主机和端口设为那个地址,检测器和识别调用的工作方式就和在一台 PC 上一样。识别工具仍然在你自己的硬件上。

简短版结论

Node.js 验证码检测器的价值在于先运行:先检测验证码类型,再识别。按固定的顺序读取脚本和标记,把参数交给正确的方法,并把 null、needsBrowser 和 unsupported 当作答案,而不是错误。CapSkip 运行在你自己的机器上,检查页面时没有任何计费表在走,这正是 本地验证码识别工具 在每隔几分钟就遇到新站点的爬取中切实可用的原因。关于 Node.js SDK 本身,请参阅 Node.js 验证码识别页面.