C# 验证码检测器:找出类型,调用 CapSkip

C# 验证码检测器会读取页面 HTML 中小组件的脚本和容器,从中取出 sitekey,再把这一类映射到能识别它的 CapSkip 方法。reCAPTCHA v2 方框、v3 脚本和 Turnstile 小组件需要三种不同的调用和不同的选项,猜错会白白浪费一整次识别,还给你一个用不了的 token。本指南构建一个约 200 行的检测器,除了 CapSkip 之外不需要任何包。它返回类型、方法和参数,或者明确说它无法判断。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用,所以检查一百个页面的程序不会产生额外开销。同样的检测器也有 Python 和 Node.js 版本,链接在最后。
你需要什么
- 在 Windows PC 上运行的 CapSkip,并已开启你需要的识别功能。 设置指南 详细介绍了首次启动的步骤。
- 需要 .NET 8 SDK 或更高版本,因为代码使用了原始字符串字面量,它需要 C# 11。测试使用了 SDK 10.0.100 和 CapSkip 包 1.3.0,目标框架为 net8.0 和 net10.0。把 LangVersion 11 加入项目文件之后,以 net6.0 为目标同样可行。.NET Framework 没有试过。
- 一个终端。这些命令在 PowerShell 中可用。
- 一个你拥有或被允许自动化操作的页面,页面上有验证码。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的程序就能通过 API 访问它。两种模式的设置位置都是 连接设置.
测试方式:检测器在与 Python 版本相同的 66 个保存页面上运行,每个页面都返回了相同的结果。其中 59 个是手工构建的(约 40 个遵循各厂商的文档,其余是我自己编出来的边缘情况),7 个是 2026 年 10 月 29 日抓取的公开页面快照。其中 40 个指明了方法的结果,通过真实的 CapSkip NuGet 包发往一个照搬了应用输入检查逻辑的模拟 CapSkip。真实的应用没有运行,所以下面的任何数字都不是识别耗时。
为什么要先检测验证码类型再识别
CapSkip 为每一类验证码提供不同的方法:SDK 中有 RecaptchaAsync、TurnstileAsync、GeetestAsync、AltchaAsync、CapyAsync、CaptchaFoxAsync、FriendlyCaptchaAsync,以及用于图片的 NormalAsync;HTTP API 上还有 hcaptcha,SDK 目前尚未封装它。在 reCAPTCHA 内部,选项也很重要。Invisible、Enterprise 和 v3 是同一个调用上的标志。传错了,最好的情况是任务失败,最坏的情况是得到一个被站点拒绝、却不给任何提示的 token。我无法确认,面对错误的 reCAPTCHA 版本,真实的应用到底属于哪一种情况。无论哪种,一次识别的时间都白费了。
其他错误会很快、很明显地失败。从以显式方式渲染 reCAPTCHA 的页面读取 sitekey,得到的是单词 explicit,CapSkip 会用 ERROR_GOOGLEKEY 拒绝它。 ERROR_GOOGLEKEY 与 ERROR_PAGEURL 指南 从另一个角度讲了这个错误。要可靠地检测验证码类型,检测器必须按固定的顺序检查各种信号,而这个顺序能避开这两种错误。在构建要面对多个站点的程序之前,检测器也是首先要做的东西。如果你想了解每种类型的背景,可以看 什么是验证码 这一页,里面不写代码,只讲各个类别。
第 1 步:创建项目
创建一个控制台项目,并添加 CapSkip 包。检测器本身只用 .NET 自带的东西:正则表达式和 HttpClient。
dotnet new console -o detect-demo cd detect-demo dotnet add package CapSkip
C# 验证码检测器处理的是文本,所以只需要两个输入:页面的 HTML 和它的地址。普通的请求看到的是服务器在任何 JavaScript 运行之前发来的内容。Google 的 reCAPTCHA 演示页面、Cloudflare 的 Turnstile 演示和 hCaptcha 演示,都把各自的小组件放在这样的 HTML 里。通过 JavaScript 添加小组件的页面则不是这样,“当 HTML 什么都没说时”一节会讲这种情况。一个 HttpClient 只有一个 cookie 容器,这对图片验证码步骤很重要:下面的程序用同一个客户端请求页面和图片。
第 2 步:编写辅助函数
把从这里到第 9 步的所有内容,一个代码块接一个代码块,保存在一个名为 Detector.cs 的文件里。在第 9 步把类关闭之前,这个文件无法编译,而且它依赖 dotnet new console 默认开启的隐式 using。第一个代码块包含 using、两个小的 record 以及最初的几个辅助方法。没有匹配时每个读取方法都返回 null,所以缺失的属性不会抛出异常。
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using CapSkip;
public record Info(string Type, string? Method, Dictionary<string, object?> Args,
string? NeedsBrowser = null, bool Unsupported = false, string[]? Also = null);
record Page(string H, string Srcs, string Js, string Url);
public static class Detector
{
const RegexOptions F = RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant;
const string SiteKeyJs = """sitekey['"]?\s*:\s*['"]([^'"]+)""";
static string? Grab(string pattern, string? text) // first capture group that matched, or null
{
var m = Regex.Match(text ?? "", pattern, F);
for (var i = 1; i < m.Groups.Count; i++) if (m.Groups[i].Success) return m.Groups[i].Value;
return null;
}
static string? Or(params string?[] v) => v.FirstOrDefault(s => !string.IsNullOrEmpty(s));
static string Abs(string url, string rel) => Uri.TryCreate(new Uri(url), rel, out var u) ? u.AbsoluteUri : rel;
static Dictionary<string, object?> Args(params (string, object?)[] kv) => // null values are left out
kv.Where(x => x.Item2 != null).ToDictionary(x => x.Item1, x => x.Item2);Grab 方法返回第一个匹配到的捕获组。Tag 方法按 CSS 类查找开始标签,并拒绝那些只是在更长的类名里包含该名称的类,所以名为 cf-turnstile-response 的类不是 Turnstile 小组件。Attr 方法读取用任一种引号写出的属性,并对 HTML 实体做解码,这对放在属性里的挑战数据很重要。不带引号的属性不会被读取。
第二个代码块把页面变成各项检查可以共用的东西。它会去掉注释和样式块,这样提到小组件类名的 CSS 规则就不会产生匹配,并且把脚本地址和内联 JavaScript 分别收集起来。最后一个辅助方法为 HTML 不够用的页面构建答案。它的 NeedsBrowser 字段保存原因,例如 sitekey 是由 JavaScript 设置的,本文把这称为需要浏览器的结果。
static string? Attr(string? tag, string name) => // attribute value, either quote style, HTML-decoded
Grab("""\s""" + name + """\s*=\s*(?:"([^"]*)"|'([^']*)')""", tag) is { } v ? WebUtility.HtmlDecode(v) : null;
static string? Tag(string cls, string h) => // opening tag that has CSS class cls
Grab("""(<\w+[^>]*\sclass\s*=\s*["'][^"']*(?<![\w-])""" + cls + """(?![\w-])[^"']*["'][^>]*>)""", h);
static string? Opts(string call, string js) => Grab(call + """\([^,]+,\s*\{(.{0,400})""", js);
static Page Parse(string html, string url)
{
var h = Regex.Replace(html, """<!--.*?-->|<style\b.*?</style>""", "", F);
var srcs = Regex.Matches(h, """<script\b[^>]*?\ssrc\s*=\s*["']([^"']+)""", RegexOptions.IgnoreCase);
var js = Regex.Matches(h, """<script\b[^>]*>(.*?)</script>""", F);
return new(h, WebUtility.HtmlDecode(string.Join(" ", srcs.Select(m => m.Groups[1].Value))),
string.Join("\n", js.Select(m => m.Groups[1].Value)), url);
}
static Info Browser(string type, string method, Page p, string why) => // HTML is not enough: do not guess
new(type, method, new() { ["url"] = p.Url }, why);
static readonly (string, string)[] Vendors = { ("arkose", """arkoselabs\.com/v2/|funcaptcha\.com/fc/"""),
("geetest_v4", """gt4\.js|initGeetest4""") };
static Info? Unsup(Page p) => Vendors.Where(v => Regex.IsMatch(p.Srcs + " " + p.Js, v.Item2)) // CapSkip cannot solve these
.Select(v => new Info(v.Item1, null, new(), Unsupported: true)).FirstOrDefault();
static Info? Cf(Page p) => Regex.IsMatch(p.Js, """_cf_chl_opt\s*=\s*\{""") // the "Just a moment..." page, not a widget
? Browser("turnstile_challenge", "turnstile", p, "data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time") : null;
static Info? Hcaptcha(Page p) // before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class
{
if (!Regex.IsMatch(p.Srcs + " " + p.Js, """hcaptcha\.com/1/api\.js""")) return null;
if (Regex.IsMatch(p.Js, """\.setData\s*\(|rqdata""")) // Enterprise rqdata is issued per challenge
return Browser("hcaptcha_enterprise", "hcaptcha", p, "rqdata is issued for one challenge at run time");
var t = Tag("h-captcha", p.H) ?? Tag("g-recaptcha", p.H);
var key = Or(Attr(t, "data-sitekey"), Grab(SiteKeyJs, Opts("""hcaptcha\.render""", p.Js)));
if (key == null) return Browser("hcaptcha", "hcaptcha", p, "no sitekey in the static HTML");
var invisible = Attr(t, "data-size") == "invisible" ? 1 : (int?)null;
return new("hcaptcha", "hcaptcha", Args(("sitekey", key), ("url", p.Url), ("invisible", invisible)));
}第 3 步:hCaptcha,以及 CapSkip 无法识别的类型
这三项检查位于上面第二个代码块的末尾,所以这一步没有需要粘贴的新内容。先处理正确答案是“否”的页面。CapSkip 不识别 FunCaptcha 或 Arkose,GeeTest v4 也不在 GeeTest 方法的范围内,该方法针对的是 v3。如果检测器跳过这些,带有 Arkose 方框的页面就会落到下一项检查,还可能被判成它并不是的东西。所以这项检查最先运行,并返回一个没有方法的结果。
同一个代码块也处理 Cloudflare 的整页检查,也就是 “Just a moment” 中间页。这个页面不是 Turnstile 小组件。turnstile 方法需要从中取得两个值,data 和 pagedata,而普通的请求拿不到它们。检测器会查找页面内联的挑战选项,并回答需要浏览器。 cf_clearance 指南 介绍了通过挑战页面之后要做什么。
接下来是 hCaptcha,排在 reCAPTCHA 之前。无论小组件是复选框、invisible 还是 Enterprise,CapSkip 都能识别,但从 reCAPTCHA 迁移到 hCaptcha 的站点常常在小组件上保留 g-recaptcha 类,只有 hCaptcha 脚本才能区分两者。这项检查从小组件或 hcaptcha.render 调用中读取 sitekey,当小组件的 data-size 设为 invisible 时加上 invisible。如果 Enterprise 页面通过 hcaptcha.setData 给小组件传入 rqdata 值,结果则会是“需要浏览器”,因为这个值只针对一次挑战签发,而且很快过期。
第 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 小组件。带有该类的其他任何元素,都是复选框。
static Info? Recaptcha(Page p) // Enterprise flag, then v3 (render=KEY), then v2
{
var m = Regex.Match(p.Srcs, """/recaptcha/(api|enterprise)\.js(\?\S*)?""");
if (!m.Success) return null;
var render = Grab("""[?&]render=([^&\s]+)""", m.Groups[2].Value); // a v3 key, or "explicit" (a v2 page)
var t = Tag("g-recaptcha", p.H);
var o = Opts("""\.render""", p.Js);
var button = (t ?? "").StartsWith("<button", StringComparison.OrdinalIgnoreCase);
var action = Attr(t, "data-action");
var v3 = (render != null && render != "explicit") || (button && !string.IsNullOrEmpty(action));
var key = Or(render == "explicit" ? null : render, Attr(t, "data-sitekey"), Grab(SiteKeyJs, o));
if (key == null) return Browser("recaptcha", "recaptcha", p, "no sitekey in the static HTML (set by JavaScript)");
var small = Attr(t, "data-size") == "invisible" || Grab("""size['"]?\s*:\s*['"](invisible)""", o) != null;
var invisible = !v3 && (button || small); // a <button> is invisible v2 unless it has data-action (v3)
action = Or(action, Grab("""execute\(\s*[^,)]+,\s*\{\s*['"]?action['"]?\s*:\s*['"]([^'"]+)""", p.Js));
var ent = m.Groups[1].Value == "enterprise";
var a = Args(("sitekey", key), ("url", p.Url), ("enterprise", ent ? 1 : null), ("version", v3 ? "v3" : null),
("action", v3 ? action : null), ("invisible", invisible ? 1 : null));
return new("recaptcha_" + (v3 ? "v3" : invisible ? "v2_invisible" : "v2") + (ent ? "_enterprise" : ""), "recaptcha", a);
}每种结果都对应一次 RecaptchaAsync 调用。复选框是带 sitekey、页面 URL 和一个可选选项字典的 RecaptchaAsync。Invisible 要加 "invisible",Enterprise 要加 "enterprise",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 存在时,它也会读取,并分别作为 action 和 data 传下去。 Turnstile 识别工具页面 列出了这些选项。
GeeTest v3 会给你两个值,其中一个是陷阱。gt 值对站点是固定的,可以放在页面里。challenge 值只能用一次,站点在运行时从一个 register 端点获取它。如果页面同时带有两者,检测器就把它们返回。如果页面只带有 initGeetest 调用,它就回答 HTML 不够用,因为从保存的页面里复制的 challenge 早已失效。
static Info? Turnstile(Page p) // widget only: div.cf-turnstile or turnstile.render
{
if (!p.Srcs.Contains("challenges.cloudflare.com/turnstile/v0/api.js")) return null;
var t = Tag("cf-turnstile", p.H);
var o = Opts("""turnstile\.render""", p.Js);
var key = Or(Attr(t, "data-sitekey"), Grab(SiteKeyJs, o));
if (key == null) return Browser("turnstile", "turnstile", p, "no sitekey in the static HTML");
var action = Or(Attr(t, "data-action"), Grab("""\baction['"]?\s*:\s*['"]([^'"]+)""", o));
var cdata = Or(Attr(t, "data-cdata"), Grab("""\bcData['"]?\s*:\s*['"]([^'"]+)""", o));
return new("turnstile", "turnstile", Args(("sitekey", key), ("url", p.Url), ("action", action), ("data", cdata)));
}
static Info? Geetest(Page p) // v3 only: gt is static, challenge comes from the register endpoint
{
if (!Regex.IsMatch(p.Js, """initGeetest\s*\(""") && !Regex.IsMatch(p.Srcs, """/gt(\.[\d.]+)?\.js""")) return null;
var gt = Grab("""\bgt['"]?\s*:\s*['"]([0-9a-f]{32})""", p.Js);
var ch = Grab("""\bchallenge['"]?\s*:\s*['"]([0-9a-f]{32,34})""", p.Js);
return gt == null || ch == null ? Browser("geetest", "geetest", p, "gt and challenge come from the register endpoint")
: new("geetest", "geetest", Args(("gt", gt), ("challenge", ch), ("url", p.Url)));
}第 6 步:ALTCHA 和 Capy
ALTCHA 在不同代之间改过属性名。版本 1 和 2 使用 challengeurl,内联挑战则用 challengejson。版本 3 及更高版本使用一个叫 challenge 的属性,它既可以是 URL,也可以是挑战数据。检查会读取这三个,并把相对 URL 与页面地址拼合。在 C# 中,选项键是 challenge_url 和 challenge_json。
Capy 在这项检查中不需要小组件 div。它的 key 以 PUZZLE_ 开头,位于小组件脚本地址的 k 参数中,主机不是 https://jp.api.capy.me 时,则作为 api_server 传入。
static Info? Altcha(Page p) // challengeurl / challengejson (v1, v2) or challenge (v3+)
{
var t = Grab("""(<altcha-widget\b[^>]*>)""", p.H);
if (t == null) return null;
var c = Attr(t, "challenge");
var json = Or(Attr(t, "challengejson"), (c ?? "").TrimStart().StartsWith('{') ? c : null);
var url = Or(Attr(t, "challengeurl"), json != null ? null : c);
return json != null ? new("altcha", "altcha", Args(("url", p.Url), ("challenge_json", json)))
: url != null ? new("altcha", "altcha", Args(("url", p.Url), ("challenge_url", Abs(p.Url, url))))
: Browser("altcha", "altcha", p, "no challenge attribute");
}
static Info? Capy(Page p) // the widget script is .../puzzle/get_js/?k=PUZZLE_...
{
var m = Regex.Match(p.Srcs, """((?:https?:)?//[^\s"']+?)/puzzle/get_js/\S*?(PUZZLE_[\w-]+)""");
if (!m.Success) return null;
var full = m.Groups[1].Value.StartsWith("http") ? m.Groups[1].Value : "https:" + m.Groups[1].Value;
var host = full == "https://jp.api.capy.me" ? null : full;
return new("capy", "capy", Args(("sitekey", m.Groups[2].Value), ("url", p.Url), ("api_server", host)));
}第 7 步:CaptchaFox 和 Friendly Captcha
CaptchaFox 小组件通过两种途径出现在页面上。普通版从 cdn.captchafox.com 加载。另一种由托管平台打包,放在共享的 CDN 路径下,所以检测器是在该路径中查找 CaptchaFox 的包名,而不是只看路径本身,因为其他小组件也用这个路径。
Friendly Captcha 是最棘手的。版本 1 和版本 2 使用同样的 div 和同一个 sitekey 命名空间,但协议不同,CapSkip 会为错误的版本返回一个格式正确的 token,而站点会拒绝它。可以通过脚本把两者区分开:版本 2 加载 @friendlycaptcha/sdk 包,版本 1 加载 friendly-challenge。当页面没有给出任何迹象时,检测器拒绝猜测,并回答 HTML 不够用。
const string Mam = "mampkg/@mamdev/core.frontend.libs.captchafox";
static Info? CaptchaFox(Page p) // plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com
{
var t = Tag("captchafox", p.H);
var key = Grab("""captchafox[^"']*/api\.js\?key=([^"'&]+)""", p.Srcs);
if (t == null && key == null) return null;
var mam = p.Srcs.Contains(Mam) ? "https://s.uicdn.com/" + Mam + "/" : null;
var sitekey = Or(Attr(t, "data-sitekey"), key);
if (sitekey == null) return Browser("captchafox", "captchafox", p, "no sitekey in the static HTML");
return new("captchafox", "captchafox", Args(("sitekey", sitekey), ("url", p.Url), ("api_server", mam)));
}
static Info? Friendly(Page p) // v2 loads @friendlycaptcha/sdk (site.min.js), v1 loads friendly-challenge
{
var t = Tag("frc-captcha", p.H);
if (t == null) return null;
var e2 = Attr(t, "data-api-endpoint");
var e1 = Attr(t, "data-puzzle-endpoint");
if (Attr(t, "data-sitekey") == null) return Browser("friendly_captcha", "friendly_captcha", p, "no sitekey in the static HTML");
var v2 = e2 != null || Regex.IsMatch(p.Srcs, """friendlycaptcha/sdk|site(\.compat)?\.min\.js""");
var v1 = e1 != null || Regex.IsMatch(p.Srcs, """friendly-challenge|widget(\.module)?\.min\.js""");
if (v2 == v1) return Browser("friendly_captcha", "friendly_captcha", p, "cannot tell v1 from v2 in the HTML");
return new(v2 ? "friendly_captcha_v2" : "friendly_captcha_v1", "friendly_captcha", Args(("sitekey", Attr(t, "data-sitekey")),
("url", p.Url), ("version", v2 ? "v2" : "v1"), ("api_server", Or(e2, e1))));
}其中每一种都有自己的识别页面,介绍该方法所接受的选项:
- Capy Puzzle 识别工具页面:Capy 答案返回的三个值。
- CaptchaFox 识别工具页面:CaptchaFox 方法所接受的选项。
- Friendly Captcha 识别工具页面:Friendly Captcha 方法。
第 8 步:图片验证码,一次猜测,以及各项检查
图片验证码没有可以查找的厂商脚本,所以这项检查是一种猜测,只在其他检查都没有匹配时才运行。这项检查要找一张标签里带有 captcha 的图片,以及页面上其他位置一个标签里带有 captcha 或 security code 的文本框。凡是标记里没有用到这些词的情况,它每次都漏掉,而且它看不见绘制出来的 canvas。请把命中视为可能,而不是确定。
同一个代码块按顺序列出各项检查,并提供 Detect。这个顺序就是逻辑:先是不支持的厂商,然后是 Cloudflare 页面,然后是 hCaptcha,再是其他各个类别,只有当列表一无所获时才用图片猜测。Detect 方法返回第一个结果,当页面带有不止一类时(例如 v3 脚本加一个 Turnstile 方框),会附加一个 Also 列表。这个列表列出的是类别,而不是带着另一个 key 的第二个 reCAPTCHA 小组件。
static Info? Image(Page p) // a guess: an <img> that says captcha, next to a text box that says captcha
{
const string box = """<input\b(?![^>]*type\s*=\s*["']?(?:hidden|checkbox|submit))[^>]*(?:captcha|security.?code)""";
if (!Regex.IsMatch(p.H, box, RegexOptions.IgnoreCase)) return null;
foreach (Match m in Regex.Matches(p.H, """<img\b[^>]*>""", RegexOptions.IgnoreCase))
if (Regex.IsMatch(m.Value, "captcha", RegexOptions.IgnoreCase) && Attr(m.Value, "src") is { Length: > 0 } src)
return new("image", "normal", Args(("image", Abs(p.Url, src))));
return null;
}
public static Info? Detect(string html, string pageUrl) // every check runs; the first hit wins, the rest are "also"
{
var p = Parse(html, pageUrl);
var found = new[] { Unsup(p), Cf(p), Hcaptcha(p), Recaptcha(p), Turnstile(p), Geetest(p), Altcha(p), Capy(p), CaptchaFox(p), Friendly(p) }
.Where(r => r != null).ToList();
return found.Count == 0 ? Image(p) : found.Count == 1 ? found[0]
: found[0]! with { Also = found.Skip(1).Select(r => r!.Type).ToArray() };
}第 9 步:识别
最后一个代码块把结果分发给正确的 CapSkip 方法。对于 unsupported 或标记为需要浏览器的结果,它会用 InvalidOperationException 拒绝。CapSkip 的方法以位置方式接收主要的值,其余的放在一个选项字典里,所以代码块从参数中取出位置参数,并把其他所有内容按原始 API 名称传下去。图片是用你自己的 HttpClient 下载的,所以它的 cookie 容器会随请求一起发出,因为 SDK 自己下载 URL 时不发送 cookie。该下载的状态码不对时,会抛出 HttpRequestException。
SDK 还没有 hCaptcha 调用,所以 hCaptcha 结果改为交给 SolveHcaptcha。它以 method=hcaptcha 把 sitekey 和页面地址提交到 in.php,然后每 5 秒轮询一次 res.php,直到 token 就绪,正如 API 文档的 hCaptcha 部分 所述。它从你传入的 CapSkipClient 读取地址和 API key,所以不需要额外配置。被拒绝的任务会抛出 SDK 自带的 ApiException 并附带错误代码,因此下面程序中捕获 CapSkipError 的 catch 会处理它。结果在 Code 中携带 token,在 UserAgent 中携带获取它时使用的 User-Agent。
public static async Task<SolveResult> Solve(Info i, CapSkipClient s, HttpClient http)
{
if (i.Unsupported || i.NeedsBrowser != null) throw new InvalidOperationException(i.NeedsBrowser ?? i.Type + " is not supported by CapSkip");
if (i.Method == "hcaptcha") return await SolveHcaptcha(i.Args, s, http);
string K(string k) => (string)i.Args[k]!; // the positional arguments
var o = i.Args.Where(kv => kv.Key is not ("sitekey" or "url" or "gt" or "challenge" or "image"))
.ToDictionary(kv => kv.Key, kv => kv.Value); // everything else is an options entry
return i.Method switch
{
"recaptcha" => await s.RecaptchaAsync(K("sitekey"), K("url"), o),
"turnstile" => await s.TurnstileAsync(K("sitekey"), K("url"), o),
"geetest" => await s.GeetestAsync(K("gt"), K("challenge"), K("url"), o),
"altcha" => await s.AltchaAsync(K("url"), o),
"capy" => await s.CapyAsync(K("sitekey"), K("url"), o),
"captchafox" => await s.CaptchaFoxAsync(K("sitekey"), K("url"), o),
"friendly_captcha" => await s.FriendlyCaptchaAsync(K("sitekey"), K("url"), o),
"normal" => await s.NormalAsync(await ImageData(K("image"), http)), // fetched with YOUR cookies
_ => throw new InvalidOperationException("unknown method " + i.Method),
};
}
static async Task<string> ImageData(string src, HttpClient http) => src.StartsWith("data:") ? src
: "data:image/png;base64," + Convert.ToBase64String(await http.GetByteArrayAsync(src));
static async Task<SolveResult> SolveHcaptcha(Dictionary<string, object?> a, CapSkipClient s, HttpClient http)
{ // no SDK call yet: post to in.php and poll res.php at the address the client already uses
var form = new Dictionary<string, string> { ["method"] = "hcaptcha", ["sitekey"] = (string)a["sitekey"]!,
["pageurl"] = (string)a["url"]!, ["invisible"] = a.ContainsKey("invisible") ? "1" : "0", ["json"] = "1" };
if (a.TryGetValue("data", out var d) && d != null) form["data"] = d.ToString()!;
var key = string.IsNullOrEmpty(s.ApiKey) ? "" : "&key=" + Uri.EscapeDataString(s.ApiKey);
if (key != "") form["key"] = s.ApiKey;
using var sent = await http.PostAsync(s.ApiClient.BaseUrl + "/in.php", new FormUrlEncodedContent(form));
var job = JsonDocument.Parse(await sent.Content.ReadAsStringAsync()).RootElement;
var id = job.GetProperty("request").GetString()!;
if (job.GetProperty("status").GetInt32() != 1) throw new ApiException(id);
for (var n = 0; n < 36; n++) // a solve takes 10 to 30 seconds, so give up after 3 minutes
{
await Task.Delay(5000);
var r = JsonDocument.Parse(await http.GetStringAsync($"{s.ApiClient.BaseUrl}/res.php?action=get&id={id}&json=1{key}")).RootElement;
var value = r.GetProperty("request").GetString()!;
if (value == "CAPCHA_NOT_READY") continue;
if (r.GetProperty("status").GetInt32() != 1) throw new ApiException(value);
return new SolveResult { CaptchaId = id, Code = value, UserAgent = r.TryGetProperty("useragent", out var ua) ? ua.GetString() : null };
}
throw new CapSkip.TimeoutException("hCaptcha was not solved within 3 minutes");
}
}图片识别完成后,用同一个 http 对象把答案作为表单提交回去,这样站点才能看到它在向你显示图片时设置的 cookie。Turnstile 或 hCaptcha 识别的结果还带有一个 UserAgent,在挑战页面上,它必须与 token 一起传递。下面的程序只打印 Code 字段,所以 GeeTest 或 Capy 的结果会显示为原始 JSON。
运行 C# 验证码检测器
用这个代码块替换 Program.cs 的内容。它抓取你的页面,打印 HTTP 状态,打印检测器的发现,并且只有当有方法可调用、且没有缺失项时才调用 CapSkip。它捕获 CapSkipError(SDK 各异常的基类),而不是逐个列出它们。这也避开了一个陷阱:开启隐式 using 时,单独写 TimeoutException 在 System 和 CapSkip 之间有歧义,无法编译。用 dotnet run 运行,后面跟两个连字符和完整的页面地址(包括 https://),如下面的命令所示。端口 8080 是默认值;如果你的不同,请使用 CapSkip 连接设置中显示的端口。
dotnet run -- "https://example.com/login"
这是整个程序:
using CapSkip;
var pageUrl = args.Length > 0 ? args[0] : "https://example.com/login";
var http = new HttpClient(); // one client keeps one cookie jar for the page and the image
http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36");
Info? info;
try
{
var page = await http.GetAsync(pageUrl);
Console.WriteLine((int)page.StatusCode); // a Cloudflare challenge page arrives as a 403
var finalUrl = page.RequestMessage!.RequestUri!.ToString(); // the URL after redirects
info = Detector.Detect(await page.Content.ReadAsStringAsync(), finalUrl);
}
catch (Exception e) when (e is HttpRequestException or InvalidOperationException or TaskCanceledException or NotSupportedException)
{
Console.WriteLine("Could not fetch the page: " + e.Message);
return;
}
if (info is null) { Console.WriteLine("No CAPTCHA found."); return; }
Console.WriteLine($"{info.Type} via {info.Method ?? "nothing"}"
+ (info.Also is null ? "" : " (also " + string.Join(", ", info.Also) + ")"));
try
{
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
var result = await Detector.Solve(info, solver, http);
Console.WriteLine(result.Code.Length > 80 ? result.Code[..80] + "..." : result.Code);
}
catch (InvalidOperationException e) { Console.WriteLine("Not solvable from this HTML: " + e.Message); }
catch (HttpRequestException e) { Console.WriteLine("Image download failed: " + e.Message); }
catch (CapSkipError e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }在带有 reCAPTCHA v2 复选框的页面上,第一行显示类型和方法,第二行显示 token。在模拟运行中,它是这样的:
| 页面 | 程序打印 |
|---|---|
| reCAPTCHA v2 复选框 | 200,然后是 recaptcha_v2,通过 recaptcha 方法,然后是 token |
| hCaptcha 小组件 | 200,然后是 hcaptcha,通过 hcaptcha 方法,然后是 token |
| Cloudflare “Just a moment” 页面 | 403,然后是 turnstile_challenge,通过 turnstile 方法,然后是一条说明 data 和 pagedata 只在运行时才存在的消息 |
| 没有验证码的页面 | 200,然后是 No CAPTCHA found. |
| 返回 404 的地址 | 404,然后是 No CAPTCHA found. |
当 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 设置的 sitekey | 需要浏览器的结果 | 从实时页面读取 key |
| Cloudflare 整页检查 | 需要浏览器的结果 | 在浏览器中读取这些值,请参阅 cf_clearance 指南 |
| HTML 中没有 frc-captcha div 的 Friendly Captcha 脚本 | null | 运行控制台代码,或查看页面源代码 |
| 由 JavaScript 添加的 CaptchaFox 或 Capy 脚本 | null | 运行控制台代码,或查看页面源代码 |
| 站点自身返回的 403 或 503 页面 | null,除非它是 Cloudflare 挑战页面 | 程序先打印状态:封锁页面里没有验证码可找 |
| Arkose、GeeTest v4 | unsupported | CapSkip 无法识别这些 |
| 设置了 rqdata 的 hCaptcha Enterprise | 需要浏览器 | 针对每个任务从实时页面读取新的 rqdata,并将其作为 data 传给 SolveHcaptcha |
| 没有脚本线索的 Friendly Captcha | 需要浏览器的结果 | 检查页面加载的是哪个脚本 |
| 不含 captcha 这个词的图片验证码 | null | 为那个站点添加你自己的规则 |
常见错误
| 错误 | 原因 | 修复 |
|---|---|---|
| ERROR_GOOGLEKEY | key 是单词 explicit,或者是不像 reCAPTCHA key 的 key | 从小组件读取 key,而不是从 render 参数读取 |
| ERROR_CAPTCHA_UNSOLVABLE | 任何失败的任务都是这样的表现,代码不会指出原因。错误的 reCAPTCHA 版本是一个可能的原因 | 先检查检测到的类型,然后参阅 API 文档 |
| ValidationException | 方法不接受的选项,例如 v2 调用上的 action | 使用检测器返回的参数 |
| NetworkException | CapSkip 没有运行,或者主机或端口错误;在 Windows 上,大约 2 秒后才会出现该错误 | 启动 CapSkip 并检查连接设置 |
| Solve 抛出的 InvalidOperationException | 结果是 unsupported,或者需要浏览器 | 调用 Solve 之前先处理这两种情况 |
把识别工具跑在服务器上
检测器只需要 CapSkip 提供两样东西:主机和端口。在 Local 模式下,那就是 127.0.0.1 和你自己的 PC。在 Server 模式下,CapSkip 监听你的网络地址或公网 IP,所以另一台 PC、VPS 或托管平台上运行的检测器,可以通过同样的 API 调用它。在 CapSkipClient 构造函数中更改主机和端口,代码里的其他部分都不用变。建议使用静态公网 IP,以获得稳定的远程访问。Server 模式仍然是你自己的硬件,仍然不计量。请注意,Server 模式会对任何地址开放这个端口,所以请为不应访问它的地址添加 Windows Firewall 阻止规则。请参阅 连接设置 了解如何切换模式。
常见问题
不写代码,能判断一个站点用的是哪种验证码吗?
可以。打开 DevTools,进入 Network 标签页,重新加载页面,查看脚本地址。google.com/recaptcha 或 recaptcha.net 的脚本是 reCAPTCHA,challenges.cloudflare.com 是 Turnstile,js.hcaptcha.com 是 hCaptcha。上面的 Console 代码做的是同样的查找。
为什么 Detect 返回需要浏览器的结果,而不是猜一个?
因为猜错会白白浪费一次完整的识别,还会得到一个被站点拒绝、却不给任何提示的 token。当静态 HTML 中没有 sitekey、GeeTest 的 challenge 或 Friendly Captcha 的版本时,直接说明,比让任务失败来得更快。
CapSkip 能识别 hCaptcha 吗?
可以。CapSkip 通过其 HTTP API(method=hcaptcha)识别 hCaptcha 的复选框、invisible 和 Enterprise 小组件,正如 API 文档 所述。.NET SDK 还没有 hCaptcha 调用,所以上面的 SolveHcaptcha 会自己把任务提交到 in.php。FunCaptcha、Arkose 和 GeeTest v4 仍然不受支持,检测器会把它们报告为 unsupported,这样程序就可以跳过该页面,而不是在上面失败。
托管平台能用我的 CapSkip 来运行检测器吗?
可以,通过 Server 模式。把 CapSkip 设置为监听你的网络 IP 或公网 IP,让 CapSkipClient 构造函数指向那个主机和端口,检测器和 Solve 调用的工作方式就和在一台 PC 上一样。识别工具仍然在你自己的硬件上。
简短版结论
C# 验证码检测器的价值在于先运行:先检测验证码类型,再识别。按固定的顺序读取脚本和标记,把参数交给正确的方法,并把 null、需要浏览器的结果和 unsupported 当作答案,而不是崩溃。CapSkip 运行在你自己的机器上,检查页面时没有任何计费表在走,这正是 验证码识别工具 在每隔几分钟就遇到新站点的爬取中切实可用的原因。关于 .NET SDK 本身,请参阅 C# 验证码识别页面.
其他语言中的同一个检测器:
- Python 验证码类型检测:这是原版,使用同样的 66 个页面。
- Node.js 验证码检测器:同样的检查,不需要额外的包。
