Tutorials

Решение Cloudflare Turnstile с помощью Python requests и CaptchaAI

Cloudflare Turnstile не требует браузера: виджет выдаёт обычную строку-токен, которую сервер проверяет в поле формы cf-turnstile-response. Весь цикл укладывается в три HTTP-вызова через requests — забрать sitekey, отправить задачу в API CaptchaAI, дождаться токена. Selenium и Playwright здесь не нужны, и поэтому Turnstile обходится в автоматизации дешевле reCAPTCHA v2.

Ниже — весь путь: от разбора HTML до класса с повторными попытками. Код оставлен на «голом» requests: его можно вставить в парсер, воркер очереди или интеграционный тест без лишних зависимостей.


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

pip install requests

Минимальный набор:

  • API-ключ CaptchaAI из личного кабинета на captchaai.com — там же виден баланс потоков;
  • URL страницы с виджетом;
  • sitekey Cloudflare Turnstile (обычно начинается с 0x4AAAA…).

Тарификация идёт по потокам, а не по числу решений: BASIC ($15/мес, 5 потоков) — это пять одновременных задач без месячного лимита на их количество. Парсеру на 40–50 воркеров ближе ADVANCE ($90/мес, 50 потоков). Считать «стоимость одной капчи» бессмысленно: вы платите за ширину канала. Для команд в Москве, Минске или Алматы, сводящих бюджет в волатильной валюте, фиксированный платёж в USD предсказуемее.


Шаг 1: извлеките sitekey из HTML страницы

Ключ сайта лежит прямо в разметке — чаще всего в атрибуте data-sitekey у контейнера виджета, реже в inline-скрипте как sitekey: или siteKey =. Поэтому проверяем все три варианта сразу. Заголовки браузера обязательны: без внятного User-Agent Cloudflare вернёт 403 раньше, чем вы увидите форму.

import re
import requests

def extract_turnstile_sitekey(url):
    """Extract Cloudflare Turnstile sitekey from page HTML."""
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 Chrome/120.0.0.0",
        "Accept": "text/html,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = requests.get(url, headers=headers, timeout=15)

    patterns = [
        r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']',
        r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
        r"siteKey\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    for pattern in patterns:
        match = re.search(pattern, response.text)
        if match:
            return match.group(1)

    return None


sitekey = extract_turnstile_sitekey("https://example.com/signup")
print(f"Sitekey: {sitekey}")

Если вернулся None, sitekey достаточно один раз посмотреть в DevTools и зашить в конфиг: он статичен.

Почему регулярное выражение не находит ключ:

  • виджет монтируется скриптом после DOMContentLoaded;
  • ключ отдан через data-*-атрибут с нестандартным именем;
  • вместо формы вернулся промежуточный ответ Cloudflare — проверьте response.status_code.

Шаг 2: отправьте задачу в API CaptchaAI

Задача уходит POST-запросом на in.php с method=turnstile. В ответ приходит ID задачи — сохраните его, опрос идёт по нему.

import requests

API_KEY = "YOUR_API_KEY"

def submit_turnstile(sitekey, page_url):
    """Submit Turnstile solving task to CaptchaAI."""
    response = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = response.json()

    if data.get("status") != 1:
        raise Exception(f"Submit failed: {data.get('request')}")

    return data["request"]


task_id = submit_turnstile("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")
print(f"Task ID: {task_id}")

Скелет запроса общий для всех поддерживаемых типов: меняются только method и набор параметров. Тот же код работает для reCAPTCHA v2 и GeeTest v3.

Что именно уходит в in.php:

Параметр Значение Обязателен
key ваш API-ключ да
method turnstile да
sitekey ключ сайта из разметки да
pageurl полный URL страницы с виджетом да
action значение атрибута data-action, если он есть нет
json 1 — ответ в JSON, а не строкой нет

pageurl должен совпадать с адресом, где реально отрисован виджет, вплоть до схемы и поддомена, — расхождение здесь частая причина непринятого токена.


Шаг 3: опрашивайте res.php до готовности токена

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

import time

def poll_result(task_id, timeout=120):
    """Poll CaptchaAI for the solved Turnstile token."""
    start = time.time()

    while time.time() - start < timeout:
        time.sleep(5)

        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

        if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
            raise Exception("Turnstile could not be solved")

    raise TimeoutError("Solve timed out")


token = poll_result(task_id)
print(f"Token: {token[:50]}...")

ERROR_CAPTCHA_UNSOLVABLE — терминальная ошибка: повторный опрос не поможет, нужна новая отправка. По внутренним измерениям Turnstile решается менее чем за 10 с; тайм-аут в 120 секунд — запас на пиковую очередь.

Логика цикла опроса сводится к трём правилам:

  1. первый запрос — не раньше пяти секунд после отправки;
  2. интервал держите постоянным, 5 секунд достаточно;
  3. на терминальных ошибках выходите сразу, не дожидаясь тайм-аута.

Полный рабочий сценарий

Три шага в одном скрипте с общей сессией. Сессия важна: Cloudflare выставляет cookie при первом GET, и форму нужно отправлять из того же контекста.

import re
import time
import requests

API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://example.com/signup"


def solve_turnstile(sitekey, page_url):
    """Full Turnstile solve: submit + poll."""
    # Submit
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]
    print(f"Task submitted: {task_id}")

    # Poll
    for _ in range(30):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")


# --- Main flow ---
session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 Chrome/120.0.0.0",
    "Accept": "text/html,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
})

# 1. Get page and extract sitekey
response = session.get(TARGET_URL, timeout=15)
match = re.search(r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text)
if not match:
    raise ValueError("Turnstile sitekey not found")
sitekey = match.group(1)
print(f"Sitekey: {sitekey}")

# 2. Solve Turnstile
token = solve_turnstile(sitekey, TARGET_URL)
print(f"Token: {token[:50]}...")

# 3. Submit form with token
form_response = session.post(TARGET_URL, data={
    "cf-turnstile-response": token,
    "email": "[email protected]",
    "password": "SecurePass123",
})
print(f"Form status: {form_response.status_code}")

Turnstile с параметром action

Некоторые сайты сверяют атрибут data-action при валидации на бэкенде. Если он есть в разметке, а вы его не передали, токен придёт корректный, но форма отправку отклонит.

def solve_turnstile_with_action(sitekey, page_url, action):
    """Solve Turnstile that requires an action parameter."""
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "action": action,  # Include the action from data-action attribute
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]

    for _ in range(30):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")

Копируйте значение data-action один в один: регистр здесь имеет значение.


Как передать токен в форму: три рабочих шаблона

Шаблон 1: обычная HTML-форма

Сервер ждёт поле cf-turnstile-response в теле POST-запроса.

# Most common — Turnstile uses cf-turnstile-response field
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "email": "[email protected]",
})

Шаблон 2: JSON-эндпоинт

SPA чаще шлют токен в JSON-теле под собственным именем ключа — посмотрите во вкладке Network, что уходит из браузера.

response = session.post(api_url, json={
    "turnstileToken": token,
    "email": "[email protected]",
})

Шаблон 3: собственное имя поля

Иногда бэкенд читает токен из дополнительного поля — продублируйте значение в оба ключа.

# Some sites rename the field — check the form HTML
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "captcha_token": token,  # Custom duplicate field
    "action": "signup",
})

Готовый к продакшену класс с повторными попытками

Воркеру, работающему сутками, нужны повторы, разделение временных и терминальных ошибок и общий тайм-аут HTTP. Ключевое: ERROR_ZERO_BALANCE и ERROR_WRONG_USER_KEY повторять нельзя — это проблемы аккаунта, ретраи займут потоки впустую.

import re
import time
import requests

class TurnstileSolver:
    """Production-ready Turnstile solver with retry logic."""

    API_URL = "https://ocr.captchaai.com"

    def __init__(self, api_key, max_retries=3):
        self.api_key = api_key
        self.max_retries = max_retries

    def extract_sitekey(self, session, url):
        """Extract Turnstile sitekey from page."""
        response = session.get(url, timeout=15)
        match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text
        )
        return match.group(1) if match else None

    def solve(self, sitekey, page_url, action=None):
        """Solve Turnstile with retry logic. Returns token string."""
        for attempt in range(1, self.max_retries + 1):
            try:
                token = self._solve_once(sitekey, page_url, action)
                return token
            except TimeoutError:
                print(f"Attempt {attempt} timed out")
            except Exception as e:
                error_str = str(e)
                if "ERROR_ZERO_BALANCE" in error_str:
                    raise  # Don't retry billing errors
                if "ERROR_WRONG_USER_KEY" in error_str:
                    raise
                print(f"Attempt {attempt} failed: {e}")

        raise Exception(f"Failed after {self.max_retries} attempts")

    def _solve_once(self, sitekey, page_url, action=None):
        """Single solve attempt."""
        params = {
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }
        if action:
            params["action"] = action

        submit = requests.post(f"{self.API_URL}/in.php", data=params, timeout=30)
        submit.raise_for_status()
        data = submit.json()

        if data.get("status") != 1:
            raise Exception(f"Submit error: {data.get('request')}")

        task_id = data["request"]

        for _ in range(30):
            time.sleep(5)
            result = requests.get(f"{self.API_URL}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1,
            }, timeout=30).json()

            if result.get("status") == 1:
                return result["request"]
            if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
                raise Exception("CAPTCHA unsolvable")

        raise TimeoutError("Poll timed out")


# Usage
solver = TurnstileSolver("YOUR_API_KEY")
token = solver.solve("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")

Держите число одновременных вызовов solve() не выше числа потоков в тарифе — лишние встанут в ожидание.


Локальный сценарий: ночные тесты формы регистрации

Типичная ситуация: сервис размещён у европейского или казахстанского провайдера, а на форме регистрации в staging стоит Turnstile в managed-режиме. Ночной прогон интеграционных тестов падает не из-за кода, а из-за виджета.

Схема без браузера: тест берёт sitekey из staging-разметки, вызывает TurnstileSolver.solve(), подставляет строку в cf-turnstile-response и продолжает обычную проверку ответа API. Пяти потоков BASIC на прогон хватает. Если тесты работают с пользовательскими данными, ограничьте поля тем, что вы вправе обрабатывать: для аудитории в РФ это требование 152-ФЗ «О персональных данных».

Что зафиксировать в таком прогоне:

  • sitekey стенда — в конфиге, а не в коде теста;
  • API-ключ — в переменной окружения, рядом с секретами CI;
  • отдельное сообщение об ошибке, если токен не пришёл: иначе падение выглядит как баг приложения.

При нестабильном канале увеличивайте тайм-аут HTTP-запроса, а не частоту опроса: частый опрос res.php решение не ускоряет.


Поиск неисправностей

Симптом Причина Что делать
Токен получен, форма отклоняет отправку Неверный sitekey или не передан action Извлеките sitekey заново, добавьте action
Sitekey не найден в HTML Виджет отрисован через JavaScript Возьмите ключ в DevTools или используйте браузерную автоматизацию
HTTP 403 ещё до получения страницы Запрос без заголовков браузера Задайте User-Agent, Accept, Accept-Language
Решение занимает больше 60 с Загруженность очереди Нормально в часы пик — увеличьте тайм-аут
Токен срабатывает один раз, потом перестаёт Сайт требует свежий токен на каждую попытку Решайте новую задачу перед каждой отправкой формы
ERROR_ZERO_BALANCE Исчерпан оплаченный период Пополните баланс; повторные попытки здесь бесполезны

Часто задаваемые вопросы

Нужен ли браузер, чтобы решить Turnstile?

Нет. Turnstile проверяется по строковому токену, поэтому чистого requests достаточно. Браузер нужен только если sitekey появляется в DOM после исполнения JavaScript.

Сколько потоков заказывать под парсер?

По пиковой параллельности, а не по объёму за сутки. Одиночному скрипту хватит BASIC ($15/мес, 5 потоков), пулу на 40–50 задач нужен ADVANCE ($90/мес, 50 потоков).

Влияет ли режим виджета на код запроса?

Нет. Managed, non-interactive и invisible уходят одним и тем же вызовом с method=turnstile — различия обрабатываются на стороне сервиса.

Можно ли переиспользовать токен для нескольких отправок?

Нет. Токен одноразовый и привязан к конкретной попытке. Решайте новую задачу перед каждой отправкой, иначе на второй итерации получите отказ.

Что делать, если на странице стоят и Turnstile, и reCAPTCHA?

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


Коротко

Turnstile на Python requests — это три вызова: найти sitekey в HTML, отправить задачу в CaptchaAI с method=turnstile, опрашивать res.php до токена. Дальше токен уходит в cf-turnstile-response из той же сессии. Браузер не нужен ни на одном шаге.

Похожие статьи

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