Cloudflare Turnstile 单击式验证 SOP — 浏览器自动化通用方案
适用:自动化访问受 Cloudflare Turnstile 保护的页面(注册 / 登录 / 接口预热)。 核心理念:先识别 widget 类型 → 选最低成本路径 → 物理点击 / 回调劫持 / 云打码 三档降级,不要硬刚 challenge。
0. 识别 Widget 类型(必做前置)
| 类型 | 视觉 | 是否需点击 | 检测特征 |
|---|---|---|---|
| managed | 白底 checkbox | 多数情况需要 | <div class="cf-turnstile"> 渲染出 iframe + 内嵌 input |
| non-interactive | 白底 spinner | 否,自动通过 | data-action="non-interactive" 或 challenge 静默完成 |
| invisible | 完全无视觉元素 | 否,但 token 触发条件需手动 execute() |
仅有 <script src=".../turnstile/v0/api.js">,JS 内 turnstile.render({size:'invisible'}) |
| managed-action | 提交时弹 challenge | 触发后才出现 | submit 被点击后 turnstile 才 render |
速查脚本(贴 DevTools / page.run_js 即可判断):
(() => {
const widgets = document.querySelectorAll('.cf-turnstile,[data-sitekey]');
const iframes = [...document.querySelectorAll('iframe[src*="challenges.cloudflare"]')];
const apiLoaded = !!window.turnstile;
const sitekeys = [...widgets].map(w => w.getAttribute('data-sitekey'))
.concat([...document.documentElement.outerHTML.matchAll(/0x4[A-Z][A-Za-z0-9_-]{20,40}/g)].map(m=>m[0]));
return {widgets: widgets.length, iframes: iframes.length, apiLoaded,
sitekeys: [...new Set(sitekeys)],
turnstileFns: window.turnstile ? Object.keys(window.turnstile) : null};
})();
判定:widgets ≥ 1 → 方法一;widgets = 0 && apiLoaded → 方法二(invisible hook);apiLoaded = false → 还没渲染或被替换,等渲染或触发 submit。
1. 根因:为什么自动化点击会失败
Chrome CDP 协议 bug(chromium #40280325):
Input.dispatchMouseEvent 把 screenX/screenY 设成与 clientX/clientY 相同。真实鼠标事件中 screen 坐标因窗口位置和任务栏存在偏移,Cloudflare Turnstile 通过这个差值判定自动化。
修复办法:通过浏览器扩展 Object.defineProperty(MouseEvent.prototype, 'screenX', …) 覆盖到一个随机偏移值。详见方法一中 turnstilePatch。
2. 方法一:DrissionPage + turnstilePatch(managed / non-interactive 主力)
2.1 环境
pip install DrissionPage
# 无头服务器:必装 Xvfb(绝不用 co.headless())
sudo apt-get install -y xvfb
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp -ac &
export DISPLAY=:99
2.2 turnstilePatch 扩展(Manifest V3)
目录结构:turnstilePatch/{manifest.json, script.js}
manifest.json:
{
"manifest_version": 3,
"name": "Turnstile Patcher",
"version": "2.1",
"content_scripts": [{
"js": ["./script.js"],
"matches": ["<all_urls>"],
"run_at": "document_start",
"all_frames": true,
"world": "MAIN"
}]
}
script.js:
function r(min,max){return Math.floor(Math.random()*(max-min+1))+min;}
const sx = r(800,1200), sy = r(400,600);
Object.defineProperty(MouseEvent.prototype, 'screenX', {value: sx});
Object.defineProperty(MouseEvent.prototype, 'screenY', {value: sy});
⚠️ "world": "MAIN" 必填——必须运行在页面 JS 上下文,而非扩展隔离世界。
2.3 浏览器配置
from DrissionPage import Chromium, ChromiumOptions
co = ChromiumOptions()
co.auto_port()
co.set_timeouts(base=3)
co.add_extension("/path/to/turnstilePatch")
co.set_argument("--no-sandbox")
co.set_argument("--disable-dev-shm-usage")
co.set_argument("--window-size=1920,1080")
# 绝不用 co.headless() — Shadow DOM 会失效,用 Xvfb 替代
browser = Chromium(co)
page = browser.get_tabs()[-1]
2.4 solve_turnstile() 完整版
import time
def solve_turnstile(page, max_attempts=15, sleep=1.0):
# managed / non-interactive widget 解题;返回 token 或 None
try: page.run_js("try{turnstile.reset()}catch(e){}")
except: pass
for _ in range(max_attempts):
# 1) 直接尝试 getResponse
token = page.run_js("try{return turnstile.getResponse()}catch(e){return null}")
if token and len(str(token)) > 20: return token
# 2) Shadow DOM 遍历点击
try:
wrapper = page.ele("@name=cf-turnstile-response").parent()
iframe = wrapper.shadow_root.ele("tag:iframe")
iframe.run_js("if(!window.dtp){window.dtp=1;const r=(a,b)=>Math.floor(Math.random()*(b-a+1))+a;Object.defineProperty(MouseEvent.prototype,'screenX',{value:r(800,1200)});Object.defineProperty(MouseEvent.prototype,'screenY',{value:r(400,600)});}")
iframe.ele("tag:body").shadow_root.ele("tag:input").click()
except: pass
# 3) Hidden input 兜底
token = page.run_js("for(const i of document.querySelectorAll('[name=\"cf-turnstile-response\"]'))if(i.value && i.value.length>20) return i.value; return null;")
if token: return token
time.sleep(sleep)
return None
3. 方法二:Invisible Turnstile(页面无 checkbox,hook callback)
页面只引入 <script src=".../turnstile/v0/api.js?onload=...">,widget 由前端框架在挂载时调用 turnstile.render({size:'invisible', sitekey, callback})。关键:不要点击,劫持 callback 拿 token。
HOOK_JS = """
let _ts;
Object.defineProperty(window, 'turnstile', {
configurable: true,
get(){return _ts;},
set(v){
const orig = v.render;
v.render = function(elem, opts){
const cb = opts.callback;
opts.callback = (tok) => { window.__solverToken = tok; cb && cb(tok); };
window.__solverParams = {sitekey: opts.sitekey, action: opts.action};
return orig.call(this, elem, opts);
};
_ts = v;
}
});
"""
# 步骤 A:在任何脚本执行前注入(document_start)
page.run_cdp("Page.addScriptToEvaluateOnNewDocument", source=HOOK_JS)
# 步骤 B:访问页面后等 token
page.get(URL)
import time
deadline = time.time() + 30
token = None
while time.time() < deadline:
token = page.run_js("return window.__solverToken || (window.turnstile&&window.turnstile.getResponse())||null")
if token and len(str(token)) > 100: break
time.sleep(1)
hook 必须在页面任何脚本执行前完成;DrissionPage 用
Page.addScriptToEvaluateOnNewDocument(CDP)或扩展run_at:"document_start"注入。
4. 方法三:YesCaptcha 云端(付费兜底)
import requests, time
def solve_via_yescaptcha(site_url, site_key, client_key,
action=None, cdata=None):
task = {"type": "TurnstileTaskProxyless",
"websiteURL": site_url, "websiteKey": site_key}
if action: task["action"] = action
if cdata: task["cdata"] = cdata
r = requests.post("https://api.yescaptcha.com/createTask",
json={"clientKey": client_key, "task": task}).json()
tid = r["taskId"]
for _ in range(60):
time.sleep(3)
rr = requests.post("https://api.yescaptcha.com/getTaskResult",
json={"clientKey": client_key, "taskId": tid}).json()
if rr.get("status") == "ready": return rr["solution"]["token"]
return None
环境变量:YESCAPTCHA_CLIENT_KEY=...。需要 action/cdata 时同步透传,否则服务端会 400 invalid action。
5. 代理:认证扩展 + sid 轮转
Chrome CLI 不支持带认证的代理,要用扩展注入:
import json, tempfile
def create_proxy_auth_extension(host, port, user, pwd, scheme="http"):
plugin_dir = tempfile.mkdtemp(prefix="proxy_plugin_")
manifest = {
"version": "1.0.0", "manifest_version": 2,
"name": "Proxy Auth",
"permissions": ["proxy","tabs","unlimitedStorage","storage",
"<all_urls>","webRequest","webRequestBlocking"],
"background": {"scripts": ["background.js"]},
}
bg = (
'var c={mode:"fixed_servers",rules:{singleProxy:{scheme:"%s",host:"%s",port:parseInt(%d)}}};\n'
'chrome.proxy.settings.set({value:c, scope:"regular"}, ()=>{});\n'
'chrome.webRequest.onAuthRequired.addListener(\n'
' d => ({authCredentials:{username:"%s",password:"%s"}}),\n'
' {urls:["<all_urls>"]}, ["blocking"]);\n'
) % (scheme, host, port, user, pwd)
open(f"{plugin_dir}/manifest.json","w").write(json.dumps(manifest))
open(f"{plugin_dir}/background.js","w").write(bg)
return plugin_dir
co.add_extension(create_proxy_auth_extension("PROXY_HOST", 3010, "USER", "PASS"))
住宅代理常用 sid-xxx 字段切换出口 IP:
import random, string, re
def rotate_sid(username):
new = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
return re.sub(r"sid-[a-zA-Z0-9]+", f"sid-{new}", username)
# "user-zone-residential-region-us-sid-abc123:pass" → 换 sid 即换 IP
6. 决策树
有可见 widget?
├─ 是 ─ size=invisible?
│ ├─ 是 → 方法二 (hook turnstile.render)
│ └─ 否 → 方法一 (DrissionPage + turnstilePatch)
│ 失败 ↓
│ 方法三 (YesCaptcha 云端)
└─ 否 ─ window.turnstile 已存在? → 方法二
否 → 等渲染 / 触发 submit 后再判定
7. 故障排查
| 症状 | 原因 | 修复 |
|---|---|---|
ElementNotFoundError on shadow root |
用了 co.headless() |
用 Xvfb + headed |
PageDisconnectedError after click |
页面发生导航 | page = browser.get_tabs()[-1] 重新拿 |
| 点击成功但无 token | 数据中心 IP 被风控 | 加住宅代理(§5) |
| Token 被服务端拒绝 | TTL 过期(~300s) | 拿到立即提交 |
turnstilePatch 未生效 |
路径错或缺 world:MAIN |
检查 manifest.json |
| 代理认证弹窗 | Auth 扩展未加载 | manifest v2 + webRequestBlocking |
| invisible 模式拿不到 token | 没 hook turnstile.render |
方法二在 document_start 注入 |
服务端 400 invalid action |
缺 action/cdata |
检查 turnstile.render 参数并透传给 solver |
| 持续被 challenge | sitekey 风控等级被升级 | 切换 IP/UA,必要时换更强反指纹引擎 |
8. 端到端模板(注册场景)
import os, time
from DrissionPage import Chromium, ChromiumOptions
URL = "https://example.com/signup/"
SITEKEY = "0xXXXXXXXXXXXXXXXXXXXXXXX" # 用 §0 速查脚本现场抓
co = ChromiumOptions(); co.auto_port()
co.add_extension(os.path.abspath("./turnstilePatch"))
co.set_argument("--no-sandbox"); co.set_argument("--window-size=1920,1080")
browser = Chromium(co); page = browser.get_tabs()[-1]
# 0) 先注入 invisible hook(不影响 managed 模式)
page.run_cdp("Page.addScriptToEvaluateOnNewDocument", source=HOOK_JS) # 见 §3
page.get(URL); time.sleep(3)
# 1) invisible 路径:等 callback hook 抢到 token
deadline = time.time() + 15; token = None
while time.time() < deadline:
token = page.run_js("return window.__solverToken||null")
if token: break
time.sleep(1)
# 2) managed 路径:DrissionPage 物理点击
if not token:
token = solve_turnstile(page, max_attempts=10)
# 3) 云端兜底
if not token:
token = solve_via_yescaptcha(URL, SITEKEY, os.environ["YESCAPTCHA_CLIENT_KEY"])
assert token, "Turnstile failed"
# 填表 + 提交
page.ele("@name=username").input("user_xxx")
page.ele("@name=email").input("xxx@example.com")
page.ele("@name=password").input("Aa123456!")
page.run_js("document.querySelector('input[name=\"cf-turnstile-response\"]').value=" + repr(token))
page.ele("@type=submit").click()
9. 速查 Cheatsheet
1. 抓 sitekey: grep -oE '0x4[A-Z][A-Za-z0-9_-]{20,40}' _next/*.js
2. Xvfb: Xvfb :99 -screen 0 1920x1080x24 & export DISPLAY=:99
3. token 寿命: ~300s,拿到立即用
4. 选方法: managed → 方法一 / invisible → 方法二 / 持续被刷 → 方法三
5. 反检测要点: Manifest V3 + world:MAIN + 非 headless + 住宅 IP + UA 正常
附录 A:实测验证(Invisible 模式 / 2026-05-02)
目标:某 Next.js SaaS 注册页,Turnstile Invisible / Managed 模式(无可见 checkbox),CSR 阶段调用 turnstile.render(elem, {sitekey, callback, ...}),size 默认 invisible。
A.1 关键侦察结果
| 检测项 | 结果 |
|---|---|
window.turnstile 暴露 |
✅ fns = _private, execute, getResponse, isExpired, ready, remove, render, reset |
.cf-turnstile 容器节点 |
0(invisible 不挂 DOM 占位) |
iframe[src*="challenges.cloudflare"] |
0(容器藏在 closed shadow root) |
input[name="cf-turnstile-response"] |
✅ 长度 730 |
turnstile.getResponse() |
✅ 730B 有效 token |
turnstile.isExpired() |
❌ false(仍在窗口内) |
| Submit 按钮 | ✅ enabled |
A.2 结论
- 过盾路径成立:浏览器自然态在加载页面后约 3s 拿到 730B token,按钮 enable。
- 失败诊断:若按"managed widget + 物理点击"硬走 invisible 模式,根本没有可点击的 checkbox,再调
screenX/screenY也点不到不存在的元素。这正是方法二存在的理由。 - 正确解法:
document_starthookturnstile.render,劫持opts.callback把 token 写到window.__solverToken,30s 内必到手。
A.3 最小可复现脚本
# test_invisible_turnstile.py — 端到端验证 (DrissionPage + Xvfb)
import os, time
from DrissionPage import Chromium, ChromiumOptions
URL = "https://example.com/signup/"
SITEKEY = "0xXXXXXXXXXXXXXXXXXXXXXXX"
co = ChromiumOptions(); co.auto_port()
co.add_extension(os.path.abspath("./turnstilePatch")) # §2.2
co.set_argument("--no-sandbox"); co.set_argument("--window-size=1920,1080")
browser = Chromium(co); page = browser.get_tabs()[-1]
HOOK_JS = """
let _ts;
Object.defineProperty(window,'turnstile',{configurable:true,
get(){return _ts;},
set(v){const o=v.render;v.render=function(el,opts){
const cb=opts.callback;opts.callback=t=>{window.__solverToken=t;cb&&cb(t);};
window.__solverParams={sitekey:opts.sitekey,action:opts.action};
return o.call(this,el,opts);}; _ts=v;}});
"""
page.run_cdp("Page.addScriptToEvaluateOnNewDocument", source=HOOK_JS)
page.get(URL)
deadline = time.time() + 30; token = None
while time.time() < deadline:
token = page.run_js("return window.__solverToken || (window.turnstile&&window.turnstile.getResponse())||null")
if token and len(str(token)) > 100: break
time.sleep(1)
assert token, "Turnstile token not captured"
print(f"[OK] token len={len(token)} prefix={token[:40]}")
参考
- DrissionPage:https://github.com/g1879/DrissionPage
- CDP screenX/Y patch 灵感:https://github.com/TheFalloutOf76/CDP-bug-MouseEvent-.screenX-.screenY-patcher
- YesCaptcha API:https://yescaptcha.com