API Tutorials

Создание клиентской библиотеки Go для API CaptchaAI

Обращаться к CaptchaAI через голые net/http-запросы можно, но уже на третьем вызове в коде появляются скопированные URL и необработанные ошибки в виде голых строк. Ниже — пакет captchaai: типизированные методы под каждый тип CAPTCHA, отмена через context.Context, подключаемый http.Client и предсказуемые типы ошибок.

Пример по нагрузке: сервис, решающий около 300 задач reCAPTCHA v2 в час, укладывается в STANDARD ($30/мес, 15 потоков) — Go легко держит 15 горутин параллельно, а тарификация по потокам делает расходы предсказуемыми даже в пиковые часы. Это ценно при биллинге в USD для фрилансеров и агентств из СНГ, зависящих от курса валют.

Пакет ниже закрывает четыре практические задачи:

  • отправку задачи в in.php и опрос res.php без дублирования кода на каждый тип CAPTCHA;
  • различение фатальных ошибок API (повторять их бессмысленно) и временных сбоев;
  • отмену через context.Context, чтобы решение останавливалось вместе с родительским запросом;
  • подключение своего http.Client — например, с прокси или иными таймаутами — без правки логики пакета.

Структура пакета

Файлы разделены по ответственности: errors.go описывает типы ошибок, types.go — параметры запросов и внутренние структуры ответа, client.go — HTTP-логику submit/poll и публичные методы Solve*, а client_test.go — тесты. Такое разделение упрощает ревью и позволяет тестировать submit/poll отдельно от конкретного типа CAPTCHA.

captchaai/
├── client.go       # Main client and solve logic
├── errors.go       # Error types
├── types.go        # Request/response structs
└── client_test.go  # Tests

Типы ошибок API

Два типа ошибок закрывают разные сценарии. APIError оборачивает код и сообщение, которые вернул CaptchaAI, и отвечает на вопрос IsFatal() — стоит ли вообще повторять запрос. Ошибки вроде ERROR_ZERO_BALANCE или ERROR_WRONG_USER_KEY ретраить бессмысленно: баланс сам не пополнится, а ключ сам не станет верным. TimeoutError — отдельный тип на случай, когда res.php не вернул результат за отведённое время; он хранит TaskID, чтобы можно было залогировать, какая именно задача зависла.

// errors.go
package captchaai

import "fmt"

// APIError represents a CaptchaAI API error response.
type APIError struct {
    Code    string
    Message string
}

func (e *APIError) Error() string {
    return fmt.Sprintf("captchaai: %s (%s)", e.Message, e.Code)
}

// IsFatal returns true if this error should not be retried.
func (e *APIError) IsFatal() bool {
    switch e.Code {
    case "ERROR_WRONG_USER_KEY", "ERROR_KEY_DOES_NOT_EXIST",
        "ERROR_ZERO_BALANCE", "ERROR_IP_NOT_ALLOWED":
        return true
    }
    return false
}

// TimeoutError indicates the solve exceeded the configured timeout.
type TimeoutError struct {
    TaskID string
}

func (e *TimeoutError) Error() string {
    return fmt.Sprintf("captchaai: task %s timed out", e.TaskID)
}
  • APIError.IsFatal() — единственное место, где решается судьба повтора;
  • TimeoutError не является APIError — это отдельная ветка обработки у вызывающего кода;
  • оба типа реализуют интерфейс error, поэтому дальше по коду достаточно errors.As.

Структуры запроса и ответа

Каждый тип CAPTCHA получает собственную структуру параметров — RecaptchaV2Params, RecaptchaV3Params, TurnstileParams, ImageParams — вместо одной map[string]string на все случаи. Это ловит опечатки в именах полей на этапе компиляции, а не в рантайме после сотни неудачных запросов к in.php. submitResponse и pollResponse — внутренние типы: они не экспортируются, потому что вызывающему коду нужен только итоговый токен, а не сырой ответ CaptchaAI.

