API Tutorials

Асинхронное решение CAPTCHA на C# с помощью Task.WhenAll и CaptchaAI

Две ошибки убивают производительность при пакетном решении CAPTCHA в C#: создавать новый HttpClient на каждый запрос и останавливать весь пакет из-за одного сбойного задания. Task.WhenAll решает обе проблемы за счёт нескольких строк кода — задачи уходят в CaptchaAI параллельно, res.php опрашивается одновременно по всем ID, а частичные сбои не обрушивают остальные решения. Ниже — рабочий клиент, ограничение параллелизма через SemaphoreSlim и разбор типичных ошибок.

Что понадобится перед стартом

dotnet new console -n CaptchaSolver
cd CaptchaSolver
dotnet add package System.Text.Json

Дополнительные NuGet-пакеты не нужны — HttpClient и Task.WhenAll уже входят в .NET из коробки.

Клиент CaptchaAI на HttpClient

Один общий экземпляр HttpClient на всё приложение — обязательное условие: пересоздание клиента на каждый запрос быстро исчерпывает пул сокетов при параллельной нагрузке.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

public class CaptchaAiClient : IDisposable
{
    private readonly HttpClient _client;
    private readonly string _apiKey;
    private const string SubmitUrl = "https://ocr.captchaai.com/in.php";
    private const string ResultUrl = "https://ocr.captchaai.com/res.php";

    public CaptchaAiClient(string apiKey)
    {
        _apiKey = apiKey;
        _client = new HttpClient();
    }

    public async Task<string> SolveCaptchaAsync(string sitekey, string pageurl)
    {
        // Submit
        var submitParams = new FormUrlEncodedContent(new[]
        {
            new KeyValuePair<string, string>("key", _apiKey),
            new KeyValuePair<string, string>("method", "userrecaptcha"),
            new KeyValuePair<string, string>("googlekey", sitekey),
            new KeyValuePair<string, string>("pageurl", pageurl),
            new KeyValuePair<string, string>("json", "1")
        });

        var submitResp = await _client.PostAsync(SubmitUrl, submitParams);
        var submitJson = await submitResp.Content.ReadAsStringAsync();
        var submitData = JsonSerializer.Deserialize<ApiResponse>(submitJson);

        if (submitData.Status != 1)
            throw new Exception($"Submit failed: {submitData.Request}");

        var captchaId = submitData.Request;

        // Poll for result
        for (int i = 0; i < 60; i++)
        {
            await Task.Delay(5000);

            var resultResp = await _client.GetAsync(
                $"{ResultUrl}?key={_apiKey}&action=get&id={captchaId}&json=1"
            );
            var resultJson = await resultResp.Content.ReadAsStringAsync();
            var resultData = JsonSerializer.Deserialize<ApiResponse>(resultJson);

            if (resultData.Status == 1)
                return resultData.Request;

            if (resultData.Request != "CAPCHA_NOT_READY")
                throw new Exception($"Solve failed: {resultData.Request}");
        }

        throw new TimeoutException("Solve timeout after 300s");
    }

    public void Dispose() => _client.Dispose();
}

public class ApiResponse
{
    public int Status { get; set; }
    public string Request { get; set; }
}

Пакетное решение через Task.WhenAll

SolveAllAsync запускает все задачи сразу и ждёт, пока отработает каждая — без раннего выхода при первом же исключении. Ошибки ловятся внутри SolveSingleAsync, поэтому один упавший запрос не «роняет» весь Task.WhenAll.

public class BatchSolver
{
    private readonly CaptchaAiClient _client;

    public BatchSolver(string apiKey)
    {
        _client = new CaptchaAiClient(apiKey);
    }

    public async Task<BatchResult> SolveAllAsync(
        IReadOnlyList<CaptchaTask> tasks)
    {
        var solveTasks = new Task<TaskResult>[tasks.Count];

        for (int i = 0; i < tasks.Count; i++)
        {
            var task = tasks[i];
            solveTasks[i] = SolveSingleAsync(task);
        }

        // Wait for ALL tasks — no short-circuiting on failure
        var results = await Task.WhenAll(solveTasks);

        return new BatchResult
        {
            Solved = Array.FindAll(results, r => r.Solution != null),
            Failed = Array.FindAll(results, r => r.Error != null)
        };
    }

