Merge branch 'cf2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-30 21:27:34 +08:00
commit 1ce937345e
36 files changed
+2609 -64

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# CF-1 Cloudflare 验证处理(调研)
> 状态:CF-2 已按本文 §3 推荐方案 A→B 级联落地,另加出口统一(站点请求明确直连 / 站点代理)。运维与字段见
> `docs/runbook/sites.md`「出口」「Cloudflare(CF-2)」、`docs/runbook/browser.md` §13。与本文的差异:同步 cf_clearance 单独存
> `site_account.user_info.cloudflare`(值在 secret),普通浏览器回写仍剔除;视觉子流程最多 2 次;`site.config.cloudflare` 多了
> `lastAttemptAt / lastSolvedAt / pending / sessionId / lastResult / lastRule / lastError`。
## 1. 复现(2026-09-30,HTTP 5 次、浏览器 5 次,间隔 ≥15s)
| 请求 | 结果 |
| --- | --- |
+2
View File
@@ -213,3 +213,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
- **观众详情页适配核对(DET-2)**:用真实详情页(≤3 次请求)逐项核对并修,脱敏样本进差分测试。
- **Cloudflare 验证处理(CF-1,用户 2026-09-30)**:搜索入口有时随机出 CF 验证。先用浏览器复现并研究:HTTP 端遇 CF 挑战时的处理策略(浏览器过挑战后同步 cf_clearance+UA 重试 / 该请求改由浏览器执行并解析 / 站点级"走浏览器"标记与时效),对照 MP(FlareSolverr / CloakBrowser)与 BR-4 的固定身份,出方案后实现。
- DET-2 结果:观众详情页修了促销/到期、上传时间、做种数、hash、副标题、标签、上传者、简介清洗、截图过滤;站点没有 TMDB 与单种 H&R。**后续**:资源增加豆瓣 / IMDb 评分字段(页面有,本工具没字段);观众文件清单是点开后另加载的端点,待核对(需 1–2 次请求)。
**CF-2 已实现(分支 cf2,待合并)**:出口统一(站点请求明确直连 / 站点代理)、挑战不算凭据错误、浏览器过挑战级联(自动 / 视觉子流程 / 转人工)、
cf_clearance 有条件给 HTTP 重试、浏览器代抓与 6 小时浏览器代理期、站点页展示;见 `docs/runbook/sites.md`「Cloudflare(CF-2)」。
+1 -1
View File
@@ -13,7 +13,7 @@
| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.list`、`download.inspect`、`downloader.list`、`task.inspect`、`site.list`、`site.user_data` |
| 计划(preview / propose) | `identity.resolve`、`download.plan`、`organize.plan`、`policy.preview` |
| 执行(execute) | `download.submit`、`download.recheck`、`organize.execute`、`task.retry`、`task.cancel`、`policy.apply`、`site.health_check`、`site.refresh_login` |
| 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md)) |
| 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md));站点请求遇 Cloudflare 复选框时系统也会发起 `solve_challenge` 视觉子流程(发起人 `system:cloudflare`、trigger event、没有父会话,预算取设置,见 browser.md §13) |
| 会话 | `agent.plan`、`agent.propose_options`、`agent.spawn`、`agent.await` |
| 网页搜索 | `web.search`、`web.fetch`(见 [websearch.md](websearch.md)) |
| 元数据补全 | `metadata.enrich`(propose,只出建议;网页搜索开启才提供,见 [identity.md](identity.md#元数据补全meta-1)) |
+35 -3
View File
@@ -126,8 +126,9 @@ execute 类需要持续授权(`automation_grant`)或审批。元素引用只来
不支持时截图不发送,能力改给已脱敏的文字快照并提示;`solve_challenge` 直接判定需人工。fake 上游按支持图片处理。
- **坐标系**:服务端对每次截图读页面视口与 DPR(`evaluate`),`页面 CSS 像素 = 截图像素 × 视口宽 / 截图宽`;
输出的 `coordinates` 记录截图尺寸、原始尺寸、视口、DPR(取不到时 `assumed = true`,按原图、DPR 1)。动作之后旧坐标系作废,
必须用新截图的坐标。点击沿用 1.17.0 的做法:`elementFromPoint` 打标记再按选择器点,**画布(canvas)内的位置、跨域 iframe
里的控件(如 Cloudflare Turnstile)点不到具体位置**,这类挑战实际只能转人工。
必须用新截图的坐标。点击(CF-2 起)先走 umt 插件的 `POST /umt/tabs/{id}/mouse-click`——Playwright `page.mouse.click` 发真实鼠标事件,
**跨域 iframe 里的控件(Cloudflare Turnstile)和画布也点得到**;插件没有这个端点(旧镜像、纯截图镜像)时退回 1.17.0 的做法:
`elementFromPoint` 打标记再按选择器点(跨域 iframe / 画布点不到具体位置)。
- **动作前提**:会话在自动化控制下(人工接管 / 等待处理中一律拒绝),当前页面主机在站点域名白名单内。
- **敏感截图**:登录页地址、页面上有密码框、地址带凭据 → `sensitive = true`,默认不交给模型(不做像素遮蔽),改给已脱敏的文字快照;
要交给模型需要 `automation_grant.action = browser.sensitive_screenshot`(或 `*`)的持续授权,且调用时传 `allowSensitive: true`。
@@ -368,6 +369,7 @@ camofox 1.17.0 只有 `POST /sessions/:userId/cookies`(导入),而且要另一
| `GET /umt/instance` | `{instanceId, startedAt}`:插件加载时生成,camofox 进程重启即变(BR-3 重启检测,§10) |
| `GET /umt/identity` | 固定浏览器身份的公开部分(UA / OS / 版本,BR-4,§11.1) |
| `PUT /umt/sessions/{userId}/proxy` `{proxy}` | 该 profile 的出口:`{server, username?, password?}` 或 `null` 直连(BR-4,§11.5) |
| `POST /umt/tabs/{tabId}/mouse-click` `{userId, x, y}` | 按视口坐标发真实鼠标事件(`page.mouse.click`,CF-2,§13);标签页不在回 422 |
适配器先走插件;插件不在(404)再试上游导入端点(`CAMOFOX_API_KEY` 与访问凭据相同时可用,只能写不能读);都不行 → 会话照常打开,
`credential.status = unsupported` 并说明。profile 落盘在 `/root/.camofox/profiles/<sha256(userId) 前 32 位>/storage-state.json`
@@ -550,7 +552,7 @@ Windows 的真值);TLS / HTTP2 指纹是 Firefox 152 本身(与 OS 无关)。屏
- Cookie 账号如果一直不开原站视图、也不换 Cookie,会一直待切换(界面写"待切换:下次刷新登录,或在原站视图里用过后改为跟随浏览器")。
刷新登录对 Cookie 账号只校验,不切换。
### 11.4 Cloudflare Cookie 仅浏览器
### 11.4 Cloudflare Cookie 仅浏览器(CF-2 起有条件给 HTTP)
`cf_clearance`、`__cf_bm`(及挑战过程中的 `cf_chl_*`)与取得它的浏览器绑定(UA + IP + TLS 指纹),Deno 的 TLS 指纹与 Firefox 必然不同,
HTTP 端拿去用多半无效还可能触发风控。所以:
@@ -559,6 +561,9 @@ HTTP 端拿去用多半无效还可能触发风控。所以:
- 比较"有没有变化"时也不算它们(CF Cookie 轮换不会触发回写);refresh-login 从浏览器读回的 Cookie 同样剔除;
- 站点账号页技术信息里显示"仅浏览器使用的 Cookie";
- 用户手填的 Cookie 里带 `cf_clearance` 不处理(用户自己的选择)。
- **CF-2 例外**:站点遇 Cloudflare 挑战、浏览器过了之后,账号 UA 来源 = 跟随浏览器、浏览器 UA 与 HTTP UA 相同、两端出口相同时,
`cf_clearance`(只有它,`__cf_bm` 仍不带)单独存进 `site_account.user_info.cloudflare`(值在 secret)并由 HTTP 带上重试 1 次,
结果按站点记 `cfReuse`;普通回写仍剔除它(见 sites.md「Cloudflare(CF-2)」)。
### 11.5 出口 IP 按站点统一
@@ -642,3 +647,30 @@ camofox 按 **API 调用**判断活动,有三道空闲回收(`docker-compose.yml
核验:`apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts`(登记 / 引用计数 / 复查;对 mock camofox:接管期间保活、
不记自动化活动、交还后停;桌面连接期间标签页被回收 → 保活时自愈重连;智能体会话结束后停、会话关闭撤销)。真机未核验。
## 13. Cloudflare 挑战(CF-2)
站点 HTTP 遇挑战时由站点请求一侧(sources-identity `CloudflareGate`)经钩子调用浏览器模块,流程与状态见 sites.md「Cloudflare(CF-2)」。
浏览器这边:
- `solveSiteChallenge({siteId, accountId, url})`:该账号 profile 的自动化会话(没有就按站点打开;人工接管中 / 等人处理中不打扰)导航到
同一地址(取站点令牌)→ 用 `POST /tabs/{id}/evaluate` 读页面(地址、标题、outerHTML、正文样本、`navigator.userAgent`,适配器
`pageContent`)按 `classifyChallengePage` 分类:自动(JS 检查)/ 可点(Turnstile、「确认您是真人」)/ 真验证码(hCaptcha、reCAPTCHA、
选图、输码)→ ① 每 1.5 秒读一次,最多 15 秒 → ② 可点的派视觉子流程(`browser.solve_challenge`,系统发起:发起人 `system:cloudflare`、
trigger event、没有父会话;步数 4、时长 ≤ 120 秒、token / 费用取 `agent.maxTokensPerRun` / `maxCostUsdPerRun`;最多 2 次;
自动化总开关关闭或只读对账时不派)→ ③ 没过:会话转等待 `cf_challenge`,待处理事项「<站点>需要你在原站视图里过一次验证」
(视觉子流程失败留下的 `challenge_unsolved` 事项改成这个说法)。通过后读回 profile 里的 `cf_clearance`、UA 与会话出口(`recovery_state.egress`,
插件按站点下发出口成功才有值)。
- `fetchSiteHtml({siteId, accountId, url})`:同一会话导航(取站点令牌)后读 outerHTML;仍是挑战页 / 会话在人工处理 / 站点停用时不返回页面。
- 同一 profile 的过挑战与代抓在进程内串行(profile 锁);跨进程同站 30 分钟一次靠站点状态的条件更新。
- 交还会话(给自动化)时:站点在等人过验证 → 读回 `cf_clearance` / UA 通知站点请求继续(`CloudflareGate.afterHuman`),待处理事项随交还关闭。
- 插件新端点 `POST /umt/tabs/{tabId}/mouse-click`:`click_at`(与原站视图截图模式的坐标点击)都先走它。**需要重建 camofox 镜像**才有
(`docker compose build camofox && docker compose up -d camofox`,只重跑插件层);没重建时退回 elementFromPoint,Turnstile 点不到,
可点类挑战会转人工。
核验:mock camofox(`cloudflare` 模式:auto / interactive / captcha)见 `browser-cloudflare.test.ts`、`umt-plugin.test.ts`、E14。
插件鼠标点击的真实容器核验见下表(本地临时容器,只开本地页面,不访问真实验证页)。
| 日期 | 做法 | 结果 |
| --- | --- | --- |
| 2026-09-30 | 临时容器 `umt-cf2-camofox`(镜像 `umt-browser-desktop:1.17.0`,只读挂载新插件目录),容器内 node 起两个本地页面:父页 `localhost:8001`(标题 Just a moment...)内嵌**跨域** iframe `localhost:8002`(复选框,`change` 事件 `isTrusted` 才向父页 postMessage);适配器 `openSession` → `pageContent` → `click({x, y})` → `pageContent` | 插件路径:`POST /umt/tabs/{id}/mouse-click` 命中复选框,iframe 里收到可信事件,父页标题变为 passed;`pageContent` 读到 UA `…Windows NT 10.0; Win64; x64; rv:152.0…`。旧路径(elementFromPoint)只能标到 iframe 元素本身、按元素中心点击,点不到指定坐标。未访问任何外部站点,容器已删除 |
+67 -2
View File
@@ -97,7 +97,7 @@
| `defaultHealthCheckIntervalSec` | 21600 | 定时健康检测的最短间隔(与定义取较长) |
| `defaultDetailCacheSec` | 86400 | 详情缓存:期内 `GET /sites/{id}/releases/{rid}?refresh=true` 与 `release.inspect` 不再请求站点(与定义取较长);0 = 不缓存 |
| `userAgent` | 空(跟随浏览器) | 全局 UA 覆盖:空 = 站点请求跟随浏览器固定身份的 UA(Windows Firefox);填了则账号没手动 UA 时都用它。优先级:账号手动 > 待切换 > 全局 > 跟随浏览器,见 browser.md §11.2 |
| `proxyUrl` | null | 站点 `proxy=true` 时,适配器、登录、用户数据、RSS 请求都经此代理(`Deno.createHttpClient`),浏览器会话也走同一代理(browser.md §11.5);地址要让 app 与 camofox 容器都能连 |
| `proxyUrl` | null | 站点 `proxy=true` 时,适配器、登录、用户数据、RSS 请求都经此代理(`Deno.createHttpClient`),浏览器会话也走同一代理(browser.md §11.5);没开代理的站点**明确直连**,不读容器的 `HTTP(S)_PROXY`(见下「出口」);地址要让 app 与 camofox 容器都能连 |
| `userDataMaxSeedingPages` | 10 | 用户数据做种分页上限 |
运行时每 30 s 最多重读一次设置;设置变化会让站点适配器按新签名重建。
@@ -137,9 +137,74 @@
- 注册数:首批能力 17 → 21,启动断言 `assertCapabilityCount` 跟随 `CAPABILITY_IDS.length`。
- 未接:`agent/invoke-limits.ts` 的站点并发上限还没把 `site.health_check` / `site.refresh_login` 算作外部站点调用;Agent 默认只读白名单(`agent/runner.ts`)也还没加 `site.list` / `site.user_data`。两处都在 agent 模块,留给该模块的负责人。
## 出口:哪些请求走哪条(CF-2)
代码 `apps/server/src/egress.ts`。之前 app 的站点请求用 `globalThis.fetch`,Deno 会读容器的 `HTTPS_PROXY`(本机栈 override 里的 mihomo 中继),
站点没开代理时 HTTP 端走代理、浏览器却直连,两端出口 IP 不同(CF-1 实测 HKG / DFW),这是 Cloudflare 随机拦截的来源之一。
| 请求 | 出口 |
| --- | --- |
| 站点请求:适配器(列表 / 搜索 / 详情 / 下载 / 健康检测)、RSS、账密登录、用户数据(`SourcesRuntime.siteFetch`) | 站点开「使用代理」且设了 `sites.proxyUrl` → 该代理;否则**明确直连** |
| 浏览器会话(camofox) | 同一站点同一出口:插件按 profile 设代理或直连(browser.md §11.5);`UMT_BROWSER_PROXY_MODE=env` 时按容器环境变量(与 HTTP 不一定一致) |
| TMDB、Brave 搜索 / 网页读取、TMDB 图片缓存、海报与站点截图代理 | 照旧按环境变量 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY`(进程启动时读一次),改为代码里显式建客户端(`externalFetch`),行为不变 |
| 模型上游(pi SDK)、下载器、媒体服务器、camofox 本身 | 不在本表范围,仍是各自的 fetch(模型上游随环境变量) |
**Deno 2.9 的语义**(2026-09-30 本机实测,`test/sources-identity/egress.test.ts` 锁住):缺省 `fetch` 与 `Deno.createHttpClient({})` 都读
`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`(大小写都认)与 `NO_PROXY`,读取发生在**客户端创建时**;`createHttpClient` 没有"不走代理"的选项,
`NO_PROXY=*` 不认;显式 `proxy` 排在环境变量代理前面,但 `NO_PROXY` 命中的主机连显式代理也绕过。所以直连客户端是在创建那一刻临时拿掉
这几个环境变量(同步调用,同一 isolate 内不会插进别的代码)、建完立即还原;代理客户端同样在拿掉后创建,只走给定代理。
排查:站点 HTTP 与浏览器是否同出口,在 app 容器里 `curl` 不能代表 Deno(curl 也读环境变量);看站点页技术信息里的 Cloudflare 记录,
或临时对一个会回显来源 IP 的地址各发一次(不要对 PT 站做)。
## Cloudflare(CF-2)
方案见 `docs/design/cloudflare-handling.md`。代码:`packages/adapters/src/sites/challenge.ts`(识别)、
`apps/server/src/modules/sources-identity/service/cloudflare.ts`(级联,`CloudflareGate`)、`modules/browser/service.ts`
(`solveSiteChallenge` / `fetchSiteHtml`)、`modules/agent/challenge.ts`(`systemChallengeVision`)。
**识别**:站点响应带 `cf-mitigated: challenge`(任何状态码),或 403 / 503 / 429 且正文是挑战页(标题 Just a moment / Attention Required、
`cf_chl_opt` 等,不认正常页面也会注入的 `/cdn-cgi/challenge-platform/`)→ `SiteError("challenge")`。**不是凭据错误**:快照 / 详情不调
`onAuthError`、不置账号失效、不排自动重登;健康检测记 `challenge`(degraded);刷新登录 / 用户数据遇挑战只写 `last_error`,账号状态不变
(验证码、二次验证仍是账号状态 `challenge`)。
**级联**(站点请求经 `siteFetch` 自动走,调用方无感):
1. 站点在浏览器代理期(`config.cloudflare.browserUntil` 未到)且请求是页面(GET + Accept `text/html`)→ 直接由浏览器代抓,不发 HTTP;
2. 发 HTTP;满足复用条件时带上浏览器拿到的 `cf_clearance`(`__cf_bm` 不带);
3. 遇挑战 → 记 `lastChallengeAt` / `lastRule`;只处理**有登录态账号**的站点(第一个启用、未失效、确有 Cookie 的 Cookie / 账密账号);
4. 过挑战:同站 30 分钟最多一次(`lastAttemptAt` 条件更新,跨进程)、进程内同一时刻只跑一次,浏览器用该账号 profile 打开同一地址:
① 轮询页面等它自己过(≤ 15 秒)→ ② 「我是人类」复选框 / Turnstile:派视觉子流程点一下(系统发起、没有父会话,预算取设置,最多 2 次;
自动化总开关关闭或只读对账时跳过)→ ③ 选图 / 输码类或仍没过:会话转等待(`cf_challenge`),待处理事项「<站点>需要你在原站视图里过一次验证」,
站点记 `pending = human`。不接任何打码服务;
5. 通过 → 读回 `cf_clearance` 与 `navigator.userAgent`;**只有账号 UA 来源 = 跟随浏览器、UA 相同、两端出口相同**(浏览器出口按站点下发成功才算)
时才存进账号(`site_account.user_info.cloudflare = {secretRef, userAgent, egress, at}`,值在 secret 里),HTTP 重试 1 次:过了记
`cfReuse = ok`,仍被挑战记 `cfReuse = fails`(之后跳过复用;UA 或出口变了自动清掉重试);
6. 仍被挑战 → 页面请求改由浏览器导航取 outerHTML 交给原解析器(受站点限流令牌与 profile 锁约束),站点记 6 小时浏览器代理期,到期再试 HTTP;
下载 .torrent、RSS、POST 这类不能代抓的请求照常报挑战。
人在原站视图里过完验证、交还会话(交还给自动化)→ 读回 `cf_clearance` / UA,清 `pending`,记 `lastSolvedBy = human`,并允许马上再试(清
`lastAttemptAt`)。
`site.config.cloudflare` 字段(契约 `SiteCloudflareState`):`browserUntil`、`cfReuse`(ok / fails)、`lastChallengeAt`、`lastSolvedBy`
(auto / vision / human)、`lastSolvedAt`、`lastAttemptAt`、`pending`(human)、`sessionId`、`lastResult`、`lastRule`、`lastError`。
改这个键不会让站点适配器重建。
**界面**:站点卡片与站点概览「站点」一栏显示一行 Cloudflare 状态(没遇到过挑战不显示):「需要你验证」(卡片上可点,进原站视图)/
「浏览器代理中,至 hh:mm」/「自动通过」/「没能通过」;原始记录进概览的技术信息。
**测试**:`packages/adapters/src/sites/challenge.test.ts`(分类)、`apps/server/test/sources-identity/egress.test.ts`(出口)、
`cloudflare.test.ts`(级联:自动通过 / 视觉 / 转人工 / 复用失败回退浏览器 / 节流单飞 / 同步条件 / 健康)、
`modules/browser/__tests__/browser-cloudflare.test.ts`(mock camofox:自动放行、插件真实鼠标点击、选图转人工、交还继续、代抓与 profile 锁)、
`test/e2e/e14-vision.test.ts`(系统发起的视觉子流程)。真机:未对真实站点验证(CF-2 只用 mock);插件鼠标点击见 browser.md §13。
**要人处理时**:站点页 Cloudflare 行显示「需要你验证」→ 原站视图接管 → 过验证 → 交还。一直过不去(站点绑定 TLS 指纹、代理 IP 被拉黑):
关掉站点代理或换出口;实在不行关闭该站点的定时快照,只保留 RSS。
## 待核验
1. takelogin 表单字段、二次验证字段名、错误文本全部只对 `@umt/testkit` mock 站点验证过,未连接真实站点。
2. 失败文本归类是关键词匹配(`classifyLoginError`),真实站点的措辞可能落到 `unknown`(状态记为 expired,原文截 200 字写进 `last_error`)。
3. Cloudflare 站点只识别并转待处理,不做浏览器登录。
3. Cloudflare:识别与级联见上「Cloudflare(CF-2)」,只对 mock 验证过;真实站点的挑战页类型、cf_clearance 能否给 HTTP 复用(是否绑 TLS)
按站点记 `cfReuse` 在线验证。账密登录遇挑战仍不做浏览器登录。
4. 馒头用户数据:`MTorrent` 解析器的做种列表首页按 GET 请求,真实 API 可能要求 POST。