Explicaciones Técnicas

Guía de detección de implementación de Cloudflare Turnstile

Para resolver un CAPTCHA de Cloudflare Turnstile con una API necesitas dos datos: el sitekey del widget y la URL de la página. Lo difícil casi nunca es la resolución, sino localizar ese sitekey, porque Turnstile se incrusta de tres formas distintas y cada una deja rastros diferentes. Esta guía cubre la extracción del sitekey en los tres casos: HTML estático, turnstile.render() y widgets que solo aparecen tras ejecutar el navegador.

Las tres formas de incrustar Turnstile en una página

Muchos portales en español — tiendas tipo MercadoLibre, logins con cita previa — protegen sus formularios con Turnstile. Antes de automatizar uno de esos flujos conviene saber cómo está montado el widget, porque el método de detección cambia según la integración:

Método Cómo funciona Dificultad de detección
HTML implícito <div class="cf-turnstile" data-sitekey="..."> en el código fuente Fácil (HTML estático)
JavaScript explícito turnstile.render() dentro de un script Media (analizar el JS)
Carga dinámica El widget se inyecta tras una acción o una XHR Alta (requiere ejecutar JS)

Método 1: leer el sitekey directamente del HTML

La integración más sencilla deja cf-turnstile y data-sitekey en el HTML. Descargas la página y una expresión regular saca el sitekey, el modo, el tema y la acción:

import re
import requests

