Tutorials

Como resolver Cloudflare Turnstile com Python requests e CaptchaAI

Resposta curta: você não precisa de navegador. O Turnstile pode ser resolvido em três chamadas HTTP — ler a sitekey na página, enviar a tarefa para a API da CaptchaAI com method=turnstile e consultar o resultado até receber o token. Depois é só mandar esse token no campo cf-turnstile-response junto com o resto do formulário.

Isso muda o custo da automação: um time de QA que testa cadastro em staging.example.com a partir de um worker em São Paulo (AWS sa-east-1) não precisa subir Chrome headless só para atravessar um widget. Se o navegador for inevitável, o caminho é outro — veja o guia de Playwright com Python e CaptchaAI.


O que você precisa antes de começar

pip install requests

Reúna três coisas:

  • Uma chave de API da CaptchaAI, criada em captchaai.com — o plano BASIC (US$ 15/mês, 5 threads) já sustenta um pipeline de QA, porque a cobrança é por thread concorrente e não por resolução.
  • A URL exata da página que exibe o widget.
  • A sitekey (chave pública do widget) do Turnstile.

A sitekey costuma estar no HTML servido, no atributo data-sitekey — um valor público por definição.


Etapa 1: extrair a sitekey do Turnstile na página

Abra a página com cabeçalhos coerentes e procure a sitekey por expressão regular. Três padrões cobrem a maioria das integrações: data-sitekey e as variantes sitekey: / siteKey = de quem inicializa o widget via JavaScript.

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}")

Se a função devolver None, a página provavelmente injeta o widget depois do carregamento — pule para a seção de solução de problemas.


Etapa 2: enviar a tarefa para a API da CaptchaAI

Envie a tarefa para in.php com quatro parâmetros: sua chave, method=turnstile, a sitekey e a pageurl. O json=1 faz a API responder em JSON.

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}")

A resposta de sucesso traz status: 1 e, em request, o ID da tarefa. Qualquer outro valor é um código de erro que vale tratar já aqui: ERROR_ZERO_BALANCE e ERROR_WRONG_USER_KEY não melhoram com retentativa.


Etapa 3: consultar o resultado até receber o token

A resolução é assíncrona: consulte res.php até o status virar 1. Cinco segundos entre consultas é um bom intervalo para o Turnstile; mais curto só gasta requisição à toa.

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]}...")

O token retornado tem validade curta e uso único. Trate-o como credencial efêmera: gere, use no envio imediatamente e descarte.


Fluxo completo, do HTML ao envio do formulário

As três etapas em um script só, com uma Session que mantém os cookies entre a leitura da página e o POST final:

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}")

Repare no ponto crítico do final: o token vai no campo cf-turnstile-response. Esse nome é específico do Turnstile: outros tipos de CAPTCHA usam campos próprios, e trocar um pelo outro é a causa mais comum de "token aceito pela API, formulário rejeitado pelo site".


Quando o widget exige o parâmetro action

Parte das integrações valida no servidor o parâmetro action declarado em data-action. Se ele existe e você não o repassa, o token volta válido, mas é recusado no envio.

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")

Confira o HTML do widget antes de assumir que não há action.


Três padrões de envio do token

A forma como o site consome o token varia — identifique o padrão antes de escrever o POST.

Padrão 1: formulário tradicional com cf-turnstile-response

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

Padrão 2: API JSON com nome de campo próprio

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

Padrão 3: campo renomeado ou duplicado

# 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",
})

Na dúvida, abra o DevTools, envie o formulário uma vez à mão e copie a estrutura exata do payload.


Classe pronta para produção, com retentativa

Em produção você quer retentativa controlada, timeouts explícitos e a decisão de não repetir erros de cobrança ou de chave:

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")

Com as 5 threads do BASIC (US$ 15/mês) você mantém cinco instâncias resolvendo em paralelo; subir para STANDARD (US$ 30/mês, 15 threads) é o que aumenta a vazão, já que as resoluções por thread são ilimitadas.


Solução de problemas

Sintoma Causa provável Como corrigir
Token recebido, mas o formulário rejeita Sitekey errada ou action ausente Extraia a sitekey novamente e inclua o action, se houver
"Sitekey não encontrada" Widget montado via JavaScript Use Selenium ou Playwright para renderizar a página
HTTP 403 antes de ler a página Requisição sem cabeçalhos de navegador Envie User-Agent, Accept e Accept-Language coerentes
A resolução passa de 60 s Congestionamento de fila Normal em horário de pico; aumente o timeout do polling
O token funciona uma vez e depois falha O site exige token novo a cada tentativa Resolva um token por envio, sem reaproveitar

Quando a API resolve mas o navegador continua falhando, o problema costuma estar no envio do formulário, não na resolução: compare o payload que o navegador manda com o que o seu script monta.


Onde esse fluxo se aplica

Mantenha o escopo em ambiente próprio: testes de integração em staging, monitoramento autorizado dos endpoints que você opera, validação de formulários antes de um deploy. Se o pipeline tocar dados pessoais reais, considere as obrigações da LGPD (ou do RGPD, em Portugal) sobre o que fica em log e por quanto tempo. Use dados fictícios e URLs de QA como staging.example.com — nunca sites de terceiros como alvo de automação.


Perguntas frequentes

Preciso de navegador para resolver o Turnstile?

Não. Todo o fluxo roda em Python puro com requests. Navegador só é necessário quando a página monta o widget via JavaScript e a sitekey não aparece no HTML servido.

Posso reaproveitar o mesmo token em vários envios?

Não. O token é de uso único e expira rápido. Resolva um por envio: reaproveitar é a causa clássica da falha intermitente que "funciona no primeiro teste".

Quantas threads eu preciso para rodar isso em paralelo?

Cada resolução em andamento ocupa uma thread. BASIC (US$ 15/mês) traz 5, STANDARD (US$ 30/mês) traz 15 e ADVANCE (US$ 90/mês) traz 50, todos com resoluções ilimitadas por thread. Dimensione pelo pico de concorrência, não pelo total mensal.

O modo do widget muda a chamada da API?

Não. Os modos gerenciado, não interativo e invisível usam a mesma chamada com method=turnstile. A diferença é tratada internamente pela CaptchaAI.

E se a página tiver Turnstile e reCAPTCHA ao mesmo tempo?

Identifique qual dos dois protege o formulário e resolva apenas esse. Cada tipo usa um campo de resposta próprio — no Turnstile é sempre cf-turnstile-response —, e o token errado gera rejeição silenciosa. A distinção em relação ao Cloudflare Challenge está em como identificar cada um.


Resumo

São três movimentos: ler a sitekey no HTML, enviar a tarefa à CaptchaAI com method=turnstile e consultar res.php até o token chegar. Envie esse token como cf-turnstile-response dentro da mesma Session que carregou a página. O Turnstile é liberado em menos de 10 s, com alta taxa de sucesso nos tipos suportados, e a cobrança por thread mantém o custo previsível conforme o volume cresce.

Artigos relacionados

Os comentários estão desativados para este artigo.