- 删除 uc_gui_click_captcha / _poke_captcha,Turnstile 改为无头自动放行 - _wait_for_turnstile_token 改为纯轮询,新增 _acquire_turnstile_token 做 JS 重置 + 重开页面恢复 - headless 默认 True,--headless 改用 BooleanOptionalAction(--no-headless 调试) - 新增 proxy: "direct" 配置,通过 chromium_arg --proxy-server=direct:// 绕过系统代理 (mjai.ekyu.moe Turnstile 风控机房代理 IP,需直连) - install_uc_driver.py 平台感知:Linux/Mac/Win 选对应 chromedriver 包和二进制名 - .gitignore 加 .DS_Store/venv/pytest_cache/个人配置 - README 加 Fedora 支持、proxy direct、headless 默认说明 - docs/headless-turnstile-troubleshooting.md 排查记录
128 lines
7.5 KiB
Markdown
128 lines
7.5 KiB
Markdown
# 无头化与 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"` 就不会再走任何代理。
|