如何用 C# 构建最小 API 验证码识别端点

minimal api captcha solver - How to Build a Minimal API CAPTCHA Solver Endpoint in C#

要构建最小 API 验证码识别服务,就在 ASP.NET Core 中把一个 CapSkipClient 注册为单例。提供一个接收 sitekey 和页面 URL 的 POST 端点。给每次调用加上时限和一个 SemaphoreSlim,并把 SDK 的四种异常映射为 HTTP 状态码。这样,你用任何语言编写的爬虫、后台程序和脚本,只需调用一个内部地址,永远看不到 CapSkip 的端口或密钥。CapSkip 运行在你自己的 Windows 机器上,所以这个服务同样没有按次识别的费用。本指南用大约 120 行代码构建这个服务,并展示在 CapSkip 响应慢、已关闭或毫无回应时,每个部分会怎么表现。

你需要什么

  • 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 会带你完成首次启动的每一步。
  • 已安装 .NET 10 SDK。下面的所有内容都在 Windows 11 上运行过,使用 SDK 10.0.100、ASP.NET Core 10.0.0 和 CapSkip NuGet 包 1.3.0。
  • 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
  • 一个带有 reCAPTCHA v2 复选框的页面。服务需要它的 sitekey 和它的地址,页面的其他信息一概不需要。
  • CapSkip 的地址。在 Local 模式下,它只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的服务就能通过 API 调用它。两种模式的设置位置都是 连接设置.

本指南中的数据来自同一台 PC 上一个为测试搭建的模拟 CapSkip。ASP.NET Core 和 SDK 都是真实的。模拟程序会让每次识别保持一段固定的时间,除非测试另有说明,否则是两秒,而真实的识别耗时会有所不同。这里的一切都没有在真实的 CapSkip 或真实的站点上运行过。

为什么要在 CapSkip 前面放一个服务

如果只有一个 C# 应用在识别验证码,直接在该应用里调用 SDK 即可。 C# 验证码识别页面 对此有详细介绍。当多个调用方共用一个 CapSkip 时,服务才值得搭建。这时有四件事适合放在同一个地方处理。

  • 一个时限。.NET SDK 默认最多轮询 300 秒,所以每个调用方都得自带一个计时器。
  • 一个上限。CapSkip 同时只运行固定数量的识别,多出来的任务要等待。你自己掌控的队列,比 CapSkip 内部的队列更容易理解和推断。
  • 一个密钥。调用方拿到的是服务密钥。CapSkip 的地址和 API 密钥保留在服务的设置里。
  • 任何语言。Python 脚本、PHP 站点和 PowerShell 任务都能使用 HTTP 和 JSON。

第 1 步:创建项目

web 模板就是空的 ASP.NET Core 项目:一个 Program.cs,没有控制器。把项目放在它自己的文件夹里。安装包只会修改项目文件。

# "web" is the empty ASP.NET Core template. This guide uses .NET SDK 10.
dotnet new web -n CapSkipSolver -f net10.0
cd CapSkipSolver
dotnet add package CapSkip

第 2 步:把设置放进 appsettings.json

CapSkip 部分保存地址、识别工具的 API 密钥、需要匹配的 CapSkip 线程数,以及以秒为单位的时限。Solver 部分保存你的调用方将要发送的密钥。运行任何东西之前,请替换这两个占位符。

