Детектор капчи на C#: определите тип и вызовите CapSkip

Детектор капчи на C# читает в HTML страницы скрипт и контейнер виджета, достаёт из них sitekey и сопоставляет семейство с методом CapSkip, который его решает. Для чекбокса reCAPTCHA v2, скрипта v3 и виджета Turnstile нужны три разных вызова с разными параметрами, а неверная догадка тратит впустую целое решение и даёт токен, который вам не пригодится. В этом руководстве собирается детектор примерно в 200 строк без пакетов, кроме CapSkip. Он возвращает тип, метод и аргументы или прямо говорит, что определить не может. CapSkip работает на вашем собственном ПК с Windows без платы за каждое решение, поэтому программа, проверяющая сто страниц, ничего не добавляет к расходам. Тот же детектор есть на Python и Node.js, ссылки в конце.
Что понадобится
- CapSkip, запущенный на ПК с Windows, с включёнными нужными вам решателями. Пошаговое руководство по настройке проведёт вас через первый запуск.
- Нужен .NET 8 SDK или новее, потому что код использует сырые строковые литералы (raw string literals), для которых нужен C# 11. Проверялось на SDK 10.0.100 и пакете CapSkip 1.3.0 для net8.0 и net10.0. Целевая платформа net6.0 тоже заработала после добавления LangVersion 11 в файл проекта. .NET Framework не пробовали.
- Терминал. Команды работают в PowerShell.
- Страница, которой вы владеете или которую вам разрешено автоматизировать, с капчей на ней.
- Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы программа на другом ПК, VPS или размещённой платформе могла обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
Как это проверялось: детектор запускался на тех же 66 сохранённых страницах, что и версия на Python, и вернул тот же результат для каждой страницы. Из них 59 собраны вручную (около 40 следуют документации поставщиков капчи, остальные придуманные мной крайние случаи), а 7 являются снимками публичных страниц от 29 октября 2026 года. 40 результатов, в которых назван метод, были отправлены через настоящий пакет CapSkip из NuGet на имитацию CapSkip, которая копирует проверки входных данных приложения. Настоящее приложение не запускалось, поэтому ни одно число ниже не является временем решения.
Зачем определять тип капчи до решения
В CapSkip для каждого семейства свой метод: RecaptchaAsync, TurnstileAsync, GeetestAsync, AltchaAsync, CapyAsync, CaptchaFoxAsync, FriendlyCaptchaAsync и NormalAsync для изображений в SDK, плюс hcaptcha в HTTP API, для которого в SDK пока нет обёртки. Внутри reCAPTCHA важны и параметры. Invisible, Enterprise и v3 являются флагами того же вызова. Если передать не те, в лучшем случае задание завершится ошибкой, в худшем вы получите токен, который сайт отклонит без объяснений. Я не смог проверить, что именно делает настоящее приложение при неверной версии reCAPTCHA. В любом случае время одного решения потрачено.
Остальные ошибки проявляются быстро и громко. Если взять sitekey со страницы, где reCAPTCHA рендерится явно, получится слово 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. Демо-страницы reCAPTCHA от Google, демо Turnstile от Cloudflare и демо hCaptcha несут свой виджет прямо в этом HTML. Страницы, которые добавляют виджет через JavaScript, так не делают, и этот случай разбирает раздел “Когда HTML ничего не говорит”. Один HttpClient хранит одно хранилище cookies, что важно для шага с капчей-изображением: программа ниже использует один и тот же клиент и для страницы, и для картинки.
Шаг 2: пишем вспомогательные функции
Сохраните всё отсюда до шага 9 в один файл Detector.cs, блок за блоком. Файл не скомпилируется, пока шаг 9 не закроет класс, и он опирается на неявные using (implicit usings), которые включает dotnet new console. Первый блок содержит директивы using, две небольшие записи и первые вспомогательные методы. Каждая функция чтения возвращает 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. CapSkip решает её, будь виджет чекбоксом, невидимым или Enterprise, но сайт, перешедший с reCAPTCHA на hCaptcha, часто оставляет на виджете класс g-recaptcha, и отличить одно от другого можно только по скрипту hCaptcha. Проверка читает sitekey из виджета или из вызова hcaptcha.render и добавляет invisible, если у виджета data-size равен invisible. Страница Enterprise, которая передаёт виджету значение rqdata через hcaptcha.setData, вместо этого получает результат «нужен браузер», потому что это значение выдаётся для одной проверки и быстро истекает.
Шаг 4: reCAPTCHA, в правильном порядке
reCAPTCHA требует больше всего внимания, потому что несколько вариантов CapSkip используют два имени скрипта. Разделяет их порядок вопросов. Сначала имя скрипта: enterprise.js означает флаг Enterprise, api.js нет. Затем параметр render в адресе скрипта. Ключ после render= означает v3, а слово explicit после render= означает страницу v2, которая создаёт виджет из кода, и использовать его как ключ нельзя ни в коем случае. Затем разметка. Кнопка с классом g-recaptcha и атрибутом data-action соответствует привязке кнопки v3 от Google. Кнопка без data-action или любой элемент с data-size, равным 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. Чекбокс вызывается как RecaptchaAsync с sitekey, URL страницы и необязательным словарём параметров. Для invisible добавляется "invisible", для Enterprise добавляется "enterprise", а для v3 добавляются "version" и action. Имя action берётся из атрибута data-action или из вызова execute на странице; если в HTML нет ни того, ни другого, запись опускается, и CapSkip использует значение по умолчанию verify. Подробности смотрите на странице решателя reCAPTCHA с описанием каждого поддерживаемого варианта.
Шаг 5: Turnstile и GeeTest
Виджету Turnstile на странице нужны две вещи: api.js от Cloudflare и sitekey. Ключ лежит в атрибуте data-sitekey у div с классом cf-turnstile или в вызове 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 виджета для этой проверки. Его ключ начинается с 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 возвращает корректный по форме токен для неверной версии, а сайт его отклоняет. Скрипты их различают: версия 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 возвращает первый результат и добавляет список Also, если на странице больше одного семейства, например скрипт v3 плюс виджет Turnstile. Список называет семейства, а не второй виджет 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, поэтому его хранилище cookies уходит вместе с запросом, ведь собственная загрузка URL в SDK cookies не отправляет. Неверный статус при этой загрузке выбрасывает HttpRequestException.
В SDK пока нет вызова для hCaptcha, поэтому результат hCaptcha вместо этого уходит в SolveHcaptcha. Метод отправляет sitekey и адрес страницы в in.php с method=hcaptcha, затем каждые 5 секунд опрашивает res.php, пока токен не будет готов, как описано в разделе hCaptcha документации API на сайте. Он берёт адрес и API key из переданного вами CapSkipClient, поэтому ничего дополнительно настраивать не нужно. Отклонённое задание выбрасывает собственное исключение SDK ApiException с кодом ошибки, поэтому его обрабатывает catch для CapSkipError в программе ниже. Результат несёт токен в Code и User-Agent, с которым он получен, в UserAgent.
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, который на странице проверки должен идти вместе с токеном. Программа ниже печатает только поле Code, поэтому результат GeeTest или Capy показывается как сырой JSON.
Запуск детектора капчи на C#
Замените содержимое Program.cs этим блоком. Он загружает вашу страницу, печатает HTTP-статус, печатает то, что нашёл детектор, и вызывает CapSkip, только когда есть метод для вызова и ничего не пропущено. Он перехватывает CapSkipError, базовый класс исключений SDK, вместо того чтобы перечислять их по одному. Это же избавляет от ловушки: при включённых implicit usings голое 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 первая строка показывает тип и метод, а вторая показывает токен. В запуске на имитации это выглядело так:
| Страница | Программа печатает |
|---|---|
| reCAPTCHA v2 checkbox | 200, затем recaptcha_v2 через recaptcha, затем токен |
| Виджет hCaptcha | 200, затем hcaptcha через hcaptcha, затем токен |
| Страница 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)';
})();Он нашёл sitekey и флаги на странице reCAPTCHA v2, на невидимой, на странице v3, на странице Turnstile и на демо hCaptcha, а на странице без капчи вывел сообщение. Три ограничения: браузер по умолчанию хранит только около 250 записей ресурсов, поэтому долго работающее одностраничное приложение может потерять ранние, и сниппет не отличает Friendly Captcha версии 1 от версии 2, и не ищет скрипты GeeTest, Capy или CaptchaFox. На полностраничной проверке Cloudflare он может показать sitekey из адреса ресурса, но не значения data и pagedata, для которых нужен перехват вызова render. Этот перехват здесь не проверялся.
Где определение даёт сбой
| Ситуация | Что возвращает Detect | Что делать |
|---|---|---|
| Скрипт виджета, добавленный через JavaScript | null | Загрузите страницу в браузере и запустите сниппет для консоли |
| Sitekey, заданный через JavaScript | результат, которому нужен браузер | Прочитайте ключ с живой страницы |
| Полностраничная проверка Cloudflare | результат, которому нужен браузер | Прочитайте значения в браузере, см. руководство по cf_clearance |
| Скрипт Friendly Captcha без div frc-captcha в HTML | null | Запустите сниппет для консоли или прочитайте исходный код страницы |
| Скрипт CaptchaFox или Capy, добавленный через JavaScript | null | Запустите сниппет для консоли или прочитайте исходный код страницы |
| Страница 403 или 503 от самого сайта | null, если это не проверка Cloudflare | Программа сначала печатает статус: на странице блокировки нет капчи, которую можно найти |
| Arkose, GeeTest v4 | unsupported | CapSkip не может их решить |
| hCaptcha Enterprise, которая задаёт rqdata | нужен браузер | Для каждого задания читайте свежий rqdata с живой страницы и передавайте его в SolveHcaptcha как data |
| Friendly Captcha без подсказки в скрипте | результат, которому нужен браузер | Проверьте, какой скрипт загружает страница |
| Капча-изображение без слова captcha | null | Добавьте собственное правило для этого сайта |
Частые ошибки
| Ошибка | Причина | Исправить |
|---|---|---|
| ERROR_GOOGLEKEY | Ключом оказалось слово explicit или ключ, не относящийся к reCAPTCHA | Читайте ключ из виджета, а не из параметра render |
| ERROR_CAPTCHA_UNSOLVABLE | Любое неудавшееся задание выглядит так, и код не называет причину. Неверная версия reCAPTCHA является одной из возможных причин | Сначала проверьте определённый тип, затем откройте раздел Документация API |
| ValidationException | Параметр, которого метод не принимает, например action в вызове v2 | Используйте аргументы, которые вернул детектор |
| NetworkException | CapSkip не запущен, или неверны хост либо порт; в Windows ответ приходит примерно через 2 секунды | Запустите CapSkip и проверьте настройки подключения |
| InvalidOperationException из Solve | Результат unsupported или нужен браузер | Обработайте эти два случая до вызова Solve |
Запуск решателя на сервере
Детектору от CapSkip нужны только две вещи: хост и порт. В режиме Local это 127.0.0.1 и ваш собственный ПК. В режиме Server CapSkip слушает ваш сетевой адрес или публичный IP, поэтому детектор, работающий на другом ПК, VPS или размещённой платформе, может обращаться к нему через тот же API. Смените хост и порт в конструкторе CapSkipClient, и больше в коде ничего не меняется. Для стабильного удалённого доступа рекомендуется статический публичный IP. Режим Server всё равно работает на вашем оборудовании и всё равно без счётчика. Учтите, что режим Server открывает порт для любого адреса, поэтому добавьте в Windows Firewall блокирующее правило для адресов, которым доступ к нему не нужен. Смотрите раздел Настройки подключения с описанием переключения режимов.
FAQ
Можно ли узнать, какую капчу использует сайт, без кода?
Да. Откройте DevTools, перейдите на вкладку Network, перезагрузите страницу и посмотрите на адреса скриптов. Скрипт google.com/recaptcha или recaptcha.net означает reCAPTCHA, challenges.cloudflare.com означает Turnstile, а js.hcaptcha.com означает hCaptcha. Сниппет для Console выше делает тот же поиск за вас.
Почему Detect возвращает результат «нужен браузер», а не догадку?
Потому что неверная догадка тратит впустую целое решение и даёт токен, который сайт отклоняет без объяснений. Если в статическом HTML нет sitekey, challenge GeeTest или версии Friendly Captcha, лучше так и сказать: это проще, чем разбираться с неудавшимся заданием.
Решает ли CapSkip hCaptcha?
Да. CapSkip решает чекбоксы, невидимые виджеты и виджеты Enterprise hCaptcha через свой HTTP API с method=hcaptcha, как описано в разделе Документация API на сайте. В .NET SDK пока нет вызова для hCaptcha, поэтому SolveHcaptcha выше сам отправляет задание в in.php. FunCaptcha, Arkose и GeeTest v4 по-прежнему не поддерживаются, и детектор помечает их как unsupported, чтобы программа могла пропустить страницу, а не падать на ней.
Может ли размещённая платформа использовать детектор с моим CapSkip?
Да, через режим Server. Настройте CapSkip на прослушивание вашей сети или публичного IP, направьте конструктор CapSkipClient на этот хост и порт, и детектор вместе с вызовом Solve работают так же, как на одном ПК. Решатель остаётся на вашем оборудовании.
Коротко
Детектор капчи на C# оправдывает себя тем, что запускается первым: определите тип капчи, затем решайте. Читайте скрипт и разметку в фиксированном порядке, передавайте аргументы нужному методу и считайте null, результат «нужен браузер» и unsupported ответами, а не сбоями. Пока CapSkip работает на вашей машине, счётчик не крутится, пока вы проверяете страницу. Поэтому распознавание капчи подходит для краулинга, который каждые несколько минут встречает новый сайт. Сам SDK для .NET описан на странице сервиса распознавания капчи для C#.
Тот же детектор на других языках:
- Определение типа капчи на Python: оригинал, с теми же 66 страницами.
- Детектор капчи на Node.js: те же проверки без дополнительных пакетов.
