Tutorials

Тестирование CaptchaAI перед полной миграцией: руководство по параллельному запуску

Единственный честный способ сравнить два сервиса решения CAPTCHA — отправить им одну и ту же задачу в одну и ту же секунду и посмотреть, что вернулось. Это и есть параллельный запуск: текущий провайдер остаётся основным, CaptchaAI подключается вторым каналом, а решение о миграции принимается по собственным цифрам, а не по чужому лендингу. Ниже — схема стенда, код на Python и Node.js, набор метрик и план перехода, который не ломает продакшен.

Почему чужие бенчмарки не отвечают на ваш вопрос

Доля успешных решений и время решения зависят от того, что вы решаете: разные sitekey, страницы, прокси и распределение нагрузки по часам сдвигают цифры. Провайдер, выигрывающий на публичном тесте, вполне может проседать именно на вашей форме.

Вторая причина чисто инженерная. При миграции меняется не только качество решений: другие коды ошибок, другое поведение при тайм-ауте, другой формат ответа. Параллельный запуск ловит эти различия на живом трафике до того, как они станут ночным инцидентом.

Локальный пример: агентство в Алматы

Типичная ситуация: агентство держит несколько десятков парсеров для клиентов и хочет предсказуемый месячный счёт вместо оплаты по факту решений. У CaptchaAI тарификация идёт по потокам — BASIC ($15/мес, 5 потоков), STANDARD ($30/мес, 15 потоков), ADVANCE ($90/мес, 50 потоков), — и число решений внутри потока не ограничено. Значит, считать надо не капчи за месяц, а задачи, висящие одновременно в пике; параллельный тест эту величину и показывает. Цены указаны в USD.

Схема стенда

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

                    ┌──────────────┐
                    │ Your App     │
                    └──────┬───────┘
                           │
                    ┌──────▼───────┐
                    │ CAPTCHA      │
                    │ Router       │
                    └──┬───────┬───┘
                       │       │
              ┌────────▼──┐ ┌──▼────────┐
              │ Current   │ │ CaptchaAI │
              │ Provider  │ │           │
              └────────┬──┘ └──┬────────┘
                       │       │
                    ┌──▼───────▼──┐
                    │ Metrics     │
                    │ Collector   │
                    └─────────────┘

Важная деталь: роутер замеряет время сам по обеим веткам и не считает успехом просто HTTP 200.

Шаг 1. Обёртка над провайдером на Python

Оба сервиса работают по схеме in.php / res.php, поэтому достаточно одного класса с разными базовыми URL и ключами. Метод отправляет задачу, получает ID задачи и опрашивает результат до готовности, фиксируя затраченное время.

import os
import time
import requests
from dataclasses import dataclass, field
from typing import Optional
from concurrent.futures import ThreadPoolExecutor


@dataclass
class SolveResult:
    provider: str
    success: bool
    solution: Optional[str] = None
    error: Optional[str] = None
    elapsed: float = 0.0
    cost: float = 0.0


class CaptchaProvider:
    def __init__(self, name, submit_url, result_url, api_key):
        self.name = name
        self.submit_url = submit_url
        self.result_url = result_url
        self.api_key = api_key
        self.session = requests.Session()

    def solve_recaptcha(self, sitekey, pageurl):
        start = time.time()

        resp = self.session.post(self.submit_url, data={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": 1
        })
        data = resp.json()
        if data.get("status") != 1:
            return SolveResult(
                provider=self.name, success=False,
                error=data.get("request"), elapsed=time.time() - start
            )

        captcha_id = data["request"]

        for _ in range(60):
            time.sleep(5)
            result = self.session.get(self.result_url, params={
                "key": self.api_key, "action": "get",
                "id": captcha_id, "json": 1
            }).json()

            if result.get("status") == 1:
                return SolveResult(
                    provider=self.name, success=True,
                    solution=result["request"], elapsed=time.time() - start
                )
            if result.get("request") != "CAPCHA_NOT_READY":
                return SolveResult(
                    provider=self.name, success=False,
                    error=result.get("request"), elapsed=time.time() - start
                )

        return SolveResult(
            provider=self.name, success=False,
            error="TIMEOUT", elapsed=time.time() - start
        )

