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 секунд — запас на пиковую очередь.
Логика цикла опроса сводится к трём правилам:
- первый запрос — не раньше пяти секунд после отправки;
- интервал держите постоянным, 5 секунд достаточно;
- на терминальных ошибках выходите сразу, не дожидаясь тайм-аута.
Полный рабочий сценарий
Три шага в одном скрипте с общей сессией. Сессия важна: 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 из той же сессии. Браузер не нужен ни на одном шаге.