Un widget Cloudflare Turnstile laisse toujours les mêmes traces sur une page :
- le script
challenges.cloudflare.com/turnstile; - un conteneur portant la classe
cf-turnstile; - un attribut
data-sitekey.
Détecter Turnstile, c'est retrouver ces marqueurs puis en extraire la clé de site (sitekey) et le mode du widget — les deux seules informations dont l'API a besoin. La difficulté : ils n'apparaissent pas toujours dans le HTML initial. Selon l'intégration, la détection va d'une simple lecture de la source à l'analyse du JavaScript exécuté.
Comment les sites intègrent Turnstile
Turnstile s'intègre de trois façons, et chacune appelle une approche différente :
- HTML implicite —
<div class="cf-turnstile" data-sitekey="...">dans la source ; détection facile. - JavaScript explicite —
turnstile.render()appelé dans un script ; il faut analyser le JS. - Chargement dynamique — widget injecté après une action ou un XHR ; exécution du JS obligatoire.
Cas concret : un pipeline de surveillance hébergé sur OVHcloud parcourt des centaines de pages de connexion. Le même code doit reconnaître, page par page, si le formulaire est protégé par Turnstile, par reCAPTCHA ou par rien — d'où l'intérêt d'une détection indépendante de l'intégration. Si vous collectez des données au passage, limitez les données personnelles conservées, conformément à vos obligations RGPD.
Méthode 1 — détecter Turnstile dans le HTML statique
L'intégration la plus simple s'appuie sur la classe cf-turnstile et l'attribut data-sitekey ; une requête HTTP et une regex suffisent :
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://example.com/login")
if info["turnstile_found"]:
print(f"Sitekey: {info['sitekey']}")
print(f"Mode: {info['mode']}")
Deux garde-fous :
- exigez le script
challenges.cloudflare.com/turnstile(le conteneur seul ne suffit pas) ; - écartez tout
data-sitekeyvide laissé par un gabarit.
Méthode 2 — repérer l'appel turnstile.render() en JavaScript
Certains sites ne posent aucun attribut HTML et construisent le widget avec turnstile.render(). La clé de site vit alors dans l'objet de configuration passé à la fonction :
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éthode 3 — capturer un widget chargé dynamiquement (Selenium/Puppeteer)
Quand Turnstile s'injecte après une interaction ou un appel réseau, le HTML statique ne montre rien. Laissez un navigateur exécuter le JavaScript, puis inspectez le DOM rendu.
En Python avec 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()
options.add_argument("--disable-blink-features=AutomationControlled")
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()
En Node.js avec Puppeteer
const puppeteer = require("puppeteer");
async function detectTurnstileDynamic(url) {
const browser = await puppeteer.launch({
headless: "new",
args: ["--disable-blink-features=AutomationControlled"],
});
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://example.com/login").then(console.log);
Côté fiabilité, deux réflexes paient :
- attendez le rendu complet de la page avant de lire le DOM ;
- gardez l'
iframechallenges.cloudflare.comcomme signal de secours.
Une classe de détection qui couvre les trois cas
En production, vous ignorez l'intégration de chaque page. Regroupez les trois méthodes dans une classe : une URL en entrée, la clé, le mode et le type en sortie.
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://example.com/login")
info = detector.detect()
if info["turnstile_present"]:
print(f"Sitekey: {info['sitekey']}")
print(f"Mode: {info['mode']}")
print(f"Implementation: {info['implementation']}")
Résoudre le défi une fois la clé de site extraite
La clé de site et l'URL en main, la résolution tient en un aller-retour avec l'API CaptchaAI : envoyez la tâche à in.php, puis interrogez res.php jusqu'au token.
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]}...")
Deux points à ne pas négliger :
- le token Turnstile est à usage unique et expire vite : soumettez-le sans tarder ;
- transmettez
actionsidata-actionfigurait sur le widget, sinon la validation peut échouer.
Dépannage
Le symptôme pointe presque toujours vers l'une de ces causes :
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Balise de script présente mais aucune clé de site | Rendu via l'API JS avec une configuration venue d'ailleurs | Vérifiez tous les fichiers JS liés et les réponses XHR |
| Mauvaise clé de site extraite | Plusieurs widgets CAPTCHA sur la page | Rapprochez chaque clé des éléments de formulaire voisins |
| Détection correcte mais résolution en échec | Le paramètre action est exigé à la validation | Ajoutez la valeur data-action à la requête de résolution |
| Widget absent du HTML initial | Chargement dynamique après une action de l'utilisateur | Passez par Selenium/Puppeteer pour un rendu complet |
Champ cf-turnstile-response vide |
Le widget n'a pas terminé son cycle | Attendez la fin du chargement du widget |
Cas limites à anticiper
Au-delà des trois intégrations standard, quelques configurations demandent un traitement particulier :
- Sitekey dans un fichier JS externe — absente du HTML : analysez les fichiers JavaScript liés.
- Sitekey renvoyée par une API — chargée après un XHR : cherchez la clé dans les réponses JSON.
- Plusieurs widgets Turnstile — des clés différentes : associez chaque clé au bon formulaire.
- Turnstile dans un shadow DOM — hors des sélecteurs classiques : passez par
shadowRoot.querySelector. - Sitekey rendue côté serveur — injectée via un gabarit : inspectez les balises
<script>de configuration. - Turnstile derrière une authentification — invisible en public : authentifiez-vous d'abord, puis détectez.
Questions fréquentes
Les questions qui reviennent le plus souvent :
Comment distinguer Turnstile de reCAPTCHA sur une page ?
Regardez le domaine des ressources et le nom du champ caché. Turnstile charge challenges.cloudflare.com/turnstile et renseigne cf-turnstile-response ; reCAPTCHA charge google.com/recaptcha et remplit g-recaptcha-response.
Peut-on coder la clé de site en dur une fois récupérée ?
À éviter : l'opérateur peut faire tourner sa clé à tout moment, et une valeur figée finira par ne plus correspondre. Extrayez-la à chaque exécution.
Quand Selenium ou Puppeteer deviennent-ils indispensables ?
Dès que le widget n'est pas dans le HTML initial : chargement après une action, injection via XHR ou clé générée côté client. Un navigateur headless exécute le JavaScript avant la lecture du DOM.
Comment gérer plusieurs widgets Turnstile sur une même page ?
Ne prenez pas la première clé venue : repérez le formulaire soumis et prenez la clé du conteneur cf-turnstile correspondant. Une mauvaise clé produit un token refusé à la validation.
L'essentiel
Pour détecter Cloudflare Turnstile, cherchez sa balise de script, le conteneur cf-turnstile, l'attribut data-sitekey et les appels turnstile.render(). L'analyse du HTML statique suffit aux intégrations simples ; les widgets dynamiques imposent Selenium ou Puppeteer. Une fois la clé extraite, confiez la résolution au solveur Turnstile de CaptchaAI : quel que soit le mode, le traitement côté API reste identique.