Troubleshooting

Сайт с reCAPTCHA v2 и Cloudflare Turnstile одновременно

Если сценарий падает через раз на одной и той же странице — скорее всего, вы жёстко зашили один метод решения, а сайт отдаёт то reCAPTCHA v2, то Cloudflare Turnstile. Лечится это не подбором тайм-аутов, а одним архитектурным решением: определять провайдера по разметке при каждом запросе страницы и уже потом выбирать метод API и имя поля для токена. Ниже — маркеры в разметке, по которым тип узнаётся надёжно, готовый код на Python и JavaScript и разбор ошибок, из-за которых корректно решённый токен всё равно отклоняется формой.

Почему на одном домене оказываются два провайдера

Это не редкость и почти никогда не ошибка вёрстки. Типичные причины:

Сценарий Как это выглядит на практике
Разные страницы — разные провайдеры Логин закрыт reCAPTCHA, оформление заказа — Cloudflare Turnstile
A/B-тест провайдеров Один и тот же URL показывает случайный из двух типов
Незавершённая миграция Старые разделы на reCAPTCHA, новые уже на Cloudflare Turnstile
Запасной вариант при сбое Основной провайдер не отвечает → подключается второй
Региональные различия reCAPTCHA для одних регионов, Cloudflare Turnstile для других (соображения GDPR)

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

Локальный пример: один сценарий на несколько площадок

Типичная задача для команды из Москвы, Минска или Алматы — один парсер собирает публичные каталоги сразу нескольких региональных площадок. Часть из них уже переехала на Cloudflare Turnstile ради более быстрой проверки и меньшего числа сторонних запросов, часть осталась на reCAPTCHA v2. Держать по копии сценария под каждую площадку — тупик: любую правку приходится вносить дважды, а при следующей миграции всё ломается снова. Один сценарий с автоопределением типа снимает эту работу целиком.

Отдельная сноска для этого рынка: если по пути вы сохраняете какие-либо пользовательские данные, собирайте только то, что вы вправе обрабатывать — требования 152-ФЗ «О персональных данных» и GDPR-подобная дисциплина здесь работают на вас. Это ответственность читателя, а не свойство сервиса.

Как определить провайдера по разметке

Различить типы можно по трём независимым признакам: CSS-классу контейнера, адресу подключаемого скрипта и имени поля, куда форма ждёт токен.

Провайдер HTML-маркер URL-адрес скрипта Поле ответа
reCAPTCHA v2 class="g-recaptcha" google.com/recaptcha/api.js g-recaptcha-response
Cloudflare Turnstile class="cf-turnstile" challenges.cloudflare.com/turnstile cf-turnstile-response
hCaptcha class="h-captcha" js.hcaptcha.com/1/api.js h-captcha-response

Строка hCaptcha здесь справочная: по этим маркерам вы поймёте, что перед вами именно hCaptcha. Решение hCaptcha в CaptchaAI не поддерживается, поэтому такую страницу сценарий должен корректно пропустить, а не отправлять задачу в API.

Ключевой момент — порядок проверок. Атрибут data-sitekey есть у всех трёх провайдеров, поэтому искать надо сначала классоспецифичный маркер и только потом ключ. Обратный порядок даёт самую частую ошибку: Turnstile опознаётся как reCAPTCHA, метод API выбирается формально валидный, но не тот.

Python: определение типа и решение в одном проходе

Функция detect_captcha_type() возвращает структуру с провайдером, методом API, значением sitekey и именем поля ответа; solve_captcha() собирает параметры под нужный метод и опрашивает res.php до готовности результата. Обратите внимание: для reCAPTCHA ключ сайта передаётся как googlekey, а для Turnstile — как sitekey. Это разные имена параметров у одного и того же эндпоинта.

import requests
import time
import re
from dataclasses import dataclass

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


@dataclass
class CaptchaInfo:
    provider: str     # "recaptcha" or "turnstile"
    method: str       # API method name
    sitekey: str
    pageurl: str
    response_field: str  # Form field name for the token