{
  "CapSkip": {
    "Host": "127.0.0.1",
    "Port": 8080,
    "ApiKey": "YOUR_API_KEY",
    "MaxThreads": 10,
    "DeadlineSeconds": 120
  },
  "Solver": {
    "ApiKey": "YOUR_SERVICE_KEY"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

任何值都可以被环境变量覆盖。双下划线代表冒号,所以 CapSkip__Port 会覆盖 CapSkip:Port。标题为“把服务运行在 CapSkip 之外的机器上”的那一节用这个办法,让服务指向另一台机器,而不必编辑文件。

第 3 步:读取设置,没有密钥就拒绝启动

把模板的 Program.cs 里的全部内容替换成下面的代码块,并把每个新代码块加在前一个的下面。项目要到第 10 步之后才能构建,因为 record 和辅助函数放在最后。

Program.cs 的开头把 CapSkip 部分绑定到一个 record,并读取服务密钥。密钥检查使用 IsNullOrWhiteSpace,而不是 null 检查加 throw。在模拟程序上,空字符串能通过 null 检查,服务因此启动起来,随后会接受每一个带空请求头的请求。

这个代码块里还有那个 .NET 特有的陷阱。System 和 CapSkip 都定义了一个名为 TimeoutException 的类。在启用隐式 using 并写了 using CapSkip 时,直接写 TimeoutException 无法编译。针对 System.TimeoutException 的 switch 分支能编译,却永远匹配不上 SDK 的异常。在这个服务里,这会把 SDK 超时送进兜底分支,返回 500 而不是 504。最后一行 using 给 SDK 的那个类起了单独的名字。

// Program.cs
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using CapSkip;
// System and CapSkip both define TimeoutException, so give the SDK's
// one its own name.
using SolverTimeout = CapSkip.TimeoutException;

var builder = WebApplication.CreateBuilder(args);

// Reads the "CapSkip" section of appsettings.json. The environment
// variable CapSkip__Port overrides CapSkip:Port (__ stands for a colon).
var cfg = builder.Configuration.GetSection("CapSkip").Get<CapSkipSettings>()
    ?? new CapSkipSettings();
var serviceKey = builder.Configuration["Solver:ApiKey"];
if (string.IsNullOrWhiteSpace(serviceKey))
    throw new InvalidOperationException("Set Solver:ApiKey before starting.");

第 4 步:注册一个客户端和一个闸门

CapSkipClient 可以放心用作单例。在 SDK 源码里,它不保存按调用区分的状态,每次调用都会复制 options 字典,而且所有实例共用同一个静态 HttpClient。没有什么需要释放,也不需要 IHttpClientFactory。SDK 使用一个私有的静态 HttpClient,不接受你传入的。长期存活的连接不会察觉 DNS 变化,所以 CapSkip 主机请使用 IP 地址。

SemaphoreSlim 就是闸门。给它的槽位数等于 CapSkip 的线程数,也就是 CapSkip 中的 Max. Threads 设置(默认为 10)。AddProblemDetails 加上 UseStatusCodePages,会让 ASP.NET Core 自己写出的 400、404、405 和 415 响应,以与我们的响应相同的格式返回。没有 UseStatusCodePages 的话,这些响应没有正文。

builder.Services.AddSingleton(cfg);
// One client for the whole process. It keeps no per-call state.
builder.Services.AddSingleton(new CapSkipClient(
    cfg.ApiKey, cfg.Host, cfg.Port, recaptchaTimeout: cfg.SdkTimeoutSeconds));
// One slot per CapSkip thread (Max. Threads in the CapSkip settings).
builder.Services.AddSingleton(new SemaphoreSlim(cfg.MaxThreads));
// With these two calls, 400, 404, 405 and 415 also come back as
// ProblemDetails.
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseStatusCodePages();

第 5 步:检查服务密钥

网络地址上不受保护的识别端点,对所有能访问该端口的人来说就是一个免费的识别工具。这个中间件保护 /solve 路径。FixedTimeEquals 无论字节在哪里不同,比较所用的时间都相同,而且遇到长度不同时返回 false,而不是抛出异常。在模拟程序上,缺少密钥、密钥错误和密钥过长都得到了 401。

它返回 401 而不是 403,因为这里只有一种凭据,而它缺失或错误。中间件在模型绑定之前运行,所以没有密钥且 JSON 正文损坏的请求,得到的是 401,而不是 400。

// Only callers that know the service key may use /solve. FixedTimeEquals
// takes the same time wherever the first wrong byte is.
var expected = Encoding.UTF8.GetBytes(serviceKey);
app.Use(async (ctx, next) =>
{
    var sent = Encoding.UTF8.GetBytes(ctx.Request.Headers["X-API-Key"].ToString());
    if (ctx.Request.Path.StartsWithSegments("/solve")
        && !CryptographicOperations.FixedTimeEquals(sent, expected))
    {
        await Fail(401, "Missing or wrong X-API-Key").ExecuteAsync(ctx);
        return;
    }
    await next(ctx);
});

第 6 步:添加健康检查

SDK 没有 ping 调用,所以健康检查端点会向 CapSkip 的端口打开一个 TCP 连接,三秒后放弃。CapSkip 关闭时,它大约在两秒后返回 503,因为 Windows 拒绝回环连接就是这么慢。它能告诉负载均衡器或监控脚本:服务在运行,而 CapSkip 没有。它不检查识别能否正常工作。

// CapSkip has no ping call, so check that its port accepts a connection.
app.MapGet("/health", async (CapSkipSettings s, CancellationToken ct) =>
{
    using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);
    probe.CancelAfter(TimeSpan.FromSeconds(3));
    try
    {
        using var tcp = new TcpClient();
        await tcp.ConnectAsync(s.Host, s.Port, probe.Token);
        return Results.Ok(new { status = "ok" });
    }
    catch (Exception ex) when (ex is SocketException or OperationCanceledException)
    {
        return Fail(503, "CapSkip is not reachable");
    }
});

第 7 步:校验请求并启动时限

record 参数不会让缺失的字段变成错误。System.Text.Json 会把该属性留成 null,所以处理程序要自己检查 sitekey 和 URL,并以验证问题(validation problem)作为响应。

时限就是一个链接的 CancellationTokenSource。调用方断开连接(RequestAborted)或者时限到了(CancelAfter)时,它就会被取消。之后的每一步都使用它的令牌,所以只需要看一个时钟。

app.MapPost("/solve", async (SolveRequest req, CapSkipClient solver,
    SemaphoreSlim gate, CapSkipSettings s, HttpContext http) =>
{
    if (string.IsNullOrWhiteSpace(req.Sitekey)
        || !Uri.IsWellFormedUriString(req.Url, UriKind.Absolute))
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["request"] = ["sitekey and an absolute url are required"]
        });

    // One token for the whole request. It is cancelled when the caller
    // hangs up (RequestAborted) or when the deadline passes.
    using var deadline = CancellationTokenSource.CreateLinkedTokenSource(
        http.RequestAborted);
    deadline.CancelAfter(TimeSpan.FromSeconds(s.DeadlineSeconds));

第 8 步:等待槽位、识别并释放

等待槽位用的是同一个令牌,另外还有它自己的 QueueSeconds 限制(设置 record 中为 20)。如果所有槽位在这么长时间里都一直忙着,调用方会得到 503,可以重试。没有这个限制的话,请求会一直待在队列里,直到时限到了才结束它。

CapSkipError 是 SDK 四种异常的基类,catch 里同时写了它和取消异常。RecaptchaAsync 也接收这个令牌。SDK 把它传给每一次轮询请求,所以时限会取消正在进行的 HTTP 调用,而不是等它结束。finally 块只在槽位确实已被占用时才释放它。释放一个你从未持有的槽位,会多放行一次识别,超出 CapSkip 能同时运行的数量。

    var held = false;
    try
    {
        // Wait for a free slot, but not longer than QueueSeconds.
        if (!await gate.WaitAsync(TimeSpan.FromSeconds(s.QueueSeconds), deadline.Token))
            return Fail(503, "All solver slots are busy");
        held = true;
        var result = await solver.RecaptchaAsync(
            req.Sitekey, req.Url, req.Options(), deadline.Token);
        return Results.Ok(new SolveResponse(result.Code));
    }
    catch (Exception ex) when (ex is CapSkipError or OperationCanceledException)
    {
        return ToProblem(ex, http.RequestAborted.IsCancellationRequested,
            deadline.IsCancellationRequested);
    }
    finally
    {
        if (held) gate.Release();
    }
});