    private async Task<TaskResult> SolveSingleAsync(CaptchaTask task)
    {
        try
        {
            var solution = await _client.SolveCaptchaAsync(
                task.Sitekey, task.Pageurl);
            return new TaskResult
            {
                TaskId = task.TaskId,
                Solution = solution
            };
        }
        catch (Exception ex)
        {
            return new TaskResult
            {
                TaskId = task.TaskId,
                Error = ex.Message
            };
        }
    }
}

public record CaptchaTask(string TaskId, string Sitekey, string Pageurl);

public class TaskResult
{
    public string TaskId { get; set; }
    public string Solution { get; set; }
    public string Error { get; set; }
}

public class BatchResult
{
    public TaskResult[] Solved { get; set; }
    public TaskResult[] Failed { get; set; }
}

Лимит параллелизма через SemaphoreSlim

Запуск тысячи задач одновременно съедает память и упирается в пропускную способность сети раньше, чем в лимиты CaptchaAI. SemaphoreSlim ограничивает, сколько запросов летит одновременно, — именно этот параметр стоит подбирать под тарифный план.

public async Task<BatchResult> SolveWithLimitAsync(
    IReadOnlyList<CaptchaTask> tasks,
    int maxConcurrency = 10)
{
    var semaphore = new SemaphoreSlim(maxConcurrency);
    var solveTasks = new Task<TaskResult>[tasks.Count];

    for (int i = 0; i < tasks.Count; i++)
    {
        var task = tasks[i];
        solveTasks[i] = ThrottledSolveAsync(task, semaphore);
    }

    var results = await Task.WhenAll(solveTasks);

    return new BatchResult
    {
        Solved = Array.FindAll(results, r => r.Solution != null),
        Failed = Array.FindAll(results, r => r.Error != null)
    };
}

private async Task<TaskResult> ThrottledSolveAsync(
    CaptchaTask task, SemaphoreSlim semaphore)
{
    await semaphore.WaitAsync();
    try
    {
        return await SolveSingleAsync(task);
    }
    finally
    {
        semaphore.Release();
    }
}

Значение maxConcurrency имеет смысл держать не выше числа потоков, доступных на вашем тарифе CaptchaAI. Например, на BASIC ($15/мес, 5 потоков) параллелизм выше 5 просто выстроит задачи в очередь на стороне API; на STANDARD ($30/мес, 15 потоков) разумный старт — 10–15; на ADVANCE ($90/мес, 50 потоков) можно поднимать maxConcurrency до 40–50 и мерить, где начинает упираться сеть, а не API.

Полный пример: пакет из 20 задач

class Program
{
    static async Task Main(string[] args)
    {
        var apiKey = Environment.GetEnvironmentVariable("CAPTCHAAI_API_KEY")
            ?? throw new Exception("Set CAPTCHAAI_API_KEY");

        var solver = new BatchSolver(apiKey);

        // Create 20 tasks
        var tasks = new List<CaptchaTask>();
        for (int i = 0; i < 20; i++)
        {
            tasks.Add(new CaptchaTask(
                $"task_{i}",
                "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
                $"https://example.com/page/{i}"
            ));
        }

        Console.WriteLine($"Solving {tasks.Count} CAPTCHAs with concurrency=10...");
        var start = DateTime.UtcNow;

        var result = await solver.SolveWithLimitAsync(tasks, maxConcurrency: 10);

        var elapsed = DateTime.UtcNow - start;
        Console.WriteLine($"\nDone in {elapsed.TotalSeconds:F1}s");
        Console.WriteLine($"  Solved: {result.Solved.Length}");
        Console.WriteLine($"  Failed: {result.Failed.Length}");

        foreach (var s in result.Solved)
            Console.WriteLine($"  ✓ {s.TaskId}: {s.Solution[..Math.Min(30, s.Solution.Length)]}...");

        foreach (var f in result.Failed)
            Console.WriteLine($"  ✗ {f.TaskId}: {f.Error}");
    }
}

Отмена по глобальному тайм-ауту

Если пакет должен уложиться в жёсткий тайм-аут независимо от того, сколько задач ещё не решено, оборачивайте вызов в CancellationTokenSource:

public async Task<BatchResult> SolveWithTimeoutAsync(
    IReadOnlyList<CaptchaTask> tasks,
    int maxConcurrency = 10,
    TimeSpan? timeout = null)
{
    using var cts = new CancellationTokenSource(
        timeout ?? TimeSpan.FromMinutes(10));

    try
    {
        return await SolveWithLimitAsync(tasks, maxConcurrency);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Batch operation timed out.");
        return new BatchResult
        {
            Solved = Array.Empty<TaskResult>(),
            Failed = Array.Empty<TaskResult>()
        };
    }
}