def detect_captcha_type(html, pageurl):
    """
    Detect which CAPTCHA provider is on the page.
    Returns CaptchaInfo or None.
    """
    # Check for Turnstile
    turnstile_match = re.search(
        r'class=["\'][^"\']*cf-turnstile[^"\']*["\'][^>]*data-sitekey=["\']([^"\']+)["\']',
        html,
    )
    if not turnstile_match:
        turnstile_match = re.search(
            r'data-sitekey=["\']([^"\']+)["\'][^>]*class=["\'][^"\']*cf-turnstile',
            html,
        )

    if turnstile_match:
        return CaptchaInfo(
            provider="turnstile",
            method="turnstile",
            sitekey=turnstile_match.group(1),
            pageurl=pageurl,
            response_field="cf-turnstile-response",
        )

    # Check for reCAPTCHA
    recaptcha_match = re.search(
        r'class=["\'][^"\']*g-recaptcha[^"\']*["\'][^>]*data-sitekey=["\']([^"\']+)["\']',
        html,
    )
    if not recaptcha_match:
        recaptcha_match = re.search(
            r'data-sitekey=["\']([^"\']+)["\'][^>]*class=["\'][^"\']*g-recaptcha',
            html,
        )

    # Also check for script-rendered reCAPTCHA
    if not recaptcha_match:
        recaptcha_match = re.search(
            r'grecaptcha\.render\([^,]+,\s*\{[^}]*["\']sitekey["\']\s*:\s*["\']([^"\']+)["\']',
            html,
        )

    if recaptcha_match:
        return CaptchaInfo(
            provider="recaptcha",
            method="userrecaptcha",
            sitekey=recaptcha_match.group(1),
            pageurl=pageurl,
            response_field="g-recaptcha-response",
        )

    return None


def solve_captcha(info):
    """Solve any detected CAPTCHA type via CaptchaAI."""
    params = {
        "key": API_KEY,
        "method": info.method,
        "json": 1,
    }

    if info.method == "userrecaptcha":
        params["googlekey"] = info.sitekey
        params["pageurl"] = info.pageurl
    elif info.method == "turnstile":
        params["sitekey"] = info.sitekey
        params["pageurl"] = info.pageurl

    resp = requests.post(SUBMIT_URL, data=params, timeout=30).json()
    if resp.get("status") != 1:
        raise RuntimeError(f"Submit failed: {resp.get('request')}")

    task_id = resp["request"]
    for _ in range(60):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "get",
            "id": task_id, "json": 1,
        }, timeout=15).json()

        if poll.get("request") == "CAPCHA_NOT_READY":
            continue
        if poll.get("status") == 1:
            return poll["request"]
        raise RuntimeError(f"Solve failed: {poll.get('request')}")

    raise RuntimeError("Timeout")


def process_page(session, url):
    """Fetch page, detect CAPTCHA type, solve, and return form-ready data."""
    response = session.get(url)
    captcha_info = detect_captcha_type(response.text, url)

    if not captcha_info:
        print(f"No CAPTCHA detected on {url}")
        return None

    print(f"Detected {captcha_info.provider} on {url}")
    print(f"  Sitekey: {captcha_info.sitekey[:30]}...")

    token = solve_captcha(captcha_info)
    print(f"  Solved: {token[:30]}...")

    return {
        "provider": captcha_info.provider,
        "response_field": captcha_info.response_field,
        "token": token,
    }


# Usage: Handle multiple pages with different providers
session = requests.Session()

pages = [
    "https://staging.example.com/qa-login",      # Might have reCAPTCHA
    "https://example.com/checkout",   # Might have Turnstile
]

for url in pages:
    result = process_page(session, url)
    if result:
        form_data = {result["response_field"]: result["token"]}
        # Add other form fields...
        # session.post(url, data=form_data)

Отдельный запасной путь внутри функции определения — регулярное выражение по вызову grecaptcha.render(). Оно нужно для страниц, где виджет создаётся скриптом уже после загрузки и в исходном HTML нет ни класса, ни атрибута data-sitekey.

JavaScript: та же логика в Node.js

Если пайплайн уже написан на Node.js, переносить его на Python ради этой задачи незачем — логика повторяется один в один: сначала маркер Turnstile, затем маркер reCAPTCHA, затем разбор скриптовой отрисовки.

const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