第 9 步:把异常转换成状态码

这一部分是调用方最容易注意到的。switch 分支的顺序很重要,因为 SDK 以两种不同的方式报告被取消的调用。被取消的提交会表现为 NetworkException(“The operation was canceled”)。被取消的轮询会表现为 TaskCanceledException。如果按异常类型映射,就会告诉调用方 CapSkip 宕机了,而实际上是时限到了。所以要先检查那两个标志。

app.Run();

static IResult Fail(int status, string title, string? detail = null) =>
    Results.Problem(statusCode: status, title: title, detail: detail);

// Check the two flags first: a cancelled submit surfaces as
// NetworkException, a cancelled poll as TaskCanceledException.
static IResult ToProblem(Exception ex, bool callerLeft, bool deadlinePassed) => ex switch
{
    _ when callerLeft => Results.StatusCode(499),
    _ when deadlinePassed => Fail(504, "Solve deadline exceeded"),
    SolverTimeout => Fail(504, "CapSkip timed out"),
    ValidationException => Fail(400, "Bad solve parameters", ex.Message),
    ApiException when ex.Message.Contains("UNSOLVABLE") => Fail(422, "CAPTCHA could not be solved"),
    ApiException => Fail(502, "CapSkip refused the request"),
    NetworkException => Fail(503, "CapSkip is not reachable"),
    _ => Fail(500, "Unexpected error"),
};

