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

captcha detector in c# - CAPTCHA Detector in C#: Find the Type, Call 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))));
    }

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

第 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 v4unsupportedCapSkip 无法识别这些
设置了 rqdata 的 hCaptcha Enterprise需要浏览器针对每个任务从实时页面读取新的 rqdata,并将其作为 data 传给 SolveHcaptcha
没有脚本线索的 Friendly Captcha需要浏览器的结果检查页面加载的是哪个脚本
不含 captcha 这个词的图片验证码null为那个站点添加你自己的规则

常见错误

错误原因修复
ERROR_GOOGLEKEYkey 是单词 explicit,或者是不像 reCAPTCHA key 的 key从小组件读取 key,而不是从 render 参数读取
ERROR_CAPTCHA_UNSOLVABLE任何失败的任务都是这样的表现,代码不会指出原因。错误的 reCAPTCHA 版本是一个可能的原因先检查检测到的类型,然后参阅 API 文档
ValidationException方法不接受的选项,例如 v2 调用上的 action使用检测器返回的参数
NetworkExceptionCapSkip 没有运行,或者主机或端口错误;在 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# 验证码识别页面.

其他语言中的同一个检测器: