# 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` 即可判断）：

```javascript
(() => {
  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](https://issues.chromium.org/issues/40280325)）：
`Input.dispatchMouseEvent` 把 `screenX/screenY` 设成与 `clientX/clientY` 相同。真实鼠标事件中 `screen` 坐标因窗口位置和任务栏存在偏移，Cloudflare Turnstile 通过这个差值判定自动化。

**修复办法**：通过浏览器扩展 `Object.defineProperty(MouseEvent.prototype, 'screenX', …)` 覆盖到一个随机偏移值。详见方法一中 `turnstilePatch`。

---

## 2. 方法一：DrissionPage + turnstilePatch（managed / non-interactive 主力）

### 2.1 环境

```bash
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`：

```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`：

```javascript
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 浏览器配置

```python
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() 完整版

```python
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**。

```python
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 云端（付费兜底）

```python
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 不支持带认证的代理，要用扩展注入：

```python
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：

```python
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. 端到端模板（注册场景）

```python
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 最小可复现脚本

```python
# 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>