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
- Cloudflare Challenge frente a Turnstile: cómo diferenciarlos
- Error 403 de Turnstile después de enviar el token
- Cómo extraer el sitekey de Cloudflare Turnstile