// types.go
package captchaai

import "time"

// ClientOption configures the CaptchaAI client.
type ClientOption func(*Client)

// WithPollInterval sets the polling interval between result checks.
func WithPollInterval(d time.Duration) ClientOption {
    return func(c *Client) { c.pollInterval = d }
}

// WithTimeout sets the maximum time to wait for a solution.
func WithTimeout(d time.Duration) ClientOption {
    return func(c *Client) { c.timeout = d }
}

// RecaptchaV2Params holds parameters for reCAPTCHA v2 solving.
type RecaptchaV2Params struct {
    SiteKey   string
    PageURL   string
    Invisible bool
    Cookies   string
}

// RecaptchaV3Params holds parameters for reCAPTCHA v3 solving.
type RecaptchaV3Params struct {
    SiteKey  string
    PageURL  string
    Action   string
}

// TurnstileParams holds parameters for Cloudflare Turnstile solving.
type TurnstileParams struct {
    SiteKey string
    PageURL string
    Action  string
    CData   string
}

// ImageParams holds parameters for image/OCR CAPTCHA solving.
type ImageParams struct {
    Base64Image   string
    CaseSensitive bool
    MinLength     int
    MaxLength     int
}

type submitResponse struct {
    Status  int    `json:"status"`
    Request string `json:"request"`
}

type pollResponse struct {
    Status  int    `json:"status"`
    Request string `json:"request"`
}

Реализация Go-клиента CaptchaAI

Клиент построен вокруг двух приватных методов: submit отправляет задачу в in.php и возвращает taskID, poll опрашивает res.php с интервалом pollInterval до результата или истечения timeout. Публичные методы SolveRecaptchaV2, SolveRecaptchaV3, SolveTurnstile и SolveImage — это разная сборка параметров поверх одной и той же пары submit/poll, что убирает дублирование логики опроса между типами CAPTCHA.

// client.go
package captchaai

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "strconv"
    "time"
)

const (
    submitURL           = "https://ocr.captchaai.com/in.php"
    resultURL           = "https://ocr.captchaai.com/res.php"
    defaultPollInterval = 5 * time.Second
    defaultTimeout      = 180 * time.Second
)

// Client interacts with the CaptchaAI API.
type Client struct {
    apiKey       string
    httpClient   *http.Client
    pollInterval time.Duration
    timeout      time.Duration
}

// New creates a CaptchaAI client with the given API key and options.
func New(apiKey string, opts ...ClientOption) *Client {
    c := &Client{
        apiKey:       apiKey,
        httpClient:   http.DefaultClient,
        pollInterval: defaultPollInterval,
        timeout:      defaultTimeout,
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

// WithHTTPClient sets a custom HTTP client (e.g., for proxy support).
func WithHTTPClient(hc *http.Client) ClientOption {
    return func(c *Client) { c.httpClient = hc }
}

func (c *Client) submit(ctx context.Context, params url.Values) (string, error) {
    params.Set("key", c.apiKey)
    params.Set("json", "1")

    req, err := http.NewRequestWithContext(ctx, http.MethodPost, submitURL, nil)
    if err != nil {
        return "", fmt.Errorf("captchaai: build request: %w", err)
    }
    req.URL.RawQuery = params.Encode()

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("captchaai: submit: %w", err)
    }
    defer resp.Body.Close()

    var result submitResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return "", fmt.Errorf("captchaai: decode submit response: %w", err)
    }

    if result.Status != 1 {
        return "", &APIError{Code: result.Request, Message: "submit failed"}
    }

    return result.Request, nil
}

