如何在 C# 中识别 ALTCHA 并把 token 原样回传

在 C# 中识别 ALTCHA,没有任何东西需要去辨认。ALTCHA 属于工作量证明,而不是图像识别:站点下发一个挑战,客户端必须暴力枚举出满足它的那个数字。整个过程没有图片、没有音频,也不需要猜测,因此一次识别是确定性的,而且很快。要么找到答案,要么就是挑战本身格式有误或者已经过期。CapSkip 从 1.2.6 版开始支持 ALTCHA,.NET SDK 把它暴露为一个方法,参数是页面 URL 加上挑战。真正让人栽跟头的是之后那一步:token 必须原封不动地按识别器返回的样子写回表单。
你需要什么
- 在 Windows 机器上运行的 CapSkip 1.2.6 或更高版本。ALTCHA 支持就是在该版本中加入的。
- CapSkip 的 .NET 包,它面向 .NET Standard 2.0,因此支持 .NET Framework 4.6.1 及以上、.NET Core 2.0 及以上,以及 .NET 6 及更高版本。
- 小组件所在页面的 URL,以及该小组件获取挑战的接口地址。
- 识别器的地址。Local 模式只在 127.0.0.1 上响应,仅供本机使用;Server 模式监听你的网络地址或公网 IP,这样另一台机器才能访问到它。第 4 步会说明你需要哪一种,两者都位于 连接设置.
# dotnet add package CapSkip dotnet add package CapSkip
第 1 步:找到 ALTCHA 小组件调用的接口地址
其余所有步骤都依赖这一个值,所以先把它拿到。打开 DevTools,切到 Network 面板,然后重新加载小组件所在的页面。小组件会为自己的挑战发起一个请求,路径里通常带有 altcha。这个请求 URL 就是你要传给识别器的值;而该地址返回的 JSON 本身就是挑战文档,你可以用它代替 URL 传入。
不要去猜指定它的那个属性名,因为它在不同的小组件版本之间变过。请直接看页面源码。
| 小组件版本 | 指定挑战的属性 |
|---|---|
| v1 和 v2 | 接口地址用 challengeurl,内联挑战另有一个 challengejson 属性 |
| v3 及以后 | challenge,同一个属性既可以放 URL,也可以放挑战数据 |
native、checkbox 和 switch 这三种显示样式纯粹是视觉上的区别。它们提交的载荷完全相同,这点差异也从不会传到识别工具,所以你不用去分辨自己面对的是哪一种。小组件属性的完整说明见 ALTCHA 自己的集成文档.
第 2 步:识别调用,以及提供挑战的两种方式
一个方法,两个参数:先是页面 URL,然后是携带挑战的选项字典。给它接口地址,CapSkip 就会替你把挑战取回来。
// dotnet add package CapSkip
using CapSkip;
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
// CapSkip fetches the challenge, then brute-forces the counter.
var result = await solver.AltchaAsync(
"https://example.com/signup",
new Dictionary<string, object?>
{
["challenge_url"] = "https://example.com/altcha/challenge",
});
Console.WriteLine(result.Token); // base64 payload for the form field
Console.WriteLine(result.Number); // the counter that satisfied it结果对象上有两个字段只属于 ALTCHA。Token 是表单需要的 base64 载荷,Number 是解出该挑战的计数值。Code 属性里的字符串和 Token 完全一样,用哪个都行,但 Token 是按它要填入的表单字段命名的,在调用处读起来更清楚。极验(GeeTest)的相关字段和 Turnstile 的 user agent 在这里都保持为 null。
Number 值得记录下来。两代 ALTCHA 都会上报它,尽管两者的载荷并不相同:旧版 token 把计数器放在顶层,而工作量证明 v2 的 token 不会,它把计数器留在 solution 对象里。CapSkip 会从自己 API 响应的 solution 对象中把它读出来,因此两代的上报方式完全一致。
改为直接传入挑战文档
如果你的代码已经取到了挑战,直接把文档传进来,就完全不会再发生网络请求。当你本来就在抓取这个页面时,这是更快的路径;当挑战是嵌在 HTML 里而不是来自某个接口时,也应该走这条路径。
// No fetch happens: the document is already here.
var result = await solver.AltchaAsync(
"https://example.com/signup",
new Dictionary<string, object?>
{
["challenge_json"] = new Dictionary<string, object>
{
["algorithm"] = "SHA-256",
["challenge"] = "YOUR_CHALLENGE_HASH",
["salt"] = "YOUR_SALT",
["signature"] = "YOUR_SIGNATURE",
["maxnumber"] = 1000000,
},
});该选项接受一个字典(会自动为你序列化),或者一个你手头已有的 JSON 字符串。同时传入接口地址和挑战文档是允许的,此时内联的文档优先,因为再去抓取只会重新取回你刚刚提供的东西。在高负载下,两条路径的表现也不一样:已经过期的内联挑战会被直接拒绝,而不是白白去做哈希运算;传接口地址则可以让识别器在第一个挑战于排队期间失效时,自己重新抓一个新的。
识别工具支持哪些算法
同一个方法可以处理两代方案。旧版方案支持 SHA-1、SHA-256、SHA-384 和 SHA-512,工作量证明 v2 支持 PBKDF2 和迭代式 SHA。PBKDF2 是 ALTCHA 官方推荐的默认算法,因此这已经覆盖了线上绝大多数站点。
Argon2id 和 scrypt 是例外,它们会被直接拒绝,而不是尝试计算:使用其中之一的任务大约在三分之一秒后返回 ERROR_CAPTCHA_UNSOLVABLE,并且永远不会重试。这是有意为之。重试解决不了内存困难型函数的问题,所以立刻失败胜过假装在忙。对 ALTCHA 来说,这个结果指向的是算法,而不是一张看不清的图片;这个错误码本身也有 一篇专门的指南.
第 3 步:在 token 过期之前,把它原样回传
小组件把它的载荷提交在一个名为 altcha 的表单字段里,所以你的 token 也要填到那里。这一步最容易在无声无息中出错。
// Send it exactly as it came back: no trimming,
// no re-encoding, no reordering.
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["email"] = "[email protected]",
["altcha"] = result.Token!,
});
var response = await http.PostAsync("https://example.com/signup", body);token 是一个 JSON 文档的 base64 编码,文档中的字段都被服务端自己的 HMAC 签名覆盖。任何改动都会让它失效,所以任何看起来像是在做整理的操作都会让提交失败:去掉空白字符、解码后再重新编码,或者按不同的键顺序重建这段 JSON。有些集成方式是从 JSON 请求体的某个字段里读取载荷,而不是从表单字段读取,所以要看清页面自身的提交发送了什么,然后照着做。
这一步失败的另一种方式是时间。挑战的有效窗口很短,有些站点会在两分钟内就关闭它;一旦过期,站点只会用一句干巴巴的验证失败拒绝你的答案,这看起来和答错一模一样。错误信息里没有任何线索告诉你到底是哪一种情况。三个习惯可以避免它:在识别之前才去取挑战,而不是在一轮长流程的开头就取好;在解出 token 的同一个工作单元里就把它提交掉;永远不要一边拿着 token 一边等人填完表单。
真正限制你的并不是客户端自身的轮询超时,因为两个超时值都远长于那个两分钟的窗口。ALTCHA 属于 CPU 计算,而不是一次浏览器会话,所以它走的是默认轮询超时,而不是更长的 reCAPTCHA 超时。
| 构造函数选项 | 默认值 | 它的适用范围 |
|---|---|---|
| defaultTimeout | 120 秒 | ALTCHA 与图片验证码的轮询 |
| recaptchaTimeout | 300 秒 | reCAPTCHA、Turnstile 与极验(GeeTest)的轮询 |
| pollingInterval | 最长 5 秒 | 轮询从 0.25 秒开始,逐步退避到这个值 |
第 4 步:识别工具跑在哪里,以及这需要哪种连接模式
上面的示例使用 127.0.0.1,因为当你的代码和识别器在同一台机器上时,这样写是对的。一旦调用识别器的代码跑在别处,比如容器、构建代理、VPS 或者托管主机,回环地址就不再指向识别器,第一次识别就会抛出 NetworkException。
把 CapSkip 切到 Server 模式,它就会改为监听你的内网地址或公网 IP,这样上面那些环境都能通过 API 访问到它。如果流量要走公网,建议使用静态公网 IP,并配一条只放行你预期地址的防火墙规则。Server 模式只改变识别工具监听的位置,别的什么都不变:硬件仍然是你自己的,用量仍然不计数。把主机从环境变量里读取,这样同一个构建在两种环境下都能用。客户端不会自己读取 CAPSKIP_HOST,所以要把它传给构造函数,就像下面的完整示例那样。
| C# 代码跑在哪里 | 用哪种连接模式 |
|---|---|
| 在运行 CapSkip 的那台机器上,在 IDE 或控制台程序里 | Local 模式。127.0.0.1 在这里确实是对的 |
| 在同一内网的另一台机器上 | Server 模式,使用那台机器的内网地址 |
| 在容器主机、VPS 或托管平台上 | Server mode,配一个固定公网 IP 加一条防火墙规则 |
关于代理,有一点是 ALTCHA 特有的。这里支持代理,但它只用于获取挑战那一次请求。没有浏览器会话需要转发,所以代理对工作量证明本身没有任何影响。
完整可运行示例
// dotnet add package CapSkip
using CapSkip;
var http = new HttpClient();
var solver = new CapSkipClient(
host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
port: 8080);
try
{
var result = await solver.AltchaAsync(
"https://example.com/signup",
new Dictionary<string, object?>
{
["challenge_url"] = "https://example.com/altcha/challenge",
});
// Submit here, while the challenge is still fresh.
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["email"] = "[email protected]",
["altcha"] = result.Token!,
});
var response = await http.PostAsync("https://example.com/signup", body);
Console.WriteLine($"{(int)response.StatusCode} after counter {result.Number}");
}
catch (ApiException ex)
{
// ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
Console.WriteLine($"refused: {ex.Message}");
}
catch (CapSkip.TimeoutException)
{
Console.WriteLine("gave up waiting; defaultTimeout is 120 seconds");
}其他类型的用法完全相同,只是换一个方法。RecaptchaAsync 接受 sitekey 和页面 URL,TurnstileAsync 与 GeetestAsync 的用法一样,图片识别则是一次 base64 调用。完整的方法列表见 C# 验证码识别页面.
需要的参数不止 sitekey 的类型只有挑战页 Turnstile。它额外需要哪些值,请参见 C# 挑战页指南.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 站点返回一句干巴巴的验证失败,而 token 看起来没问题 | 挑战在表单提交之前就已过期 | 在同一个工作单元里完成获取、识别和提交 |
| 大约三分之一秒后,在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE | 该挑战使用了 Argon2id 或 scrypt | 没什么可重试的。这两种算法是按设计直接拒绝的 |
| 调用时抛出 ValidationException | 两个挑战选项都没有提供,或者传入了 ALTCHA 不接受的选项 | 传挑战接口地址或挑战文档,其余的一律去掉 |
| 第一次识别时抛出 NetworkException | CapSkip 没有在运行,或者主机和端口不对 | 启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式 |
| 结果上的 Token 属性读出来是 null | 只有 ALTCHA 的结果才会填充 Token | 调用 AltchaAsync。在 ALTCHA 的结果中,Code 属性保存的是同一个字符串 |
| 编译失败,报 TimeoutException 有歧义 | CapSkip 和 System 都定义了这个短名称 | 把 CapSkip.TimeoutException 写全,或者改为捕获 CapSkipError |
| 日志显示已经识别成功的 token 却被表单拒绝 | 某个环节对载荷做了重新编码、裁剪或键顺序调整 | 把字符串原样直接传过去,不要碰它 |
常见问题
在 C# 中识别 ALTCHA 需要浏览器吗?
不需要,而这正是它好用的地方。ALTCHA 给出的是一道哈希题,而不是要你去看的东西,所以全部工作都在 CPU 上完成,几毫秒就结束。你不需要 WebDriver,不需要无头 Chrome,也不需要 user agent。一个带 HttpClient 的控制台程序就够了,这也意味着它可以轻松跑在后台工作服务、队列消费者或构建步骤里,而在这些地方驱动浏览器会很别扭。
跑在托管平台上的 .NET 程序能连到识别工具吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,然后把 CAPSKIP_HOST 指向该地址。容器宿主机、VPS、CI 代理或者托管应用服务,连接方式都一样,走的是同一套 HTTP API。如果链路要经过公网,请使用固定的公网 IP,并用防火墙规则加以限制。以上每一种情况下,识别器都仍然运行在你自己的硬件上,因此授权和识别次数都不会有任何变化。
我该传接口地址还是挑战文档?
除非你已经拿到了挑战文档,否则就传接口地址。它只是选项字典里的一项,可以替你省下一次请求;而且如果挑战在排队期间失效,识别器会自己重新抓一个。什么时候该传文档:你的爬虫已经从页面上读到了它,挑战直接嵌在 HTML 里而不是由接口下发,或者抓取它需要你的代码有而识别器没有的 cookie 或请求头。最后这种情况下,值得了解一下代理选项,因为对 ALTCHA 来说,它只作用于抓取这一步。
为什么我的计数值每次都不一样?
因为它是那一个具体挑战的答案,而不是站点的某种属性。每个挑战都带有自己的 salt,所以满足它的数字每次下发都会变,而且可能落在挑战允许的 maxnumber 以内的任何位置。计数器很大只说明需要做更多哈希,体现出来就是多花几毫秒,仅此而已。它在日志里很有用,可以证明工作确实做过;但拿来缓存则毫无意义。
简短版结论
从小组件上读出挑战接口地址,把它连同页面 URL 一起传给那个唯一的 ALTCHA 方法,然后把 token 原样提交回名为 altcha 的字段。抓取、识别和提交请放在同一段逻辑里,因为挑战窗口可能在两分钟内就关闭,而过期的挑战看起来和答错一模一样。只有 Argon2id 和 scrypt 会给你 ERROR_CAPTCHA_UNSOLVABLE,它们是被直接拒绝的,而不是尝试计算。一旦调用方的代码不再和识别器在同一台机器上,就立刻切换到 Server 模式。
- 挑战是什么、这个类型怎么运作: ALTCHA 验证码识别页面.
- .NET 包暴露的其他所有方法: C# 与 .NET 验证码识别页面.
最后还有一点,它会改变你设计重试的方式。由于 本地验证码识别工具 是在你本来就拥有的机器上计算工作量证明,重试一个已过期的挑战只花掉你自己 CPU 的几毫秒,除此之外没有任何代价,所以你完全负担得起去取一个新的挑战,而不必守着一个已经失效的。
