Tutoriales

Cómo resolver Cloudflare Turnstile con Python y CaptchaAI

No hace falta abrir un navegador para resolver Cloudflare Turnstile: bastan tres llamadas HTTP con la librería requests. Extraes el sitekey de la página, envías una tarea a la API de CaptchaAI y consultas el resultado hasta recibir un token; ese token viaja en tu formulario dentro del campo cf-turnstile-response. Todo el proceso cabe en unas cincuenta líneas de Python y suele completarse en menos de 10 s con una alta tasa de éxito.

Eso sorprende a quien llega desde Selenium: el widget solo existe para producir un token, y ese token se pide por API. Menos dependencias y un script que corre en un contenedor mínimo sin instalar Chrome.


Qué necesitas antes de empezar

pip install requests

Con eso instalado necesitas tres datos: tu clave API de CaptchaAI, que copias desde tu panel de control; la URL exacta de la página protegida por Turnstile; y el sitekey del widget, que extraerás en el paso 1 sin salir de Python.

CaptchaAI factura por threads concurrentes, no por resolución: el plan BASIC ($15/mes, 5 threads) incluye resoluciones ilimitadas dentro de esos cinco hilos, de sobra para un script secuencial.


Paso 1: extrae el sitekey de Turnstile

El sitekey es una cadena pública que suele empezar por 0x y vive en el atributo data-sitekey del widget. Descarga el HTML con cabeceras de navegador realistas y búscalo con varias expresiones regulares: unos sitios lo inyectan como atributo y otros lo pasan en 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}")

Si devuelve None, el widget se monta por JavaScript: lee el sitekey una vez con un navegador headless y guárdalo, porque rara vez cambia.


Paso 2: envía la tarea a CaptchaAI

El envío es un POST al endpoint in.php con method=turnstile, el sitekey y la URL de la página. Con json=1, la respuesta trae status: 1 y, en request, el identificador de la tarea. Lee siempre ese status antes de seguir, porque no todos los fallos se tratan igual.

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}")
  • ERROR_ZERO_BALANCE: saldo agotado, reintentar solo quema tiempo.
  • ERROR_WRONG_USER_KEY: la clave API no es válida; revísala en tu panel.
  • Cualquier otro status distinto de 1: fallo de resolución, ahí sí reintenta.

Paso 3: consulta el resultado hasta obtener el token

La resolución es asíncrona: espera unos segundos y sondea res.php con action=get hasta que status valga 1. Cinco segundos es buen intervalo; sondear más rápido no acelera nada.

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: esa tarea no salió adelante, envíala de nuevo desde cero.
  • TimeoutError: cola cargada o parámetros incorrectos; revisa el sitekey y la pageurl antes de culpar al servicio.

Ejemplo completo de principio a fin

  • El script crea una Session, descarga la página y extrae el sitekey.
  • Resuelve el desafío contra la API y recoge el token.
  • Envía el formulario con el token en cf-turnstile-response desde esa misma sesión: las cookies emitidas al cargar la página deben acompañar al envío.
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 con parámetro action

  • Algunos sitios validan el parámetro action del lado del servidor.
  • Su valor sale del atributo data-action del widget.
  • Si el backend lo comprueba, un token resuelto sin action acaba rechazado aunque sea válido.
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")

Cómo enviar el token en el formulario

Tener el token es media tarea; la otra media es entregarlo donde el backend lo espera. Tres patrones cubren casi todo.

Patrón 1: formulario POST con cf-turnstile-response

Es el nombre de campo que genera el propio widget y el que verás en casi todas las integraciones.

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

Patrón 2: API JSON

Las aplicaciones de página única mandan el token en el cuerpo JSON, con un nombre de propiedad que define el frontend.

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

Patrón 3: nombre de campo personalizado

Si el formulario renombra el campo, duplica el valor en ambos nombres mientras confirmas cuál lee el servidor.

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

Clase reutilizable para producción

  • Reintentos acotados con un máximo explícito, nunca un bucle infinito.
  • Tiempo de espera propio en cada llamada HTTP.
  • Un criterio claro sobre qué errores no se reintentan nunca.
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")

Instancia el solver una vez y compártelo entre tus workers. Si necesitas más paralelismo, sube de plan en lugar de acortar el sondeo: cada thread es una resolución más en vuelo.


Caso práctico: QA nocturno de altas en una plataforma regional

Una agencia de Ciudad de México revisa cada noche que el alta de registro de sus clientes siga funcionando tras el despliegue. El formulario de staging.example.com/qa-signup lleva Turnstile y la prueba se quedaba parada delante del widget; con este flujo, el job nocturno resuelve por API, envía el alta y valida la respuesta del backend. El precio fijo en USD por threads mantiene el costo predecible, algo que agradecen los equipos que facturan en monedas volátiles. Automatiza solo flujos propios o autorizados y respeta la normativa de protección de datos aplicable.


Diagnóstico de errores frecuentes

Síntoma Causa probable Solución
Token recibido pero formulario rechazado Sitekey incorrecto o action ausente Extrae otra vez el sitekey e incluye action
"Sitekey no encontrado" El widget se carga por JavaScript Léelo con un navegador headless y guárdalo en caché
HTTP 403 al descargar la página Cabeceras de navegador incompletas Añade User-Agent, Accept y Accept-Language coherentes
La resolución tarda más de 60 segundos Cola congestionada en hora punta Amplía el tiempo de espera; no acortes el sondeo
El token funciona una vez y luego falla El sitio exige un token nuevo por intento Resuelve un token para cada envío

Preguntas frecuentes

¿Necesito Selenium o un navegador headless para resolver Turnstile?

No. El flujo usa solo requests: la API devuelve el token y tú lo envías en el formulario. El navegador solo hace falta si el sitekey se inyecta por JavaScript y hay que leerlo una primera vez.

¿Cuánto tarda una resolución de Turnstile?

Cloudflare Turnstile se resuelve normalmente en menos de 10 s. En horas punta puede acercarse a ese techo, por eso el ejemplo sondea con un tiempo de espera amplio.

¿Puedo reutilizar el mismo token en varios envíos?

No conviene. Los tokens de Turnstile son de un solo uso y de vida corta: resuelve uno nuevo por envío. Guardarlos para más tarde es la causa más común de rechazos intermitentes.

¿Cuántos threads necesito para mi volumen?

Divide tu objetivo por hora entre lo que cabe en un thread: con Turnstile por debajo de 10 s, cada thread admite unas 360 resoluciones por hora. STANDARD ($30/mes, 15 threads) cubre cargas pequeñas; ADVANCE ($90/mes, 50 threads) da margen a un pipeline serio.


Resumen

  1. Extrae el sitekey del HTML de la página protegida.
  2. Envía la tarea a CaptchaAI con method=turnstile y sondea res.php hasta recibir el token.
  3. Manda ese token en cf-turnstile-response desde la misma sesión que descargó la página.
  4. Copia tu API key y prueba el flujo completo contra tu entorno de staging antes de llevarlo a producción.

Artículos relacionados

Los comentarios están deshabilitados para este artículo.