Здесь важны две вещи. elapsed считается от момента отправки, а не от первого успешного опроса — иначе вы измеряете собственный интервал опроса, а не сервис. И ошибки с тайм-аутами возвращаются тем же объектом, что и успех: неудачные попытки нужно считать, а не проглатывать.

Шаг 2. Параллельный прогон и сводный отчёт

Дальше запускаем обе ветки одновременно и собираем сводку: доля успешных решений, среднее, минимальное и максимальное время, коды ошибок.

class ParallelTestRunner:
    def __init__(self, primary, challenger):
        self.primary = primary
        self.challenger = challenger
        self.results = {"primary": [], "challenger": []}

    def run_test(self, sitekey, pageurl, num_runs=20):
        print(f"Running {num_runs} parallel solves...")

        for i in range(num_runs):
            with ThreadPoolExecutor(max_workers=2) as executor:
                primary_future = executor.submit(
                    self.primary.solve_recaptcha, sitekey, pageurl
                )
                challenger_future = executor.submit(
                    self.challenger.solve_recaptcha, sitekey, pageurl
                )

                primary_result = primary_future.result()
                challenger_result = challenger_future.result()

            self.results["primary"].append(primary_result)
            self.results["challenger"].append(challenger_result)

            print(f"  Run {i+1}/{num_runs}: "
                  f"{self.primary.name}={'OK' if primary_result.success else 'FAIL'} "
                  f"({primary_result.elapsed:.1f}s) | "
                  f"{self.challenger.name}={'OK' if challenger_result.success else 'FAIL'} "
                  f"({challenger_result.elapsed:.1f}s)")

        return self.generate_report()

    def generate_report(self):
        report = {}
        for label, results in self.results.items():
            total = len(results)
            successes = sum(1 for r in results if r.success)
            times = [r.elapsed for r in results if r.success]
            errors = [r.error for r in results if not r.success]

            report[label] = {
                "provider": results[0].provider if results else "unknown",
                "total": total,
                "successes": successes,
                "success_rate": (successes / total * 100) if total else 0,
                "avg_time": sum(times) / len(times) if times else 0,
                "min_time": min(times) if times else 0,
                "max_time": max(times) if times else 0,
                "errors": errors
            }

        return report


# Usage
current = CaptchaProvider(
    name="CurrentProvider",
    submit_url="https://current-provider.com/in.php",
    result_url="https://current-provider.com/res.php",
    api_key="current_key"
)

captchaai = CaptchaProvider(
    name="CaptchaAI",
    submit_url="https://ocr.captchaai.com/in.php",
    result_url="https://ocr.captchaai.com/res.php",
    api_key=os.environ["CAPTCHAAI_API_KEY"]
)

runner = ParallelTestRunner(primary=current, challenger=captchaai)
report = runner.run_test(
    sitekey="6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
    pageurl="https://example.com/form",
    num_runs=20
)

for label, stats in report.items():
    print(f"\n{stats['provider']}:")
    print(f"  Success rate: {stats['success_rate']:.1f}%")
    print(f"  Avg time: {stats['avg_time']:.1f}s")
    print(f"  Min/Max: {stats['min_time']:.1f}s / {stats['max_time']:.1f}s")
    if stats['errors']:
        print(f"  Errors: {stats['errors']}")

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

Шаг 3. Разделение трафика в продакшене

Когда совместимость подтверждена, переходите от зеркалирования к реальной доле трафика. Начните с 10 % и оставьте возврат к основному провайдеру при неудаче — тогда эксперимент не задевает пользователей.

import random


class TrafficSplitter:
    def __init__(self, primary, challenger, challenger_pct=10):
        self.primary = primary
        self.challenger = challenger
        self.challenger_pct = challenger_pct

    def solve(self, sitekey, pageurl):
        if random.randint(1, 100) <= self.challenger_pct:
            result = self.challenger.solve_recaptcha(sitekey, pageurl)
            if not result.success:
                # Fall back to primary on failure
                return self.primary.solve_recaptcha(sitekey, pageurl)
            return result
        return self.primary.solve_recaptcha(sitekey, pageurl)