SDK 的 ApiException 把它的文字放在 message 里,没有 code 属性。无法识别的情况是通过 message 中的 UNSOLVABLE 这个词来匹配的。下面的每个状态码都是在模拟程序上故意触发过的。

状态码发生了什么用时
503CapSkip 已关闭,所以提交被拒绝2.1 s
422CapSkip 报告了 ERROR_CAPTCHA_UNSOLVABLE3.8 s
502CapSkip 拒绝了服务自己的 API 密钥不到 0.1 秒
400v2 请求带了 action,而只有 v3 接受它不到 0.1 秒
504CapSkip 毫无回应,时限到了(测试时限为 6 秒)6.0 s
504SDK 自己的 5 秒超时先触发了7.8 s

502 是有意为之的。被拒绝的 CapSkip 密钥是服务的错误,不是调用方的错误,所以它不是 401 或 403。最后一行比 5 秒长,是因为 SDK 只在每次等待之后才检查它的时钟。它先等 0.25 秒,然后把等待时间翻倍,最多到 5 秒。从 3.75 秒开始的那次等待持续 4 秒,所以超时在 7.75 秒触发。同样的节奏也解释了 3.8 秒那一行:结果在 2 秒时就绪,在 3.75 秒的检查中被发现。

有一种失败永远得不到提前的响应。如果 CapSkip 在接受任务之后关闭,SDK 会把每一次被拒绝的轮询当作尚未就绪。调用方于是要等满整个时限(在模拟程序上,8 秒时限下大约是 8.0 秒),最后得到 504。只有被拒绝的提交才会很快得到 503。

第 10 步:添加 record

设置 record 带有默认值。请求 record 只用调用方发送的字段来构建 options 字典。v3 调用方可以发送 version 和 action,v2 调用方两者都不发送。SdkTimeoutSeconds 是 SDK 自己的限制,QueueSeconds 是请求等待槽位的最长时间。两者都不在 appsettings.json 里,所以使用默认值。让 SDK 的限制高于时限,这样先触发的就是时限。

record CapSkipSettings(string Host = "127.0.0.1", int Port = 8080,
    string ApiKey = "capskip", int MaxThreads = 10, int DeadlineSeconds = 120,
    int QueueSeconds = 20, int SdkTimeoutSeconds = 300);

record SolveRequest(string Sitekey, string Url, string? Version = null, string? Action = null)
{
    // Extra reCAPTCHA options, only when the caller sent them.
    public Dictionary<string, object?> Options()
    {
        var o = new Dictionary<string, object?>();
        if (Version is not null) o["version"] = Version;
        if (Action is not null) o["action"] = Action;
        return o;
    }
}

record SolveResponse(string Token);

在 Windows 上运行

使用 dotnet run 的 urls 选项,不要指望 ASPNETCORE_URLS 变量。web 模板自带一个 launchSettings.json 文件,里面是一个随机端口,而 dotnet run 会读取它。在测试用的 PC 上,设置了 ASPNETCORE_URLS 的普通 dotnet run 忽略了这个变量,监听的是启动配置文件里的端口,并切换到了 Development 环境。urls 选项胜过启动配置文件,而 no-launch-profile 开关让这个变量重新生效,并得到了 Production 环境。只用 urls 选项时,启动配置文件里的 Development 环境仍然开着,所以如果你想要 Production,也要加上这个开关。在没有启动配置文件、也没有设置 URL 时,Kestrel 会回退到 localhost:5000,这一点这里没有测试过。

如果只在同一台 PC 上使用,就绑定到 127.0.0.1。这样网络上的其他设备都无法访问这个服务。

# Local mode: CapSkip and this service run on the same PC. The service
# listens on 127.0.0.1 only, so no other machine can reach it.
dotnet run --urls http://127.0.0.1:5080

如果调用方在其他机器上,就绑定到 0.0.0.0,意思是所有网络适配器。在测试用的 PC 上,用正确的密钥,这个地址可以通过它自己的局域网 IP 响应,用错误的密钥则被 401 拒绝。绑定到 127.0.0.1 的服务拒绝了局域网地址。先设置一个真正的服务密钥。服务密钥还会以明文通过普通 HTTP 在网络上传输,所以只在你信任的网络上这样用。Windows Firewall 可能会请求你允许这个程序,所以请检查它为该端口创建了什么规则。

