Cloudflare Turnstile 有三种嵌入方式,检测方法也分三种:静态 HTML 里能读到 data-sitekey,就用正则解析;写在 turnstile.render() 里,就得解析 JS;交互或 XHR 之后才注入 DOM,就必须上 Selenium/Puppeteer 渲染。方法选错,提交给 CaptchaAI 的 sitekey 大概率是空的或过期的。
为什么国内团队更常测 Turnstile
越来越多出海注册页把 reCAPTCHA 换成 Turnstile,对国内 QA 是件好事:
- reCAPTCHA 脚本挂在 Google 域名下,国内网络访问不稳定,测试常卡在脚本加载这一步。
- Turnstile 脚本挂在
challenges.cloudflare.com,不依赖 Google 域名,国内加载明显更稳定。
Turnstile 的三种接入方式,先判断用哪种检测
- HTML 隐式——源码里直接有
<div class="cf-turnstile" data-sitekey="...">,静态 HTML 就能拿到,最简单。 - JavaScript 显式——脚本里调用
turnstile.render(),需解析 JS,中等难度。 - 动态加载——用户操作或 XHR 后才注入的小部件,必须执行 JS,难度较高。
方法一:静态 HTML 检测
什么时候能直接匹配
最简单的 Turnstile 集成会直接用 cf-turnstile 类和 data-sitekey 属性,拉一次 HTML 就能看到。
提取代码
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']}")
方法二:JavaScript API 检测
什么时候要解析 JS
有些站点不写 HTML 属性,而是在脚本里调用 turnstile.render(),静态正则匹配不到容器,得从脚本文本里抠配置对象。
检测代码
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}
方法三:动态加载检测(Selenium/Puppeteer)
- Turnstile 在交互或 XHR 后才注入页面时,只能上浏览器自动化。
- Python(Selenium)和 Node.js(Puppeteer)逻辑一致:等页面加载完成,查 iframe、容器和隐藏字段。
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()
options.add_argument("--no-sandbox")
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)
const puppeteer = require("puppeteer");
async function detectTurnstileDynamic(url) {
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox"],
});
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);
四合一:完整检测类 TurnstileDetector
- 合并三种方法,传一个 URL 进去即可。
- 依次检查脚本标签、
cf-turnstile容器、data-sitekey和turnstile.render()配置,一次返回完整结果。
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']}")
检测到 sitekey 后,怎么提交给 CaptchaAI
- 把 sitekey、页面 URL(以及页面上的
data-action)组装成参数,POST 到in.php。 - 轮询
res.php,status == 1即为识别完成的 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]}...")
特殊场景速查
- sitekey 在外部 JS 文件里——拉取关联 JS 文件,同样正则扫一遍。
- sitekey 来自接口返回值——监控请求,从 JSON 响应里找。
- 同一页面多个 Turnstile 组件——sitekey 和对应表单关联,别只取第一个。
- Turnstile 在 shadow DOM 里——浏览器上下文里用
shadowRoot.querySelector。 - sitekey 藏在内联
<script>里——检查所有<script>标签的配置对象。 - 登录之后才出现——先完成认证,再进入检测。
实操经验:先试静态 HTML 解析,命中率很高;确认拿不到
cf-turnstile容器再上 Selenium/Puppeteer,能省不少调试成本。
故障排除对照表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 有脚本标签,无 sitekey | JS API 配置来自别的文件 | 检查所有关联 JS 文件和 XHR 响应 |
| sitekey 提取错误 | 页面有多个验证码组件 | 把 sitekey 与周围表单元素对应 |
| 检测正常但求解失败 | 服务端校验 action 参数 | 请求里带上 data-action 值 |
| 初始 HTML 无该组件 | 交互后才动态加载 | 用 Selenium/Puppeteer 完整渲染 |
cf-turnstile-response 为空 |
widget 未加载完成 | 等待 widget 加载完成 |
常见问题
Turnstile 的检测思路和 reCAPTCHA 一样吗?
不完全一样。reCAPTCHA 用 g-recaptcha-response 字段,Turnstile 用 cf-turnstile 容器和 data-sitekey,脚本域名也不同。
sitekey 会不会随着页面改版而变化?
会。站点可随时轮换 sitekey,别硬编码,每次任务前重新提取。
只用 requests 库能检测到动态加载的 Turnstile 吗?
大多数情况不能。交互或 XHR 后才注入 DOM 的话,静态请求拿到的 HTML 里没有 cf-turnstile 容器,得用 Selenium/Puppeteer 渲染后再检测。
什么情况下必须带上 data-action 参数?
只有服务端校验时才需要。HTML 里出现了 data-action,原样传给解决请求即可。
同一页面出现多个 Turnstile 组件,检测结果会混乱吗?
有风险。要把每个 cf-turnstile 容器和对应表单关联,分别提交各自的 sitekey,不能只取第一个。
检测到的 sitekey 需要缓存吗?
不建议。sitekey 会随页面改版轮换,每次任务前重新提取更稳妥。
小结
- 按顺序检查脚本标签、
cf-turnstile容器、data-sitekey和turnstile.render(),就能定位实现方式;简单集成用静态解析,动态加载才需要浏览器自动化。 - 检测到 sitekey 后,用它和页面 URL 提交给 CaptchaAI 的 Turnstile 求解器,各接入方式的提交流程都一样。