func (c *Client) poll(ctx context.Context, taskID string) (string, error) {
    deadline := time.After(c.timeout)

    for {
        select {
        case <-ctx.Done():
            return "", ctx.Err()
        case <-deadline:
            return "", &TimeoutError{TaskID: taskID}
        case <-time.After(c.pollInterval):
        }

        params := url.Values{
            "key":    {c.apiKey},
            "action": {"get"},
            "id":     {taskID},
            "json":   {"1"},
        }

        req, err := http.NewRequestWithContext(ctx, http.MethodGet, resultURL+"?"+params.Encode(), nil)
        if err != nil {
            return "", fmt.Errorf("captchaai: build poll request: %w", err)
        }

        resp, err := c.httpClient.Do(req)
        if err != nil {
            continue // Retry on network error
        }

        var result pollResponse
        if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
            resp.Body.Close()
            continue
        }
        resp.Body.Close()

        if result.Request == "CAPCHA_NOT_READY" {
            continue
        }

        if result.Status == 1 {
            return result.Request, nil
        }

        return "", &APIError{Code: result.Request, Message: "solve failed"}
    }
}

// SolveRecaptchaV2 solves a reCAPTCHA v2 challenge.
func (c *Client) SolveRecaptchaV2(ctx context.Context, p RecaptchaV2Params) (string, error) {
    params := url.Values{
        "method":    {"userrecaptcha"},
        "googlekey": {p.SiteKey},
        "pageurl":   {p.PageURL},
    }
    if p.Invisible {
        params.Set("invisible", "1")
    }
    if p.Cookies != "" {
        params.Set("cookies", p.Cookies)
    }

    taskID, err := c.submit(ctx, params)
    if err != nil {
        return "", err
    }
    return c.poll(ctx, taskID)
}

// SolveRecaptchaV3 solves a reCAPTCHA v3 challenge.
func (c *Client) SolveRecaptchaV3(ctx context.Context, p RecaptchaV3Params) (string, error) {
    params := url.Values{
        "method":    {"userrecaptcha"},
        "version":   {"v3"},
        "googlekey": {p.SiteKey},
        "pageurl":   {p.PageURL},
    }
    if p.Action != "" {
        params.Set("action", p.Action)
    }

    taskID, err := c.submit(ctx, params)
    if err != nil {
        return "", err
    }
    return c.poll(ctx, taskID)
}

// SolveTurnstile solves a Cloudflare Turnstile challenge.
func (c *Client) SolveTurnstile(ctx context.Context, p TurnstileParams) (string, error) {
    params := url.Values{
        "method":  {"turnstile"},
        "sitekey": {p.SiteKey},
        "pageurl": {p.PageURL},
    }
    if p.Action != "" {
        params.Set("action", p.Action)
    }
    if p.CData != "" {
        params.Set("data", p.CData)
    }

    taskID, err := c.submit(ctx, params)
    if err != nil {
        return "", err
    }
    return c.poll(ctx, taskID)
}

// SolveImage solves an image/text CAPTCHA from base64.
func (c *Client) SolveImage(ctx context.Context, p ImageParams) (string, error) {
    params := url.Values{
        "method": {"base64"},
        "body":   {p.Base64Image},
    }
    if p.CaseSensitive {
        params.Set("regsense", "1")
    }
    if p.MinLength > 0 {
        params.Set("min_len", strconv.Itoa(p.MinLength))
    }
    if p.MaxLength > 0 {
        params.Set("max_len", strconv.Itoa(p.MaxLength))
    }

    taskID, err := c.submit(ctx, params)
    if err != nil {
        return "", err
    }
    return c.poll(ctx, taskID)
}

// GetBalance returns the current account balance.
func (c *Client) GetBalance(ctx context.Context) (float64, error) {
    params := url.Values{
        "key":    {c.apiKey},
        "action": {"getbalance"},
        "json":   {"1"},
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, resultURL+"?"+params.Encode(), nil)
    if err != nil {
        return 0, err
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()

    var result pollResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return 0, err
    }

    return strconv.ParseFloat(result.Request, 64)
}
  • значение CAPCHA_NOT_READY в ответе res.php — не ошибка, а сигнал «опросите ещё раз»: цикл в poll обрабатывает его молча;
  • сетевые сбои внутри poll тоже не прерывают цикл — continue уходит на следующую попытку, пока не истечёт context или timeout;
  • WithHTTPClient — единственная точка, где нужно подменить транспорт: прокси, другие таймауты, метрики.