# Callers on other machines: 0.0.0.0 means every network adapter.
# Put the service key in place first, because anyone on the network can
# reach this port.
dotnet run --urls http://0.0.0.0:5080

把服务运行在 CapSkip 之外的机器上

服务不必和 CapSkip 在同一台 PC 上。把 CapSkip 放在你控制的一台 Windows 机器上以 Server 模式运行,最好有静态公网 IP。用两个环境变量让服务指向它。测试把这种结构跑在一个绑定到局域网地址的模拟程序上,一次识别用了 1.85 秒。

# The service runs on another PC and CapSkip runs in Server mode on
# 203.0.113.10. Environment variables override appsettings.json.
$env:CapSkip__Host = '203.0.113.10'
$env:CapSkip__Port = '8080'
dotnet run --urls http://0.0.0.0:5080

有三点要注意。在 CapSkip 中开启 API key validation,并在设置的 CapSkip 部分使用同一个密钥。SDK 构建的是普通 http 地址,所以这个密钥会以明文在网络上传输。CapSkip 还会为它的端口添加一条允许任何地址的规则,所以要为每一个不该访问它的地址添加一条阻止规则。设置指南中的 连接设置 一节列出了这两种模式。

用 PowerShell 或 Python 调用它

调用方发送 sitekey、页面 URL 和服务密钥。响应是一个 JSON 对象,只有一个字段 token。请在大约两分钟内使用这个 token,因为 reCAPTCHA token 会过期,而且只能使用一次。 token 过期指南 详细讲解了这个计时机制。