def detect_turnstile_html(url):
    """Detect Turnstile from static 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)
    html = response.text

    result = {
        "turnstile_found": False,
        "sitekey": None,
        "mode": None,
        "theme": None,
        "action": None,
        "script_loaded": False,
    }

    # Check for Turnstile script
    if "challenges.cloudflare.com/turnstile" in html:
        result["script_loaded"] = True

    # Check for widget container
    if "cf-turnstile" in html:
        result["turnstile_found"] = True

        # Extract sitekey
        sitekey_match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', html
        )
        if sitekey_match:
            result["sitekey"] = sitekey_match.group(1)

        # Extract mode
        if 'data-size="invisible"' in html:
            result["mode"] = "invisible"
        elif 'data-appearance="interaction-only"' in html:
            result["mode"] = "non-interactive"
        else:
            result["mode"] = "managed"

        # Extract theme
        theme_match = re.search(r'data-theme=["\'](\w+)["\']', html)
        if theme_match:
            result["theme"] = theme_match.group(1)

        # Extract action
        action_match = re.search(r'data-action=["\']([^"\']+)["\']', html)
        if action_match:
            result["action"] = action_match.group(1)

    return result


# Usage
info = detect_turnstile_html("https://staging.example.com/qa-login")
if info["turnstile_found"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")

Método 2: extraer el sitekey de turnstile.render()

Otros sitios renderizan el widget con turnstile.render() en lugar de atributos HTML. El sitekey vive en el objeto de configuración, así que hay que analizar el texto del script:

import re

def detect_turnstile_js_api(html):
    """Detect Turnstile from JavaScript render calls."""
    patterns = [
        # turnstile.render('#element', {sitekey: '...'})
        r"turnstile\.render\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*\{([^}]+)\}",
        # turnstile.render(element, {sitekey: '...'})
        r"turnstile\.render\s*\([^,]+,\s*\{([^}]+)\}",
    ]

    for pattern in patterns:
        match = re.search(pattern, html, re.DOTALL)
        if match:
            config_text = match.group(match.lastindex)

            # Extract sitekey from config object
            sitekey_match = re.search(
                r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", config_text
            )
            # Extract callback
            callback_match = re.search(
                r"callback\s*:\s*(\w+|function)", config_text
            )
            # Extract action
            action_match = re.search(
                r"action\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )
            # Extract appearance
            appearance_match = re.search(
                r"appearance\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )

            return {
                "found": True,
                "method": "javascript_api",
                "sitekey": sitekey_match.group(1) if sitekey_match else None,
                "callback": callback_match.group(1) if callback_match else None,
                "action": action_match.group(1) if action_match else None,
                "appearance": appearance_match.group(1) if appearance_match else None,
            }

    return {"found": False, "method": None}

Método 3: detectar Turnstile de carga dinámica (Selenium/Puppeteer)

Cuando el widget solo aparece tras una interacción o una XHR, el HTML inicial no basta. Necesitas un navegador real que renderice la página y luego revisar el DOM en busca de tres señales:

  • el iframe de challenges.cloudflare.com,
  • el contenedor cf-turnstile,
  • el campo oculto cf-turnstile-response.

Python (Selenium)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import re

def detect_turnstile_dynamic(url):
    """Detect dynamically loaded Turnstile using Selenium."""
    options = webdriver.ChromeOptions()
    driver = webdriver.Chrome(options=options)

    try:
        driver.get(url)

        # Wait for page to fully load
        WebDriverWait(driver, 10).until(
            lambda d: d.execute_script("return document.readyState") == "complete"
        )

        result = {
            "turnstile_found": False,
            "sitekey": None,
            "iframe_present": False,
            "response_field": False,
        }

        # Check for Turnstile iframe
        iframes = driver.find_elements(By.CSS_SELECTOR, "iframe[src*='challenges.cloudflare.com']")
        if iframes:
            result["turnstile_found"] = True
            result["iframe_present"] = True

        # Check for cf-turnstile container
        containers = driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile, [data-sitekey]")
        for container in containers:
            sitekey = container.get_attribute("data-sitekey")
            if sitekey:
                result["turnstile_found"] = True
                result["sitekey"] = sitekey

        # Check for hidden response field
        response_fields = driver.find_elements(
            By.CSS_SELECTOR, "[name='cf-turnstile-response'], [name='g-recaptcha-response']"
        )
        if response_fields:
            result["response_field"] = True

        # Check page source for JS API render
        page_source = driver.page_source
        js_match = re.search(
            r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", page_source
        )
        if js_match and not result["sitekey"]:
            result["sitekey"] = js_match.group(1)
            result["turnstile_found"] = True

        return result

    finally:
        driver.quit()

Node.js (Puppeteer)

Lo mismo con Puppeteer, escuchando la red para la URL del script:

const puppeteer = require("puppeteer");

async function detectTurnstileDynamic(url) {
  const browser = await puppeteer.launch({
    headless: "new",
    args: [],
  });

  const page = await browser.newPage();

  const result = {
    turnstileFound: false,
    sitekey: null,
    iframePresent: false,
    responseField: false,
    scriptUrl: null,
  };

  // Monitor network for Turnstile script
  page.on("response", (response) => {
    if (response.url().includes("challenges.cloudflare.com/turnstile")) {
      result.scriptUrl = response.url();
    }
  });

  await page.goto(url, { waitUntil: "networkidle2" });

  // Check for Turnstile container
  const sitekey = await page.evaluate(() => {
    const el = document.querySelector(
      ".cf-turnstile, [data-sitekey]"
    );
    return el ? el.getAttribute("data-sitekey") : null;
  });

  if (sitekey) {
    result.turnstileFound = true;
    result.sitekey = sitekey;
  }

  // Check for Turnstile iframe
  const iframes = await page.$$("iframe[src*='challenges.cloudflare.com']");
  if (iframes.length > 0) {
    result.turnstileFound = true;
    result.iframePresent = true;
  }

  // Check for response field
  const responseField = await page.$(
    "[name='cf-turnstile-response']"
  );
  result.responseField = !!responseField;

  await browser.close();
  return result;
}

detectTurnstileDynamic("https://staging.example.com/qa-login").then(console.log);

Un detector que reúne los tres métodos

Combinar las tres estrategias en una clase resuelve el caso general: le pasas una URL y devuelve el sitekey, el modo y el tipo de implementación:

import re
import requests

class TurnstileDetector:
    """Detect Cloudflare Turnstile across all implementation methods."""

    TURNSTILE_SCRIPT = "challenges.cloudflare.com/turnstile"
    SITEKEY_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_-]+)['\"]",
        r"TURNSTILE_SITE_KEY\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    def __init__(self, url, html=None):
        self.url = url
        self.html = html
        if not self.html:
            self._fetch()

    def _fetch(self):
        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(self.url, headers=headers, timeout=15)
        self.html = response.text

    def detect(self):
        """Run all detection methods and return results."""
        return {
            "url": self.url,
            "turnstile_present": self.has_turnstile(),
            "sitekey": self.extract_sitekey(),
            "mode": self.detect_mode(),
            "implementation": self.detect_implementation(),
            "script_loaded": self.has_script(),
            "response_field": self.has_response_field(),
            "action": self.extract_action(),
            "theme": self.extract_theme(),
        }

    def has_turnstile(self):
        return (
            self.has_script()
            or "cf-turnstile" in self.html
            or self.extract_sitekey() is not None
        )

    def has_script(self):
        return self.TURNSTILE_SCRIPT in self.html

    def has_response_field(self):
        return "cf-turnstile-response" in self.html

    def extract_sitekey(self):
        for pattern in self.SITEKEY_PATTERNS:
            match = re.search(pattern, self.html)
            if match:
                return match.group(1)
        return None

    def detect_mode(self):
        if 'data-size="invisible"' in self.html or "size: 'invisible'" in self.html:
            return "invisible"
        if 'data-appearance="interaction-only"' in self.html:
            return "non-interactive"
        if "cf-turnstile" in self.html:
            return "managed"
        return "unknown"

    def detect_implementation(self):
        if "cf-turnstile" in self.html and re.search(r"data-sitekey=", self.html):
            return "html_implicit"
        if "turnstile.render" in self.html:
            return "javascript_explicit"
        if self.has_script() and not "cf-turnstile" in self.html:
            return "dynamic_loading"
        return "unknown"

    def extract_action(self):
        match = re.search(r'data-action=["\']([^"\']+)["\']', self.html)
        if match:
            return match.group(1)
        match = re.search(r"action\s*:\s*['\"]([^'\"]+)['\"]", self.html)
        return match.group(1) if match else None

    def extract_theme(self):
        match = re.search(r'data-theme=["\'](\w+)["\']', self.html)
        return match.group(1) if match else "auto"


# Usage
detector = TurnstileDetector("https://staging.example.com/qa-login")
info = detector.detect()

if info["turnstile_present"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")
    print(f"Implementation: {info['implementation']}")

Del sitekey al token: resolver con CaptchaAI

Con el sitekey y la URL ya puedes pedir el token a CaptchaAI: envías la tarea a in.php con method igual a turnstile y consultas res.php hasta que esté listo:

import requests
import time

API_KEY = "YOUR_API_KEY"

def solve_detected_turnstile(detection_result):
    """Solve Turnstile using detection results."""
    if not detection_result["turnstile_present"]:
        raise ValueError("No Turnstile detected")

    if not detection_result["sitekey"]:
        raise ValueError("Sitekey not found — may need browser-based extraction")

    params = {
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": detection_result["sitekey"],
        "pageurl": detection_result["url"],
        "json": 1,
    }

    # Include action if present
    if detection_result.get("action"):
        params["action"] = detection_result["action"]

    submit = requests.post("https://ocr.captchaai.com/in.php", data=params)
    task_id = submit.json()["request"]

    for _ in range(60):
        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("Turnstile solve timed out")


# Full workflow
detector = TurnstileDetector("https://example.com/signup")
info = detector.detect()

if info["turnstile_present"]:
    token = solve_detected_turnstile(info)
    print(f"Token: {token[:50]}...")

CaptchaAI resuelve Turnstile normalmente en menos de 10 segundos y con una alta tasa de éxito, sea cual sea el modo del widget.

Casos límite que aparecen en producción

No todos los sitios se reducen a un data-sitekey limpio en el HTML. Estos son los escenarios que más cuestan:

Escenario Desafío Solución
Sitekey en un JS externo No está en el HTML Analizar los JS enlazados
Sitekey en respuesta de API Se carga tras una XHR Buscarlo en el JSON de red
Varios widgets Turnstile Distintos sitekeys Emparejar sitekey y formulario
Turnstile en Shadow DOM Selectores normales no llegan Usar shadowRoot.querySelector
Sitekey del lado del servidor En variables de plantilla Revisar las etiquetas <script>
Turnstile tras autenticación No está en la página pública Autenticar primero, detectar después

Cuando la detección falla: diagnóstico

Si la extracción no devuelve lo que esperas, casi siempre es uno de estos cinco motivos:

Síntoma Causa Solución
Script presente, sin sitekey La config está en otra fuente Revisar JS enlazados y XHR
Sitekey equivocado Varios widgets en la página Emparejarlo con su formulario
Detección va, resolución falla Falta el parámetro de acción Enviar data-action en la solicitud
Widget ausente del HTML inicial Carga dinámica Renderizar con Selenium/Puppeteer
cf-turnstile-response vacío El widget no ha terminado Esperar a que se cargue

Preguntas frecuentes

¿Cómo distingo Turnstile de reCAPTCHA en una página?

Turnstile carga scripts desde challenges.cloudflare.com/turnstile y usa cf-turnstile con un campo oculto cf-turnstile-response. reCAPTCHA carga desde el dominio de Google y usa g-recaptcha-response. Buscar esas cadenas en el HTML basta para diferenciarlos.

¿Puedo detectar el sitekey sin abrir un navegador?

Depende de la integración. Con HTML implícito o turnstile.render() visible, requests y una expresión regular bastan. Solo necesitas Selenium o Puppeteer cuando el widget se inyecta dinámicamente.

¿Cuánto tarda CaptchaAI en resolver un Turnstile ya detectado?

Normalmente menos de 10 segundos, con una alta tasa de éxito. Como CaptchaAI factura por thread concurrente, BASIC ($15/mes, 5 threads) cubre proyectos pequeños y escalas a STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads) según tu volumen.

¿Qué hago si el sitekey no está ni en el HTML ni en el JavaScript?

Suele estar en un archivo JS externo, en una respuesta XHR o generado en tiempo de ejecución. Usa las DevTools (pestaña Red) para localizarlo, o renderiza la página con Selenium/Puppeteer y lee el DOM final.

Resumen

Detectar Cloudflare Turnstile se reduce a buscar cuatro señales: el script de challenges.cloudflare.com/turnstile, el contenedor cf-turnstile, el atributo data-sitekey y las llamadas a turnstile.render(). Para integraciones sencillas basta con analizar el HTML; para widgets dinámicos necesitas Selenium o Puppeteer. Con el sitekey en la mano, resuélvelo con el solver de Turnstile de CaptchaAI: los tres modos se gestionan igual y solo hacen falta el sitekey y la URL.

Artículos relacionados

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