Пример использования

Пример собирает типичный сценарий целиком: проверку баланса перед стартом, решение reCAPTCHA v2 с обработкой фатальной ошибки через IsFatal(), и решение Turnstile с отдельным context.WithTimeout, который короче общего таймаута клиента, — это полезно, если для одного вызова нужен более жёсткий SLA, чем для остальных.

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "your-module/captchaai"
)

func main() {
    client := captchaai.New("YOUR_API_KEY",
        captchaai.WithTimeout(120*time.Second),
        captchaai.WithPollInterval(5*time.Second),
    )

    ctx := context.Background()

    // Check balance
    balance, err := client.GetBalance(ctx)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Balance: $%.2f\n", balance)

    // Solve reCAPTCHA v2
    token, err := client.SolveRecaptchaV2(ctx, captchaai.RecaptchaV2Params{
        SiteKey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
        PageURL: "https://staging.example.com/qa-login",
    })
    if err != nil {
        var apiErr *captchaai.APIError
        if errors.As(err, &apiErr) && apiErr.IsFatal() {
            log.Fatalf("Fatal API error: %s", apiErr.Code)
        }
        log.Fatal(err)
    }
    fmt.Printf("Token: %s...\n", token[:40])

    // Solve with context timeout
    solveCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
    defer cancel()

    turnstileToken, err := client.SolveTurnstile(solveCtx, captchaai.TurnstileParams{
        SiteKey: "0x4AAAAAAADnPIDROrmt1Wwj",
        PageURL: "https://example.com/checkout",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Turnstile: %s...\n", turnstileToken[:40])
}

Частые вопросы о Go-клиенте CaptchaAI

Сколько потоков CaptchaAI закладывать под конкурентный Go-сервис?

Тариф считает потоки, а не решения — ориентируйтесь на пиковое число одновременных задач, а не на месячный трафик. При 10–12 параллельных вызовах SolveRecaptchaV2 хватит STANDARD ($30/мес, 15 потоков); для большего параллелизма берите ADVANCE ($90/мес, 50 потоков).

Что делать при ошибке ERROR_ZERO_BALANCE или ERROR_WRONG_USER_KEY?

Обе ошибки помечены как фатальные в IsFatal() — повторять запрос бессмысленно. ERROR_ZERO_BALANCE значит: пополните баланс; ERROR_WRONG_USER_KEY — в клиент передан неверный API-ключ.

Как подключить прокси, не меняя логику пакета?

Соберите http.Client с Transport на базе http.ProxyURL и передайте его через WithHTTPClient при вызове New(). Остальной код пакета — submit, poll, методы Solve* — не меняется.

Можно ли решать несколько CAPTCHA параллельно через горутины?

Да, клиент безопасен для конкурентного использования: каждый вызов SolveRecaptchaV2/SolveTurnstile работает со своим context.Context и не делит состояние между горутинами.

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

Почти все проблемы с этим клиентом сводятся к пяти сценариям — ниже они собраны вместе с причиной и тем, что проверить в первую очередь.

Проблема Причина Решение
context deadline exceeded Решение заняло дольше таймаута контекста Увеличьте WithTimeout при создании клиента
captchaai: submit failed (ERROR_ZERO_BALANCE) На балансе нет средств Пополните баланс в панели управления
Опрос res.php не завершается Сеть недоступна или неверный URL Проверьте соединение и константы submitURL/resultURL
Ошибка компиляции на errors.As Не хватает импорта errors Добавьте "errors" в импорты
Кастомный http.Client не применяется Забыли WithHTTPClient captchaai.New(key, captchaai.WithHTTPClient(myClient))

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

Дальше — по ссылкам ниже: быстрый старт для общего знакомства с API и предметные руководства по конкретным типам CAPTCHA, которые уже встречались в примерах выше.

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