# Ask the service for a token. It answers when the solve finishes.
$body = @{
    sitekey = 'YOUR_SITEKEY'
    url     = 'https://example.com/page-with-recaptcha'
} | ConvertTo-Json
$r = Invoke-RestMethod -Method Post -Uri http://127.0.0.1:5080/solve `
    -Headers @{ 'X-API-Key' = 'YOUR_SERVICE_KEY' } `
    -ContentType 'application/json' -Body $body
$r.token

其他任何语言的用法都一样。下面的 Python 调用方设置的超时比服务的时限更长。这样,服务返回的 504 会在调用方自己的计时器放弃之前到达。

import requests

# The caller's timeout must be longer than DeadlineSeconds (120 here),
# so the 504 from the service arrives before your own timeout fires.
r = requests.post(
    "http://127.0.0.1:5080/solve",
    headers={"X-API-Key": "YOUR_SERVICE_KEY"},
    json={"sitekey": "YOUR_SITEKEY", "url": "https://example.com/page-with-recaptcha"},
    timeout=130,
)
print(r.status_code, r.json())

闸门能做什么,不能做什么

在模拟程序里把 CapSkip 的线程上限设为 3,同时发出十个请求时,闸门让送到模拟程序的任务最多只有三个。没有闸门时,十个任务全都送到了模拟程序,其中七个在等待线程。闸门不会提高速度,因为无论有没有它,都是三个线程在决定节奏。每次识别保持一秒时,十个请求在有闸门时用了大约 7.2 秒,没有闸门时用了 7.9 秒。提升不到一秒。它的价值体现在调用方放弃的时候。

在那次运行中,每个调用方在 4 秒后放弃,而每次识别需要 6 秒。有闸门时,有三到五个任务送到了 CapSkip,因为等待者可以拿到一个在它自己的时限之前一刻释放的槽位。八秒后没有任何任务还在等待。没有闸门时,十个任务全都送到了 CapSkip,调用方放弃六秒后,仍有七个未完成。没有人在等这些识别的结果,但它们仍然占着线程。

还剩下一个缺口。调用方断开连接时,服务会停止轮询并释放它的槽位。SDK 没有取消调用,所以要假定 CapSkip 会把这个任务完成,并在那之前一直占用一个线程。模拟程序就是这样表现的。因此,在一阵集中取消之后,槽位计数可能会领先于 CapSkip 真实的线程数,直到这些任务完成。真实的 CapSkip 没有测试过,它的 Row Timeout 设置限制了一个任务最多能运行多久。

完整的 Program.cs

这是把第 3 步到第 10 步按顺序拼在一起的结果。把它粘贴到模板的 Program.cs 上,覆盖原有内容。

// Program.cs
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using CapSkip;
// System and CapSkip both define TimeoutException, so give the SDK's
// one its own name.
using SolverTimeout = CapSkip.TimeoutException;

var builder = WebApplication.CreateBuilder(args);

// Reads the "CapSkip" section of appsettings.json. The environment
// variable CapSkip__Port overrides CapSkip:Port (__ stands for a colon).
var cfg = builder.Configuration.GetSection("CapSkip").Get<CapSkipSettings>()
    ?? new CapSkipSettings();
var serviceKey = builder.Configuration["Solver:ApiKey"];
if (string.IsNullOrWhiteSpace(serviceKey))
    throw new InvalidOperationException("Set Solver:ApiKey before starting.");

builder.Services.AddSingleton(cfg);
// One client for the whole process. It keeps no per-call state.
builder.Services.AddSingleton(new CapSkipClient(
    cfg.ApiKey, cfg.Host, cfg.Port, recaptchaTimeout: cfg.SdkTimeoutSeconds));
// One slot per CapSkip thread (Max. Threads in the CapSkip settings).
builder.Services.AddSingleton(new SemaphoreSlim(cfg.MaxThreads));
// With these two calls, 400, 404, 405 and 415 also come back as
// ProblemDetails.
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseStatusCodePages();

// Only callers that know the service key may use /solve. FixedTimeEquals
// takes the same time wherever the first wrong byte is.
var expected = Encoding.UTF8.GetBytes(serviceKey);
app.Use(async (ctx, next) =>
{
    var sent = Encoding.UTF8.GetBytes(ctx.Request.Headers["X-API-Key"].ToString());
    if (ctx.Request.Path.StartsWithSegments("/solve")
        && !CryptographicOperations.FixedTimeEquals(sent, expected))
    {
        await Fail(401, "Missing or wrong X-API-Key").ExecuteAsync(ctx);
        return;
    }
    await next(ctx);
});

// CapSkip has no ping call, so check that its port accepts a connection.
app.MapGet("/health", async (CapSkipSettings s, CancellationToken ct) =>
{
    using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);
    probe.CancelAfter(TimeSpan.FromSeconds(3));
    try
    {
        using var tcp = new TcpClient();
        await tcp.ConnectAsync(s.Host, s.Port, probe.Token);
        return Results.Ok(new { status = "ok" });
    }
    catch (Exception ex) when (ex is SocketException or OperationCanceledException)
    {
        return Fail(503, "CapSkip is not reachable");
    }
});

app.MapPost("/solve", async (SolveRequest req, CapSkipClient solver,
    SemaphoreSlim gate, CapSkipSettings s, HttpContext http) =>
{
    if (string.IsNullOrWhiteSpace(req.Sitekey)
        || !Uri.IsWellFormedUriString(req.Url, UriKind.Absolute))
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["request"] = ["sitekey and an absolute url are required"]
        });

    // One token for the whole request. It is cancelled when the caller
    // hangs up (RequestAborted) or when the deadline passes.
    using var deadline = CancellationTokenSource.CreateLinkedTokenSource(
        http.RequestAborted);
    deadline.CancelAfter(TimeSpan.FromSeconds(s.DeadlineSeconds));

    var held = false;
    try
    {
        // Wait for a free slot, but not longer than QueueSeconds.
        if (!await gate.WaitAsync(TimeSpan.FromSeconds(s.QueueSeconds), deadline.Token))
            return Fail(503, "All solver slots are busy");
        held = true;
        var result = await solver.RecaptchaAsync(
            req.Sitekey, req.Url, req.Options(), deadline.Token);
        return Results.Ok(new SolveResponse(result.Code));
    }
    catch (Exception ex) when (ex is CapSkipError or OperationCanceledException)
    {
        return ToProblem(ex, http.RequestAborted.IsCancellationRequested,
            deadline.IsCancellationRequested);
    }
    finally
    {
        if (held) gate.Release();
    }
});

app.Run();

static IResult Fail(int status, string title, string? detail = null) =>
    Results.Problem(statusCode: status, title: title, detail: detail);

// Check the two flags first: a cancelled submit surfaces as
// NetworkException, a cancelled poll as TaskCanceledException.
static IResult ToProblem(Exception ex, bool callerLeft, bool deadlinePassed) => ex switch
{
    _ when callerLeft => Results.StatusCode(499),
    _ when deadlinePassed => Fail(504, "Solve deadline exceeded"),
    SolverTimeout => Fail(504, "CapSkip timed out"),
    ValidationException => Fail(400, "Bad solve parameters", ex.Message),
    ApiException when ex.Message.Contains("UNSOLVABLE") => Fail(422, "CAPTCHA could not be solved"),
    ApiException => Fail(502, "CapSkip refused the request"),
    NetworkException => Fail(503, "CapSkip is not reachable"),
    _ => Fail(500, "Unexpected error"),
};

record CapSkipSettings(string Host = "127.0.0.1", int Port = 8080,
    string ApiKey = "capskip", int MaxThreads = 10, int DeadlineSeconds = 120,
    int QueueSeconds = 20, int SdkTimeoutSeconds = 300);

record SolveRequest(string Sitekey, string Url, string? Version = null, string? Action = null)
{
    // Extra reCAPTCHA options, only when the caller sent them.
    public Dictionary<string, object?> Options()
    {
        var o = new Dictionary<string, object?>();
        if (Version is not null) o["version"] = Version;
        if (Action is not null) o["action"] = Action;
        return o;
    }
}

record SolveResponse(string Token);

最小 API 验证码识别服务的常见错误

你所看到的原因修复
关于 TimeoutException 的 CS0104System 和 CapSkip 都定义了它使用第 3 步中的 SolverTimeout 别名
SDK 超时却返回 500某个 switch 分支写的是 System.TimeoutException捕获带别名的 SDK 类
服务忽略 ASPNETCORE_URLSdotnet run 会应用 launchSettings.json改为传入 urls 选项
每次调用都返回 401X-API-Key 请求头与 Solver:ApiKey 不相等发送服务密钥,而不是 CapSkip 的密钥
健康检查返回 503没有任何程序在 CapSkip 的端口上接受连接启动 CapSkip,并检查 Local 模式或 Server 模式
每次识别都返回 502CapSkip 拒绝了 CapSkip 部分里的密钥让它与 CapSkip 中设置的密钥一致
等满整个时限之后返回 504CapSkip 毫无回应,或者识别很慢在 CapSkip 的 Monitor 选项卡里查看这次识别,然后调大时限

Microsoft 的 最小 API 概述 介绍了框架方面的内容,所有 CapSkip 错误代码请见 API 文档.

常见问题

如果只有一个应用在识别验证码,还需要服务吗?

不需要。直接在该应用里调用 SDK 即可。当多个调用方,或者使用其他语言的调用方,共用一个 CapSkip,而你想要一个时限、一个上限和一个密钥时,服务才有帮助。 Worker Service 指南 介绍了后台作业的进程内做法。

可以把服务托管在 Azure 或其他提供商上吗?

可以,但托管的服务运行在别人的机器上,所以它无法访问你 PC 上的 127.0.0.1。请在你控制的一台 Windows 机器上以 Server 模式运行 CapSkip,最好有静态公网 IP,并把 CapSkip__Host 设为这个地址。开启 API key validation,为其他每个地址添加阻止规则,并记住这个密钥是通过普通 HTTP 以明文传输的。本指南没有测试任何托管提供商。

被取消的请求会停止 CapSkip 里的识别吗?

不会。服务只是停止轮询并释放它的槽位。SDK 没有取消调用,所以要假定 CapSkip 会继续处理这个任务,并一直占用一个线程直到它完成。让时限长到足够完成一次正常识别,这样取消就会保持在很少的次数。

同一个端点能识别 reCAPTCHA v3 吗?

可以。在请求正文中发送 version 为 v3 和一个 action,这些选项会原样传给 SDK。服务不发送任何 score 值,因为 CapSkip 没有 score 设置。 reCAPTCHA v2 识别工具页面 介绍了本指南所用的复选框类型。

简短版结论

要构建最小 API 验证码识别服务,就保留一个 CapSkipClient、一个 SemaphoreSlim,以及每个请求一个链接的取消令牌。先检查服务密钥。先检查那两个取消标志,再看异常类型。用 urls 选项启动服务,而不是用环境变量。如果 CapSkip 以 Server 模式运行,就在它前面放一条阻止规则。

一个共享服务可以供整个团队使用,不需要有人去数识别了多少次。把它接在你自己的 Windows 机器上运行的 本地验证码识别工具 前面,就能让每一次识别都不产生按次计费的账单。