Cloudflare Turnstile 单击式验证 SOP — 浏览器自动化通用方案

下载 .md

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.dispatchMouseEventscreenX/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 结论

  1. 过盾路径成立:浏览器自然态在加载页面后约 3s 拿到 730B token,按钮 enable。
  2. 失败诊断:若按"managed widget + 物理点击"硬走 invisible 模式,根本没有可点击的 checkbox,再调 screenX/screenY 也点不到不存在的元素。这正是方法二存在的理由。
  3. 正确解法document_start hook turnstile.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]}")

参考

评论(0)

登录 后可发表评论。

暂无评论。