# Start with 10%, increase as confidence builds
splitter = TrafficSplitter(current, captchaai, challenger_pct=10)
result = splitter.solve(sitekey="...", pageurl="...")

Долю поднимайте только после суток стабильных метрик на текущем уровне.

Шаг 4. То же самое на Node.js

Если стек на JavaScript, логика та же: те же in.php и res.php, тот же подсчёт времени, Promise.all вместо пула потоков.

const axios = require("axios");

class CaptchaProvider {
  constructor(name, submitUrl, resultUrl, apiKey) {
    this.name = name;
    this.submitUrl = submitUrl;
    this.resultUrl = resultUrl;
    this.apiKey = apiKey;
  }

  async solveRecaptcha(sitekey, pageurl) {
    const start = Date.now();
    try {
      const submit = await axios.post(this.submitUrl, null, {
        params: { key: this.apiKey, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
      });
      if (submit.data.status !== 1) {
        return { provider: this.name, success: false, error: submit.data.request, elapsed: (Date.now() - start) / 1000 };
      }

      const captchaId = submit.data.request;
      for (let i = 0; i < 60; i++) {
        await new Promise((r) => setTimeout(r, 5000));
        const poll = await axios.get(this.resultUrl, {
          params: { key: this.apiKey, action: "get", id: captchaId, json: 1 },
        });
        if (poll.data.status === 1) {
          return { provider: this.name, success: true, solution: poll.data.request, elapsed: (Date.now() - start) / 1000 };
        }
        if (poll.data.request !== "CAPCHA_NOT_READY") {
          return { provider: this.name, success: false, error: poll.data.request, elapsed: (Date.now() - start) / 1000 };
        }
      }
      return { provider: this.name, success: false, error: "TIMEOUT", elapsed: (Date.now() - start) / 1000 };
    } catch (err) {
      return { provider: this.name, success: false, error: err.message, elapsed: (Date.now() - start) / 1000 };
    }
  }
}

async function parallelTest(current, captchaai, sitekey, pageurl, runs = 20) {
  const results = { current: [], captchaai: [] };

  for (let i = 0; i < runs; i++) {
    const [currentResult, captchaaiResult] = await Promise.all([
      current.solveRecaptcha(sitekey, pageurl),
      captchaai.solveRecaptcha(sitekey, pageurl),
    ]);

    results.current.push(currentResult);
    results.captchaai.push(captchaaiResult);

    console.log(`Run ${i + 1}/${runs}: ${current.name}=${currentResult.success ? "OK" : "FAIL"} ` +
      `(${currentResult.elapsed.toFixed(1)}s) | ${captchaai.name}=${captchaaiResult.success ? "OK" : "FAIL"} ` +
      `(${captchaaiResult.elapsed.toFixed(1)}s)`);
  }

  for (const [label, data] of Object.entries(results)) {
    const successes = data.filter((r) => r.success).length;
    const times = data.filter((r) => r.success).map((r) => r.elapsed);
    const avgTime = times.length ? times.reduce((a, b) => a + b, 0) / times.length : 0;
    console.log(`\n${label}: ${successes}/${runs} success (${((successes / runs) * 100).toFixed(1)}%), avg ${avgTime.toFixed(1)}s`);
  }
}

// Run
const currentProvider = new CaptchaProvider("CurrentProvider", "https://current-provider.com/in.php", "https://current-provider.com/res.php", "current_key");
const captchaai = new CaptchaProvider("CaptchaAI", "https://ocr.captchaai.com/in.php", "https://ocr.captchaai.com/res.php", process.env.CAPTCHAAI_API_KEY);

parallelTest(currentProvider, captchaai, "SITE_KEY", "https://example.com", 20);

План перехода по фазам

Фаза Длительность Доля трафика Что проверяем
1. Проверка совместимости 1 день 0 % боевого, только зеркало Форматы ответа, коды ошибок, поведение при тайм-ауте
2. Теневой прогон 3 дня 5 % на CaptchaAI с возвратом к основному Базовые метрики на реальном трафике
3. Наращивание 1 неделя 25 % → 50 % → 75 % Стабильность на каждом уровне, расход потоков
4. Полное переключение 100 % на CaptchaAI Отключение старого провайдера, финальный отчёт

Между фазами держите паузу минимум в сутки: суточный цикл трафика показывает то, чего не видно за два часа.

Какие метрики сравнивать

Метрика Как считать
Доля успешных решений успешные_решения / всего_попыток × 100
Среднее время решения От отправки задачи до получения токена
P95 времени решения 95-й процентиль — именно он определяет ваши тайм-ауты
Ошибки по кодам Каждый код ошибки считается отдельно, без слияния в «прочее»
Стоимость решения Общие расходы за период / успешные решения
Валидность токена Приняла ли токен целевая форма, а не только API провайдера

Последняя строка — самая важная и самая забываемая. Провайдер может вернуть корректный по формату токен, который целевая страница затем отклонит; в статистику должны попадать только решения, прошедшие до конца.

Типичные ошибки при параллельном тесте

Симптом Причина Что сделать
CaptchaAI медленнее в тесте Разница сетевой задержки Запускайте тест с продакшен-серверов, а не с ноутбука
Доли успешных решений сильно расходятся Выборка слишком мала Не менее 50 попыток на провайдера, разнесённых по времени
Токен работает у одного провайдера, но не у другого Токен успел устареть Используйте токен сразу после получения, не складывайте в очередь
Расходы за время теста удвоились Каждая задача решается дважды Заложите бюджет на период теста — это дешевле неудачной миграции
Цифры прыгают день ото дня Тест попал на разные профили трафика Сравнивайте одинаковые окна: будни с буднями, пик с пиком

Отдельный пункт — поддерживаемые типы. Планируя тест, сверьтесь с тем, что CaptchaAI решает: reCAPTCHA v2 и v3, включая Enterprise-варианты, Cloudflare Turnstile и Cloudflare Challenge, GeeTest v3, image/OCR, grid-капчи и BLS CAPTCHA; CaptchaFox (beta), Friendly Captcha (beta) и Lemin (beta) доступны в бета-режиме. hCaptcha и FunCaptcha не поддерживаются, GeeTest v4 — в разработке. Если ваш трафик состоит в основном из неподдерживаемых типов, сравнивать будет нечего.

И если тест идёт на парсинге, помните про требования к персональным данным (для РФ — 152-ФЗ): собирайте только то, что вправе обрабатывать.

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

Сколько прогонов нужно, чтобы верить результату?

От 50 попыток на каждого провайдера как минимум. Лучше 100 и больше, распределённых по разным часам суток и дням недели — иначе вы измеряете один срез нагрузки, а не поведение сервиса.

Можно ли обойтись без удвоения расходов на время теста?

Полностью — нет: на этапе зеркалирования каждая задача решается дважды. Сократить можно за счёт длительности: одного дня валидации и трёх дней теневого прогона обычно достаточно, дальше вы платите уже за долю трафика, а не за дубль.

Как выбрать тариф по результатам теста?

Смотрите на пиковое число одновременных задач, а не на суммарный объём за месяц: тарификация у CaptchaAI идёт по потокам, а решения внутри потока не ограничены. Если в пике висит около 12 задач, подойдёт STANDARD ($30/мес, 15 потоков); если около 40 — ADVANCE ($90/мес, 50 потоков).

Что делать, если результаты расходятся только по одному типу CAPTCHA?

Разделите статистику по типам и взвесьте по реальной доле каждого типа в вашем трафике. Общая средняя цифра такие расхождения скрывает, и решение, принятое по ней, окажется неверным.

Что считать поводом откатиться на предыдущую фазу?

Заранее задайте пороги: доля успешных решений ниже базовой линии основного провайдера или P95 выше вашего тайм-аута. Откат — это возврат challenger_pct к прошлому значению, старый провайдер всё это время остаётся подключённым.


Что почитать дальше

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