function detectCaptchaType(html, pageurl) {
  // Turnstile
  const turnstileMatch = html.match(/cf-turnstile[^>]*data-sitekey=["']([^"']+)["']/);
  if (turnstileMatch) {
    return { provider: "turnstile", method: "turnstile", sitekey: turnstileMatch[1], pageurl, field: "cf-turnstile-response" };
  }

  // reCAPTCHA
  const recaptchaMatch = html.match(/g-recaptcha[^>]*data-sitekey=["']([^"']+)["']/);
  if (recaptchaMatch) {
    return { provider: "recaptcha", method: "userrecaptcha", sitekey: recaptchaMatch[1], pageurl, field: "g-recaptcha-response" };
  }

  // Script-rendered reCAPTCHA
  const scriptMatch = html.match(/sitekey["']\s*:\s*["']([^"']+)["']/);
  if (scriptMatch) {
    return { provider: "recaptcha", method: "userrecaptcha", sitekey: scriptMatch[1], pageurl, field: "g-recaptcha-response" };
  }

  return null;
}

async function solveCaptcha(info) {
  const body = new URLSearchParams({ key: API_KEY, method: info.method, json: "1" });
  if (info.method === "userrecaptcha") { body.set("googlekey", info.sitekey); body.set("pageurl", info.pageurl); }
  else if (info.method === "turnstile") { body.set("sitekey", info.sitekey); body.set("pageurl", info.pageurl); }

  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body })).json();
  if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);

  const taskId = resp.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
    const poll = await (await fetch(url)).json();
    if (poll.request === "CAPCHA_NOT_READY") continue;
    if (poll.status === 1) return poll.request;
    throw new Error(`Solve: ${poll.request}`);
  }
  throw new Error("Timeout");
}

async function processPage(url) {
  const response = await fetch(url);
  const html = await response.text();
  const info = detectCaptchaType(html, url);

  if (!info) { console.log(`No CAPTCHA on ${url}`); return null; }
  console.log(`${info.provider} detected on ${url}`);

  const token = await solveCaptcha(info);
  return { provider: info.provider, field: info.field, token };
}

// Usage
const pages = ["https://staging.example.com/qa-login", "https://example.com/checkout"];
for (const url of pages) {
  const result = await processPage(url);
  if (result) {
    console.log(`Solved ${result.provider}: ${result.token.substring(0, 30)}...`);
  }
}

Что ломается чаще всего

Проблема Причина Что делать
Определился не тот тип Регулярное выражение зацепилось за data-sitekey чужого виджета Сначала проверяйте классы cf-turnstile и g-recaptcha, и только потом ключ
Токен получен, но форма его отклоняет Использовано имя поля от другого провайдера Поле должно совпадать с типом: g-recaptcha-response против cf-turnstile-response
Тип меняется между запусками A/B-тест или выбор по региону Определяйте провайдера при каждом запросе страницы, не кэшируйте результат первого визита
На странице найдены оба виджета Один из них скрыт или неактивен Проверьте видимость элемента и непустой data-sitekey — решать нужно только активный
Ничего не найдено, хотя проверка видна Виджет отрисован скриптом Ищите вызовы grecaptcha.render() или turnstile.render() в теле страницы
Задача уходит в тайм-аут на Turnstile Ожидание рассчитано по профилю reCAPTCHA Ориентируйтесь на тип: Cloudflare Turnstile обычно решается менее чем за 10 с, reCAPTCHA v2 — менее чем за 60 с

Последняя строка стоит отдельного пояснения. Смешанный сайт легко приводит к тому, что общий тайм-аут выставляется по одному типу, а второй в него не укладывается или, наоборот, простаивает. Разумнее держать два разных лимита ожидания и выбирать нужный по тому же полю provider, что вернул детект.

Сколько потоков заложить

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

FAQ

Как понять, что на странице Turnstile, а не reCAPTCHA?

По классу контейнера и адресу скрипта: cf-turnstile и challenges.cloudflare.com означают Cloudflare Turnstile, g-recaptcha и google.com/recaptcha/api.js — reCAPTCHA. Смотреть в первую очередь на data-sitekey нельзя: этот атрибут есть у обоих.

Нужны ли разные API-ключи под reCAPTCHA и Turnstile?

Нет. Ключ один, эндпоинты те же — in.php и res.php. Различаются только значение method (userrecaptcha против turnstile) и имя параметра для ключа сайта (googlekey против sitekey).

Что делать, если тип поменялся посреди сессии?

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

Что делать, если на том же домене попалась hCaptcha?

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

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

Отталкивайтесь от типа, а не от одного общего числа. Для Cloudflare Turnstile разумно ждать заметно меньше, чем для reCAPTCHA v2. Интервал в 5 с между опросами res.php и общий лимит попыток, как в примерах выше, покрывают оба случая без лишней нагрузки на API.


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

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