Task.WhenAll или Parallel.ForEachAsync (.NET 6+)

На .NET 6 и новее есть альтернатива, которая обрабатывает результат сразу по готовности, а не после того, как отработает весь пакет:

// .NET 6+ alternative
await Parallel.ForEachAsync(tasks,
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (task, ct) =>
    {
        var result = await SolveSingleAsync(task);
        // Process result immediately
    });

Коротко о разнице между подходами:

  • Task.WhenAll + SemaphoreSlim — собирает полный BatchResult со сводкой Solved/Failed, лимит параллелизма ручной, работает начиная с .NET Core 1.0.
  • Parallel.ForEachAsync — обрабатывает каждый результат сразу по готовности, лимит параллелизма встроенный через MaxDegreeOfParallelism, доступен только с .NET 6+.

Если нужна единая сводка по всему пакету — берите Task.WhenAll. Если решение можно сразу отправлять дальше по конвейеру, не дожидаясь остальных, — Parallel.ForEachAsync короче и не требует ручного SemaphoreSlim.

Типичные ошибки и их причины

Разбор по симптомам, с которыми чаще всего сталкиваются при первом запуске пакетного решения:

  • Истощение пула сокетов у HttpClient. Причина — новый HttpClient создаётся на каждый запрос. Держите один общий HttpClient на всё приложение, как в примере клиента выше.
  • Task.WhenAll падает на первой же ошибке. Причина — отдельные задачи не обёрнуты в try/catch. Ловите исключения внутри SolveSingleAsync, как показано выше.
  • Высокое потребление памяти при 1000+ задачах. Причина — все задачи запускаются одновременно, без ограничения. Ограничьте параллелизм через SemaphoreSlim.
  • Ошибки SSL/TLS на старом окружении. Причина — .NET по умолчанию использует TLS 1.0. Установите ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12.
  • Часть задач зависает на 300 секунд. Причина — медленное соединение или нестабильный прокси на стороне клиента. Уменьшите общий тайм-аут через CancellationTokenSource и повторяйте только зависшие ID, а не весь пакет.

Последний пункт особенно актуален для команд, которые разворачивают сервис в европейских или казахстанских регионах и опрашивают res.php через нестабильное мобильное соединение — таймауты в опросе результата стоит закладывать заранее, а не лечить постфактум.

Частые вопросы

Сколько потоков нужно, чтобы решать 20–50 CAPTCHA параллельно?

Столько же, сколько включает тарифный план CaptchaAI: BASIC ($15/мес) даёт 5 потоков, STANDARD ($30/мес) — 15, ADVANCE ($90/мес) — 50. maxConcurrency в SemaphoreSlim имеет смысл выставлять не выше числа оплаченных потоков — превышение просто создаёт очередь на стороне API, а не ускоряет решение.

Что делать, если одна задача в Task.WhenAll зависает дольше остальных?

Task.WhenAll ждёт самую медленную задачу, поэтому один зависший запрос задерживает весь пакет. Решение — общий тайм-аут через CancellationTokenSource (раздел выше) плюс собственный тайм-аут внутри SolveCaptchaAsync, который уже ограничивает опрос 300 секундами.

Можно ли в одном пакете смешивать reCAPTCHA v2, Cloudflare Turnstile и GeeTest v3?

Да — CaptchaTask не привязан к типу CAPTCHA, важно только передать корректный method и параметры при отправке в in.php для каждого типа. Логика Task.WhenAll и SemaphoreSlim из этой статьи работает одинаково для reCAPTCHA v2, Turnstile и GeeTest v3.

SemaphoreSlim обязателен, или можно обойтись одним Task.WhenAll?

Для 10–20 задач разница не критична. Начиная с сотен задач SemaphoreSlim обязателен: без него Task.WhenAll запускает все запросы одновременно, что при 1000+ задачах приводит к высокому потреблению памяти и открытых соединений раньше, чем к ускорению решения.

Чем это отличается от Parallel.ForEachAsync?

Task.WhenAll возвращает единый BatchResult со списками Solved и Failed после завершения всего пакета — удобно для отчётности. Parallel.ForEachAsync (.NET 6+) обрабатывает каждый результат сразу по готовности и не требует ручного SemaphoreSlim, но не даёт готовой сводки по всему пакету.

Следующие шаги

Комментарии для этой статьи отключены.