# 无头化与 Turnstile 排查记录 本文记录将 batchmortal 改造为无头模式、移除模拟点击、并解决 Cloudflare Turnstile 自动放行问题的完整排查过程。供后续维护或迁移到其他平台时参考。 ## 背景 原实现通过 SeleniumBase 的 `sb.uc_gui_click_captcha()` 用 `pyautogui` 物理点击 Cloudflare Turnstile 复选框来获取 token。这条路径有三个硬伤: 1. 必须授予 macOS 辅助功能(Accessibility)+ 屏幕录制(Screen Recording)权限。 2. 无头模式下没有可点击的 GUI,根本无法工作。 3. `pyautogui` 会接管鼠标,运行期间不能并行使用电脑。 目标:无头、无点击、无系统权限依赖。 ## 排查过程 ### 第一步:确认服务器是否强制校验 Turnstile 直接 `curl -X POST https://mjai.ekyu.moe/review`(不带 token): ``` HTTP/2 403 invalid captcha response ``` 结论:HTTP 层绕不开 Turnstile,必须拿到合法 token。表单 POST 到 `/review`,token 字段是 `cf-turnstile-response`,由 Cloudflare 的 `turnstile/v0/api.js` 在浏览器端生成。 ### 第二步:无头 + 自动放行(初步方案) 删掉所有 `_poke_captcha` / `uc_gui_click_captcha` 调用,把 `_wait_for_turnstile_token` 改成纯轮询器,新增 `_acquire_turnstile_token` 做恢复(先 JS 重置 `window.turnstile.reset()`,再最多 2 次重开页面)。`_open_fresh_review_page` 的 `uc_open_with_reconnect` 的 `reconnect_time` 从 2 调到 4,给 Turnstile JS 更多时间在断开驱动期间自动下发 token。 实跑 `--limit 2`(无头 + 系统代理开着):**100% 失败**。每次都 12s 轮询无 token → JS 重置 → 重开页面 → 仍无 token → stall。6 次重试全挂。 ### 第三步:隔离变量——是“无头被识破”还是“代理被风控”? 跑 `--no-headless --limit 1`(有头 + 同一代理):**同样 100% 失败**,模式完全一致。 结论:**不是无头的问题**。有头无头在代理下都挂。最大嫌疑变成代理出口 IP 被 Cloudflare 风控(机房 IP 触发交互挑战,而非 managed 模式自动放行)。 ### 第四步:测直连 `curl --noproxy '*' https://mjai.ekyu.moe/` 返回 HTTP 200,1.1s,Cloudflare 边缘 IP。站点直连可达,代理并非访问必需。 让用户关掉 Clash 系统代理,跑 `--limit 1` 无头直连: ``` [Proxy] No system proxy detected, running directly. [1/1][...] Submit slot granted, starting Turnstile [1/1][...] Turnstile token missing, resetting widget [1/1][...] Turnstile token still missing, reloading form (1/2) [1/1][...] Turnstile token ready in 26.8s ← 成功 [1/1][...] OK rating=89.869 match=80.420% ``` **直连下无头自动放行成功**。根因确认:代理出口 IP 被 Turnstile 风控。 ### 第五步:自动化直连——`proxy: "direct"` 不能要求用户每次手动关 Clash 系统代理。需要让浏览器强制直连、绕过系统代理。 #### 为什么不能直接用 SeleniumBase 的 `proxy=` 参数 SeleniumBase 的 `validate_proxy_string`(`seleniumbase/core/proxy_helper.py`)只接受 `ip:port` 或 `scheme://host:port` 格式,`"direct://"` 通不过校验,会被置为 `None`。而 `proxy=None` 意味着不传 `--proxy-server` 给 Chrome —— Chrome 就回退到系统代理,反而走回 Clash。所以“不传 proxy”不等于“直连”。 #### 解法:用 `chromium_arg` 注入 `--proxy-server=direct://` Chrome 的 `--proxy-server=direct://` 会强制忽略所有代理(含系统代理),用 `direct`(无代理)scheme 直连。SeleniumBase 的 `SB()` 有 `chromium_arg` 参数可以塞额外 Chrome 启动参数。 在 `batchmortal/browser.py` 加模块级 helper: ```python def build_browser_kwargs(headless, proxy): kwargs = {"uc": True, "headless": headless} if proxy and str(proxy).strip().lower() == "direct": kwargs["chromium_arg"] = "--proxy-server=direct://" elif proxy: kwargs["proxy"] = proxy return kwargs ``` `main.py` 的 `detect_proxy` 识别 `"direct"` 关键字(大小写不敏感)返回 `"direct"`,所有 `SB()` 调用统一走 `build_browser_kwargs`。 #### 验证 Clash 系统代理开着 + `proxy: "direct"` + 无头 + 全量 40 条:**38/38 成功,0 失败**。 - 36/38 局 token 秒出(0.0~2.1s)—— 浏览器会话热起来后指纹/cookie 被信任 - 2/38 局 stall(冷启动 + 中途偶发),都经 JS 重置 + 1 次重开页面恢复,没有任何一局需要外层 `retry` - 全程零点击、零系统权限弹窗、无头、Clash 开着 ## 关键代码位置 - `batchmortal/browser.py:build_browser_kwargs` —— proxy→SB kwargs 翻译,`"direct"` 走 `chromium_arg` - `batchmortal/browser.py:_acquire_turnstile_token` —— 纯轮询 + JS 重置 + 重开页面恢复,无点击 - `batchmortal/browser.py:_wait_for_turnstile_token` —— 纯轮询器(检测 token / 检测 `invalid captcha response` / 超时) - `main.py:detect_proxy` —— 识别 `"direct"` 关键字 - `install_uc_driver.py:_platform_archive_info` —— 跨平台 driver 包选择 ## 平台兼容性(Fedora/Linux) 核心运行路径跨平台:`detect_proxy` 用 `urllib.request.getproxies()`(Linux 读环境变量/GSettings),`browser.py` 全是 JS + SeleniumBase 调用,文件路径用 `os.path.join`,`uc_name` 按 `os.name` 分流。无头模式不需要 X server。 **唯一需要修的是 `install_uc_driver.py`**:原版硬编码 Windows 的 `chromedriver-win64.zip` / `chromedriver.exe`,在 Linux 上会下载 Windows 二进制。改成按平台选 archive 和二进制名: | 平台 | archive | 二进制 | uc_driver 名 | | :--- | :--- | :--- | :--- | | Windows | `chromedriver-win64.zip` | `chromedriver.exe` | `uc_driver.exe` | | Linux | `chromedriver-linux64.zip` | `chromedriver` | `uc_driver`(需 `chmod 755`) | | macOS | `chromedriver-mac-{arm64,x64}.zip` | `chromedriver` | `uc_driver` | Chrome 版本检测也加了 `google-chrome-stable`(Fedora/Debian 常见名)和 mac app 路径回退。旧版 Chrome(<115)在 npmmirror 没有 Linux/Mac 包,返回 None 交给 SeleniumBase 自带下载兜底。 ## 常见问题速查 | 症状 | 原因 | 解决 | | :--- | :--- | :--- | | Turnstile 每局都 stall、100% 失败 | 浏览器走了机房代理 IP | 配 `proxy: "direct"` 走直连 | | 直连下偶发 stall(~5%) | Turnstile 偶尔不出 token | 内置恢复会自动 JS 重置 + 重开页面,无需干预 | | `proxy: "direct"` 不生效、仍走代理 | Clash 用 TUN 模式(网络层拦截) | 切回系统代理模式,或临时关 Clash;TUN 模式下 Chrome 的 `--proxy-server` 绕不过 | | 无头下 token 一直拿不到 | 先确认是不是代理问题(有头也测一次) | 有头无头都挂 = 代理问题;仅无头挂 = 指纹问题 | | Fedora 上 uc_driver 不可执行 | 旧版 install_uc_driver 下了 Windows 包 | 已修;或直接删 `seleniumbase/drivers/uc_driver*` 让 SB 自带下载 | ## 注意事项 - **TUN 模式陷阱**:如果 Clash 用 TUN 模式(而非系统代理模式),`--proxy-server=direct://` 绕不过网络层拦截,`proxy: "direct"` 会失效。此时只能切回系统代理模式或关 Clash。判断方法:`scutil --proxy` 显示 `HTTPEnable: 0` 但网络仍走代理,多半是 TUN。 - **断点续跑**:`get_processed_uuids` 会跳过已成功写入的 UUID,所以即使中途网络抖动或被 Turnstile 卡住导致中断,重跑会从未处理的牌谱继续,不会重复分析。 - **不要把 `proxy: "direct"` 和真正的代理混用**:`"direct"` 是关键字,不是 URL。配了 `"direct"` 就不会再走任何代理。