Как создать эндпоинт решения капчи на minimal API в C#

Чтобы создать решатель капчи на minimal API, зарегистрируйте в ASP.NET Core один CapSkipClient как singleton. Откройте POST-эндпоинт, который принимает sitekey и URL страницы. Оберните каждый вызов в дедлайн и SemaphoreSlim и сопоставьте четыре исключения SDK с HTTP-статусами. Ваши парсеры, воркеры и скрипты на любом языке тогда обращаются к одному внутреннему адресу. Порт и ключ CapSkip они не видят. CapSkip работает на вашей собственной машине с Windows, поэтому и у сервиса нет платы за каждое решение. В этом руководстве сервис собирается примерно в 120 строк, и показано, что делает каждая часть, когда CapSkip работает медленно, закрыт или молчит.
Что понадобится
- CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
- SDK .NET 10. Всё ниже запускалось на Windows 11 с SDK 10.0.100, ASP.NET Core 10.0.0 и пакетом CapSkip 1.3.0 из NuGet.
- Окно PowerShell. Команды написаны в синтаксисе PowerShell.
- Страница с чекбоксом reCAPTCHA v2. Сервису нужны её sitekey и её адрес, и больше ничего о странице.
- Адрес CapSkip. В режиме Local он отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы сервис на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
Числа в этом руководстве получены на имитации CapSkip на том же ПК. ASP.NET Core и SDK настоящие. Имитация удерживает каждое решение заданное время, две секунды, если тест не говорит иного, а реальное время решения будет другим. Ничего из этого не запускалось ни с живым CapSkip, ни на живом сайте.
Зачем ставить сервис перед CapSkip
Если капчу решает одно приложение на C#, вызывайте SDK прямо из него. На странице сервиса распознавания капчи для C# это описано. Сервис окупается, когда одним CapSkip пользуются несколько вызывающих сторон. Тогда четыре задачи логично решать в одном месте.
- Один дедлайн. SDK для .NET по умолчанию опрашивает результат до 300 секунд, поэтому у каждой вызывающей стороны был бы свой таймер.
- Один лимит. CapSkip выполняет фиксированное число решений одновременно, а лишние задания ждут. Очередь, которую вы контролируете, понятнее очереди внутри CapSkip.
- Один ключ. Вызывающие стороны получают ключ сервиса. Адрес и API-ключ CapSkip остаются в настройках сервиса.
- Любой язык. Скрипт на 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, потому что записи и вспомогательные функции идут последними.
В начале Program.cs раздел CapSkip привязывается к записи, и читается ключ сервиса. Проверка ключа использует IsNullOrWhiteSpace, а не проверку на null с throw. На имитации пустая строка проходила проверку на null, сервис запускался и затем принимал каждый запрос с пустым заголовком.
Этот блок содержит и единственную ловушку, специфичную для .NET. В System и в CapSkip есть класс с именем TimeoutException. При неявных using и using CapSkip простое имя TimeoutException не компилируется. Ветка switch для System.TimeoutException компилируется, но никогда не совпадёт с исключением 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 безопасно использовать как singleton. В исходном коде SDK он не хранит состояния отдельных вызовов, копирует словарь параметров при каждом вызове, а все экземпляры используют один статический HttpClient. Освобождать здесь нечего, и IHttpClientFactory не нужен. SDK использует один закрытый статический HttpClient и не принимает ваш. Долгоживущее соединение не заметило бы смены DNS, поэтому для хоста CapSkip используйте IP-адрес.
SemaphoreSlim и есть шлюз. Дайте ему по одному слоту на каждый поток CapSkip, то есть значение Max. Threads в CapSkip (по умолчанию 10). AddProblemDetails вместе с UseStatusCodePages делает так, что ответы 400, 404, 405 и 415, которые ASP.NET Core пишет сам, приходят в том же формате, что и наши. Без 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: проверяем ключ сервиса
Незащищённый эндпоинт решения на сетевом адресе превращается в бесплатный решатель для всех, кто может достучаться до порта. Этот middleware защищает путь /solve. FixedTimeEquals сравнивает байты за одно и то же время, где бы они ни различались, и для другой длины возвращает false, а не выбрасывает исключение. Отсутствующий ключ, неверный ключ и слишком длинный ключ на имитации дали 401.
Возвращается 401, а не 403, потому что учётные данные одни, и они отсутствуют или неверны. Middleware выполняется до привязки модели, поэтому запрос без ключа и с битым телом 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, поэтому эндпоинт health открывает TCP-соединение с портом CapSkip и прекращает попытки через три секунды. При закрытом CapSkip он отвечал 503 примерно через две секунды, потому что Windows так медленно отклоняет соединение с loopback-адресом. Он сообщает балансировщику нагрузки или скрипту мониторинга, что сервис работает, а 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 и отвечает проблемой валидации.
Дедлайн представляет собой один связанный 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 (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: превращаем исключения в HTTP-статусы
Эту часть замечают вызывающие стороны. Порядок веток 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"),
};ApiException из SDK несёт свой текст в сообщении и не имеет свойства с кодом. Случай нерешаемой капчи определяется по слову UNSOLVABLE в этом сообщении. Каждый код статуса ниже был вызван намеренно на имитации.
| Статус | Что произошло | Сколько это заняло |
|---|---|---|
| 503 | CapSkip закрыт, поэтому отправка была отклонена | 2.1 s |
| 422 | CapSkip сообщил ERROR_CAPTCHA_UNSOLVABLE | 3.8 s |
| 502 | CapSkip отклонил собственный API-ключ сервиса | менее 0.1 с |
| 400 | Запрос v2 содержал action, который принимает только v3 | менее 0.1 с |
| 504 | Дедлайн истёк при молчащем CapSkip (тестовый дедлайн 6 с) | 6.0 s |
| 504 | Первым сработал собственный таймаут SDK в 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.0 с при дедлайне 8 с на имитации) и получает 504. Быстрый 503 даёт только отклонённая отправка.
Шаг 10: добавляем записи
Запись настроек содержит значения по умолчанию. Запись запроса строит словарь параметров только из полей, которые отправила вызывающая сторона. Вызывающая сторона 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
Используйте параметр urls у dotnet run и не рассчитывайте на переменную ASPNETCORE_URLS. Шаблон web поставляется с файлом launchSettings.json, в котором указан случайный порт, и dotnet run его читает. На тестовом ПК обычный dotnet run с заданной ASPNETCORE_URLS проигнорировал переменную, слушал порт из профиля и переключился на окружение Development. Параметр urls победил профиль, а переключатель no-launch-profile заставил переменную снова работать и дал окружение Production. Один лишь параметр urls всё равно оставляет включённым окружение Development из профиля, поэтому добавьте и переключатель, если вам нужно Production. Если нет ни профиля, ни заданного URL, Kestrel возвращается к localhost:5000, чего здесь не проверяли.
Для того же ПК слушайте адрес 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, то есть каждый сетевой адаптер. На тестовом ПК этот адрес отвечал на собственном IP этого ПК в сети LAN при верном ключе и отклонял неверный с кодом 401. Сервис, привязанный к 127.0.0.1, отклонял адрес из LAN. Сначала задайте настоящий ключ сервиса. Ключ сервиса к тому же передаётся по сети открытым текстом по обычному 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 в режим Server на машине с Windows, которую вы контролируете, желательно со статическим публичным IP. Направьте сервис на неё двумя переменными окружения. В тесте эта схема работала против имитации, слушавшей адрес из LAN, и одно решение заняло 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
Три предосторожности. Включите проверку API-ключа в CapSkip и используйте тот же ключ в разделе CapSkip настроек. SDK формирует обычный адрес http, поэтому этот ключ передаётся по сети открытым текстом. CapSkip к тому же добавляет для своего порта правило, разрешающее любой адрес, поэтому добавьте блокирующее правило для каждого адреса, которому доступа к нему быть не должно. В разделе Настройки подключения руководства по настройке описаны оба режима.
Вызов из PowerShell или Python
Вызывающая сторона отправляет sitekey, URL страницы и ключ сервиса. В ответ приходит JSON-объект с одним полем, token. Используйте токен в течение примерно двух минут, потому что токен reCAPTCHA истекает, и используйте его один раз. В руководстве по сроку действия токена объясняется, как идёт отсчёт времени.
# 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);Частые ошибки в решателе капчи на minimal API
| Что вы видите | Причина | Исправить |
|---|---|---|
| CS0104 про TimeoutException | Его определяют и System, и CapSkip | Используйте псевдоним SolverTimeout из шага 3 |
| 500 при таймауте SDK | Ветка switch называет System.TimeoutException | Перехватывайте класс SDK по псевдониму |
| Сервис игнорирует ASPNETCORE_URLS | dotnet run применяет launchSettings.json | Передайте вместо этого параметр urls |
| Каждый вызов возвращает 401 | Заголовок X-API-Key не равен Solver:ApiKey | Отправляйте ключ сервиса, а не ключ CapSkip |
| 503 от проверки работоспособности | Никто не принимает соединения на порту CapSkip | Запустите CapSkip и проверьте режим Local или Server |
| 502 на каждом решении | CapSkip отклонил ключ из раздела CapSkip | Сделайте его таким же, как ключ, заданный в CapSkip |
| 504 после полного дедлайна | CapSkip молчит, или решение идёт медленно | Посмотрите на решение во вкладке Monitor в CapSkip, затем увеличьте дедлайн |
От Microsoft: обзор minimal API раскрывает сторону фреймворка, а все коды ошибок CapSkip перечисляет Документация API.
FAQ
Нужен ли сервис, если капчу решает только одно приложение?
Нет. Вызывайте SDK прямо из этого приложения. Сервис полезен, когда одним CapSkip пользуются несколько вызывающих сторон, в том числе на других языках, и вам нужны один дедлайн, один лимит и один ключ. В руководстве по Worker Service показан вариант внутри процесса для фонового задания.
Можно ли разместить сервис на Azure или у другого провайдера?
Да, но размещённый сервис работает на чужой машине, поэтому не может обратиться к 127.0.0.1 на вашем ПК. Запустите CapSkip в режиме Server на машине с Windows, которую вы контролируете, желательно со статическим публичным IP, и задайте этот адрес в CapSkip__Host. Включите проверку API-ключа, добавьте блокирующее правило для каждого другого адреса и помните, что ключ передаётся открытым текстом по обычному HTTP. Ни один хостинг-провайдер для этого руководства не тестировался.
Останавливает ли отменённый запрос решение в CapSkip?
Нет. Сервис прекращает опрос и освобождает слот. У SDK нет вызова отмены, поэтому считайте, что CapSkip продолжает работать над заданием и удерживает один поток, пока не закончит. Задайте дедлайн, достаточный для обычного решения, чтобы отмены оставались редкими.
Может ли тот же эндпоинт решать reCAPTCHA v3?
Да. Передайте в теле запроса version со значением v3 и action, и параметры дойдут до SDK как есть. Сервис не отправляет значение score, потому что в CapSkip нет настройки score. На странице решателя reCAPTCHA v2 описан тип с чекбоксом, который используется в этом руководстве.
Коротко
Чтобы создать решатель капчи на minimal API, держите один CapSkipClient, один SemaphoreSlim и один связанный токен отмены на запрос. Сначала проверяйте ключ сервиса. Проверяйте два флага отмены раньше типа исключения. Запускайте сервис с параметром urls, а не с переменной окружения. Поставьте блокирующее правило перед CapSkip, если он работает в режиме Server.
- Все типы капчи, которые поддерживает пакет для .NET, перечислены на странице сервиса распознавания капчи для C#.
- Как работает решатель reCAPTCHA с чекбоксом, описано на странице сервиса распознавания reCAPTCHA v2.
- Обычный вызов SDK на C# описан в руководстве по решению reCAPTCHA v2 на C#.
Общий сервис может обслуживать целую команду, и никому не нужно считать решения. За ним стоит локальный сервис распознавания капчи на вашей собственной машине с Windows, поэтому каждое решение обходится без платы за вызов.
