Files
UHD_Media_Tool/docs/runbook/sites.md
T

258 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 站点运行手册(W9-A 站点中心后端)
规格:`docs/design/site-center.md`。实现:`apps/server/src/modules/sources-identity/service/account.ts`(凭证、刷新登录、用户数据、健康检测、删除、定时任务)、`routes/sources.ts`、`runtime.ts`(凭证注入、站点默认、代理);账密登录 `packages/adapters/src/sites/login/nexusphp.ts`;迁移 `0031_site_center`。
## 站点类型与获取方式(IA-2,迁移 `0055_site_kind`)
- `site.kind`:`pt`(PT 站,从站点定义库选)/ `public_bt`(公开 BT 站,不登录)/ `rss_feed`(自定义 RSS 源,只有 RSS)/ `torznab`(Jackett、Prowlarr 等的 Torznab 接口,**只定义结构**:`TorznabSiteConfig{apiUrl, apiKeySecretRef, indexerId, categories}`,不能添加、不参与调度)。定义在 `packages/contracts/src/sources.ts` 的 `SITE_KINDS`。
- 获取方式 `capabilities`:`search / list / rss / detail / login / userinfo`。站点实际可用 = 类型允许的 ∩ 站点定义实现的(`siteCapabilities`):RSS 走通用解析器,不依赖定义;搜索 / 列表 / 详情看定义的 `umt.capabilities`;登录与账号数据要有定义。`adapter = generic_rss` 的站点不查定义。`GET /sites` 每项带派生字段 `capabilities`;`site.list`(智能体)同样带 `kind` 与 `capabilities`。
- 服务端统一按 capabilities 判断(`modules/sources-identity/site-kinds.ts`):
- 调度:`sites.snapshot` 只排能 `list` 的站点;`feeds.poll` 只排 `kind` 能抓 RSS 的站点(`RSS_KINDS`);`sites.user_data` 只扫能登录的类型(`LOGIN_KINDS`);`sites.health_check` 跳过只剩 RSS 的站点。
- 搜索:参与搜索的站点先过滤能 `search` 的(自定义 RSS 源不出现在搜索站点选择里);实时搜索(`live_search`)/ 列表快照 / 补漏在不支持的站点上直接跳过(`reason: unsupported`,不请求站点)。RSS 疑似缺口只在站点能 `list` 时排补漏。
- API:`POST /sites/{id}/refresh-login`、`PUT /sites/{id}/credentials` 要 `login`,`POST /sites/{id}/snapshot` 要 `list`;不支持时 400「「名称」是自定义 RSS 源,不支持登录」。
- `POST /sites`:`kind` 缺省为"注册表里有定义 = pt,否则 rss_feed"(兼容旧的 generic_rss 登记)。非 PT 站 `adapter` 必须是 `generic_rss`,`key` 可省(按地址主机生成 `rss-<主机>`,重名加序号),可带 `rssUrl` 在同一事务里建 RSS(名称「站点名 RSS」,轮询 30 分钟);非 PT 站 `searchDefault=false`、`snapshotEnabled=false`。`kind` 添加后不改(`SiteUpdate` 不含)。
- **RSS 属于站点**:`POST /feed-sources` 不带 `siteId` 时归入同主机(`https://<主机>`)的自定义 RSS 源站点,没有就建一个;`PATCH /feed-sources/{id}` 不能把 `siteId` 改成 null(400)。secret 名带随机后缀,同站多个「站点名 RSS」不再撞名。
- 迁移 0055:`adapter = generic_rss` 的存量站点 → `rss_feed`,其余 → `pt`;`site_id` 为空的 RSS 按地址主机归入新建的自定义 RSS 源站点(同主机一个),并清掉游标窗口与 ETag / Last-Modified,下次抓取把条目重新写成资源(无站点时观察记录只标 `ignored/no_site`,没有资源)。用户库 2026-09-30 只读核对:孤立 RSS 0 条(共 1 条 RSS,1 个 nexusphp 站点),迁移只加列。
- 界面:侧栏「获取」组(站点 / 订阅 / 规则);站点列表按类型筛选,非 PT 站卡片只显示 RSS 数与上次成功;站点详情按类型显示 Tab(自定义 RSS 源:概览 / 资源 / RSS);添加站点第一步选类型。旧 `/console/sources*` 跳转:`?siteId=` → 该站 RSS Tab,`?feed=<id>` → 查所属站点后打开该 RSS,`?new=1` → 添加自定义 RSS 源,其余 → 站点列表。
## 端点
| 端点 | scope | 说明 |
|---|---|---|
| `GET /sites?include=accounts` | read | 列表;带 `include=accounts` 时每项附 `accounts[]`(完整 SiteAccount)和 `accountStatus`(多账号取最差,无启用账号为 `unconfigured`)。已删除站点不出现 |
| `POST /sites` / `PATCH /sites/{id}` | admin | 登记 / 修改;新增 `proxy`。`rateLimit` 为空、`snapshotIntervalSec` 为 null 时继承站点默认 |
| `DELETE /sites/{id}` | admin | 见"删除" |
| `PUT /sites/{id}/credentials` | admin | 按 `kind` 判别:`password{username,password,totpSecret?,saveCookieOnly?,userAgent?}` / `cookie{cookie,userAgent?}` / `api_key{apiKey}` / `passkey{passkey}`,都带 `label`(缺省 `default`)。旧格式 `{authKind, credential, username?}` 仍接受。明文进 secret 表,响应只有 `secretRef` |
| `POST /sites/{id}/refresh-login` | admin | body `{accountId?, wait?}`。缺省异步入队 `refreshSiteAccount` 返回 `{taskId, result:null}`(幂等键按 10 s 取整);`wait:true` 同步执行(≤30 s)返回 `{taskId:null, result: SiteLoginResult}` |
| `POST /sites/{id}/health-check` | admin | 同步(≤20 s,走站点限流);返回 `SiteHealthResult`,写 `site.last_health`、`last_health_at`、`health_status` |
| `GET /sites/{id}/user-data?accountId&days=30` | read | `site_user_data` 按天历史(1–400 天) |
| `DELETE /site-accounts/{id}` | admin | 删账号及其全部 secret |
`POST/PATCH /site-accounts` 的凭证部分已标弃用,新客户端只用 `PUT /sites/{id}/credentials`。该端点从 auth-settings 模块迁到 sources-identity。
## 账号状态(`site_account.status`)
| 值 | 界面 | 进入条件 |
|---|---|---|
| `unknown` | 未验证 | 新建或更新凭证 |
| `active` | 正常 | 刷新登录 / 校验成功 |
| `expired` | 已失效 | Cookie 校验失败;账密登录后仍未检测到登录态;快照 / Feed 遇 `auth_expired` |
| `invalid` | 已失效 | 密码错、账号封禁 / 未确认、API Key 无效 |
| `challenge` | 需验证 | 验证码、二次验证(无 TOTP)、Cloudflare 挑战 |
网络失败只写 `last_error`(`reason:"network"`),状态不变。站点没有定义、Passkey 账号、不支持账密的站点(馒头等)返回 `reason:"unsupported"`,状态不变。
失败时开待处理事项:kind `source_unreachable`,`dedupeKey = site-login:<accountId>`,subject `site_account`。选项 `use_cookie`(input 带 siteId / accountId,前端深链到凭证对话框)、`retry`(仅 challenge;选中后入队重新登录)、`dismiss`。更新凭证或登录成功时关闭该事项和 `site-auth:<siteId>`。
## 刷新登录流程
- **账密**(仅 `umt.kind = nexusphp` 且定义有 `umt.login`):GET 登录页 → 页面有 `imagecode.php` 判验证码 → 取 takelogin 表单隐藏字段,填用户名 / 密码 / `selectDefaults` / `extraFormData`,有 TOTP 且表单有 `two_step_code`/`otp` 等字段时填当前 6 位码 → POST(不跟随 3xx)→ GET `index.php`,`loginSuccess`(缺省 `a[href*="logout"]`)命中即成功。Cookie 存 `session_secret_ref`(purpose `site_cookie`),之后适配器取 cookie 时优先用它。`saveCookieOnly` 为真时,成功后凭证类型改为 cookie,密码与 TOTP secret 删除。
- **Cookie / API Key**:用 `refreshSiteUserData` 取用户数据作为校验(不做 schema 回退,避免对站点连发 20 次请求);站点没有解析器时退回适配器 `healthCheck()`。
- 成功后写 `user_data`、`user_data_at`,并 upsert 当日 `site_user_data`(同日取最新)。账密登录成功但用户数据没取到时仍算登录成功,`message` 说明原因。
- 快照 / Feed 遇登录失效(`onAuthError`)时,若站点有账密账号,入队一次自动重登(同账号 10 分钟一个幂等槽)。
## 用户数据字段
`SiteAccount.userData`:`schema, userId, username, userLevel, vip, joinAt, upload, download, ratio, bonus, seeding, seedingSize, leeching, leechingSize, messageUnread`。`ratio` 为 null 表示站点没给且下载量为 0;`bonus` 为 0 时记 null(不显示);`vip` 由定义 `umt.profile.vip_css`(首页命中)或 `umt.vipLevels`(等级名包含匹配)判定,都没配置为 null。做种分页上限取 `settings.sites.userDataMaxSeedingPages`。
## 健康检测
`healthCheck()` 结果映射:`ok → healthy`,`auth_* → failing`,其余(限流、不可达、维护、解析失败)→ `degraded`;已关闭站点保持 `disabled`。`loggedIn`:ok 为 true,`auth_*` 为 false,其余 null。首页已登录时顺手更新 active 账号的 `last_login_at`。
## 删除
`DELETE /sites/{id}` 在一个事务里:资源 `availability` 改为 `site_disabled`(资源和下载任务都保留,`source_release.site_id` 外键为 restrict)、Feed 停用并解除账号关联、关闭该站待处理、删全部账号(`site_user_data` 随账号级联删除)、站点行软删除(`deleted_at`,`enabled=false`,key 改为 `<key>-deleted-<时间>`,同一定义可以重新登记)。事务提交后删除账号的全部 secret。
## Feed(RSS)添加
- **站点内添加**(站点详情「RSS」→ 新建 RSS,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。
- **`POST /feed-sources` 带 `siteId` 时的推断**(`service/feeds.ts feedDefaultsForSite`):`siteAccountId` 缺省取该站启用账号,优先带 passkey 的,再取最早建的;`parserVersion` 记为 `rss@1;<站点定义 umt.version>`(解析仍走通用 RSS 解析器,版本只作追溯);`name` 缺省「站点名 RSS」;`pollIntervalSec` 缺省 1800(站点请求克制;不挂站点的 Feed 仍缺省 900)。地址主机键(去 `www.`、取最后两级,IP 原样)与站点 `baseUrl` / 定义 `domain` 都不一致时,响应附 `warnings[]`(只在创建响应出现)。`name` 现在可省,不挂站点时缺省为地址主机名。
- **站点 RSS 条目规范化(HH-2,`packages/adapters/src/sites/feed-item.ts normalizeSiteFeedItem`)**:NexusPHP `torrentrss.php` 的标题是 `[分类]种子名[副标题][大小][上传者]`(憨憨实测 `[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | …][697.64 MB]`)。挂站点的 Feed 入库时按站点定义拆开:前缀只认定义里登记过的分类名(`umt.categoryNames` / `category.*.desc`),换成站点分类 ID 写 `category_raw`(与列表快照一致,资源页分类筛选可用);尾部配平地剥最多 3 段方括号,只有出现大小段时才把它前面一段当副标题、后面一段当上传者;去掉「[新]」。大小优先用 enclosure `length`(精确字节),没有才用标题里的显示值。`source_release.raw.rss` 记 `rawTitle / categoryName / mediaType(movie·tv·music·null) / uploader / v`。不改标题解析器(`[分类]` 前缀引起的误识别归 ID-2)。
- **与列表快照合并**:同站同资源 ID 一行。快照见过的行(`raw._site`)标题 / 副标题 / 分类 / 做种 / 促销 / H&R 以快照为准,RSS 只补空缺、更新 `raw.rss`;快照没见过的行,RSS 内容变化时用新值。快照后到时照常覆盖 RSS 值。
- **旧行补规范化**:规范化上线前写入的 RSS 行(`raw.rss.v` 缺失)在该站任一 Feed 下次抓取时按原标题重算(每次最多 500 行,不请求站点);标题变了且未锁定的重新发 `release.observed`(幂等键 `…:rss-norm-1`)。
- **资源列表展示**:列表按 `coalesce(发布时间, 首次发现)` 倒序,和字段是否齐全无关。RSS 独有的条目(憨憨的音乐区不在 `torrents.php` 快照里)没有做种 / 下载 / 完成、H&R,列表显示「—」,不挂「H&R 未知」徽章(详情抽屉的 H&R 字段仍写「未知」);分类名取快照分类名 → `raw.rss.categoryName` → 分类 ID。
- **编辑 RSS**(`components/workbench/feed-form.tsx`,站点 RSS Tab 的详情 → 编辑):完整表单;站点只能换成另一个能抓 RSS 的站点,不能"不关联"。RSS 详情里有「最近条目」(`GET /observations?feedId=`,新的在前 20 条)。IA-2 前的独立 RSS 页(`/console/sources`)已取消,只做跳转。
## 定时任务
| key | 名称 | 默认 | 行为 |
|---|---|---|---|
| `sites.user_data` | 站点数据刷新 | 每 6 小时,一个周期后首跑 | 启用站点里 `status=active` 的非 passkey 账号逐个入队 `refreshSiteAccount{mode:"userData"}`;会话失效的账密账号自动重登一次;只读对账模式跳过 |
| `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(有站点适配器、不只 RSS 的)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 |
注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。
## 默认参与搜索(SEARCH-2)
`site.search_default`(迁移 0048,缺省 true;`POST/PATCH /sites` 的 `searchDefault`):搜资源时「默认站点」集合 = 启用且打开此项的站点。站点卡底部和编辑面板都有开关。
全局默认范围在 `settings.search`(站点页「搜索站点」):`defaultSiteMode = default | all | custom`,`custom` 时 `siteIds` 生效;`GET /search/releases` 不带 `sites` 时按它选站点。详见 `docs/runbook/search.md`。
## 站点默认(`settings.sites`)
| 字段 | 缺省 | 说明 |
|---|---|---|
| `defaultRateLimit` | `{requestsPerMinute: 20, concurrency: 2}` | 站点 `rate_limit` 为空时使用;不会比定义里的 `umt.rateLimit` 更激进(取两者较慢的间隔、较小的并发) |
| `defaultSnapshotIntervalSec` | 3600 | 站点开了定时快照但未设周期时使用(与定义 `umt.defaults.snapshotIntervalSec` 取较长) |
| `defaultSnapshotMaxPages` | 1 | 定时 / 手动快照最多抓几页(与定义取较小);补漏有自己的页数预算,实时补查固定 1 页 |
| `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);没开代理的站点**明确直连**,不读容器的 `HTTP(S)_PROXY`(见下「出口」);地址要让 app 与 camofox 容器都能连 |
| `userDataMaxSeedingPages` | 10 | 用户数据做种分页上限 |
运行时每 30 s 最多重读一次设置;设置变化会让站点适配器按新签名重建。
## 访问默认与请求统计
站点定义可带 `umt.defaults`(`snapshotIntervalSec / snapshotMaxPages / healthCheckIntervalSec / userDataIntervalSec / searchConcurrency / detailCacheSec`),是按站点实测给的保守下限;运行时 `siteAccessFor(def, settings.sites)` 合并:周期、缓存取较长,页数取较小;`searchConcurrency`(缺省 1)让同站不同关键词的实时补查排队执行。目前只有憨憨配置了(快照 60 分钟 1 页、健康检测 6 小时、用户数据 6 小时、搜索并发 1、详情缓存 24 小时、限流 3 秒 1 个令牌突发 2)。
每从站点限流器拿到一个令牌(列表 / 快照、搜索、详情、详情的文件清单、下载、健康检测、登录、用户数据、原站视图导航)记一次请求;挂站点的 Feed 每次真正发出 RSS 请求(不含熔断 / 退避跳过)也记一次(HH-2 之前漏算)。写 `site_request_count`(迁移 `0046`,按小时聚合,多进程共用,重启不丢,保留 7 天)。`GET /sites/{id}/request-stats`(read)返回最近 24 小时总数、按小时明细、快照实际周期与合并后的访问默认;站点页概览「近 24 小时请求」与资源页列表头显示。计数从迁移 0046 上线后开始累计,之前的请求没有记录,无法回填;RSS 请求从 HH-2 起计入。
## 详情页扩展内容(INT-2)
资源详情抽屉的「站点详情页」一节:简介、MediaInfo / BDInfo、截图、IMDb / 豆瓣 / TMDB 链接、文件清单、标签、上传者。
- 只按需抓:打开抽屉时如果没有缓存,前端自动调一次 `GET /sites/{id}/releases/{rid}?refresh=true`(同一资源本次会话只自动一次),或点「复核详情」;`release.inspect` 的 `refreshDetail` 同一路径。列表快照、搜索本身不抓详情;标题识别认不准时,识别升级链(ID-3)会为取 ID 抓一次(同一路径、同一限流器,每站每小时有上限,见 `docs/runbook/identity.md`「识别升级链」)。
- 缓存:内容放 PG `kv_cache` region `site.detail`,key = `source_release.id`,TTL 取站点详情缓存期(至少 24 小时)。缓存期内 `detail_fetched_at` 新鲜且缓存还在就不请求站点;kv_cache 被清掉(过期 / 重启丢 UNLOGGED 表)后下次刷新会重新抓一次。
- 外部 ID 另写 `source_release.raw._detail`(`imdbId / doubanId / tmdb{type,id}`),列表快照 upsert 不覆盖它。身份解析把它和列表给的 `raw._site.imdbId / doubanId` 一起当显式 ID:本地召回查已接受的外部身份;需要查 TMDB 时,有 TMDB ID 直接取详情,否则 IMDb ID 走 `/find/{imdb}?external_source=imdb_id`,命中就不再按标题搜索;TMDB 不支持豆瓣 ID:豆瓣 ID 用于本地召回,识别升级链(ID-3)里再经缓存的豆瓣条目(标题 + 年份)匹配 TMDB。是否查 TMDB 仍按惰性识别规则(DEC-1),外部 ID 本身不触发查询。
- 列表页外部 ID(ID-3 核对):NexusPHP 定义的 `torrents.fields.imdbid`(MP 字段)全部站点都有;豆瓣是 UMT 扩展字段 `torrents.fields.doubanid`(MP 不读,索引差分只比 MP 字段),写法同 imdbid。现有列表样本里只有观众 `rl-audiences-list` 带 ID 徽标(10 行豆瓣、5 行 IMDb),已给观众补 `doubanid`(`a[href*="douban.com/subject/"]` + `re_search subject/(\d+)`,覆盖层 `scripts/mp-sync/movieclaw-overrides/audiences.json`,测试 `packages/adapters/test/differential/list-ids.test.ts`)。憨憨 / 彩虹岛的列表样本没有 IMDb / 豆瓣链接(ID 只在详情页),其它站点没有列表样本,暂不加选择器。
- 截图:响应里只有代理路径 `/api/v1/img/site-detail/{releaseId}/{index}`,服务端从缓存取原始地址,公网校验(每跳重定向复核)、不带站点凭据、按文件头认图片(不放行 SVG)、≤ 8 MB,浏览器缓存 24 小时。站内附件如果需要登录才能看会显示为裂图,不会为截图消耗站点请求。
- 憨憨详情页(HH-2 按真实页面 `probe.labels` 核对):没有 `#kdescr`,内容分节为「标签 div + 相邻值 div」:`mediainfo`、`海报`、`豆瓣`(内有 `span.font-bold` 小标签 片名 / 年代 / 类别 / 导演 …)、`截图`、`种子文件`(只有「查看列表」按钮)、`发布者`、`发布时间`。对应规则:简介没有容器时取「豆瓣 / 影片信息 / IMDb」信息块,小标签补冒号、一项一行;截图取「截图」一节的 img(`data-src` 优先,海报不算截图),没有再取简介里的图;IMDb 依次取页面链接 → 简介 / 信息块 / MediaInfo 里的 `imdb.com/title/tt…` 或 `IMDB : tt…` 行(只收 `tt` + 7~8 位,不收孤立 tt 串),TMDB 取链接或 `TMDB : tv/294990` 行,豆瓣取链接或 `douban.com/subject/…`;兜底出的 IMDb / 豆瓣也回填到 `SiteRelease.imdbId / doubanId`;发布时间没有 `span[title]` 时取「发布时间 / 上传时间」字段文本。文件清单:定义 `umt.detail.page.fileListPath`(憨憨 `viewfilelist.php?id={id}`)配置了且页面上没有内联清单时,同一次详情刷新里再请求 1 次(走同一限流器、计数),失败只是没有清单。fixture `HHANCLUB_DETAIL_PAGE_2026` / `HHANCLUB_FILELIST_FRAGMENT`:标签名称与顺序来自真实页面,嵌套按已验证的标题 / 副标题结构推定,部署后打开一条憨憨资源的详情抽屉核对一次。
- 观众详情页(DET-2 按真实页面核对,样本 `packages/adapters/test/differential/inputs/detail/rl-audiences-detail-*.html.txt` 已脱敏,回归测试 `test/differential/detail.test.ts`;列表页样本 `inputs/indexer/rl-audiences-list.html.txt` 进 MP 差分):标准 rowhead 表格,但有几处与通用 NexusPHP 不同,写在定义里。h1 的促销是 `font.free / font.halfdown …`(列表是 `img.pro_*`)加「剩余时间:」和到期 `span[title]`,用 `umt.detail.promo` 覆盖列表的促销字段;h1 的「剩余时间:」文本不进标题,标题区里的 `span[title]` 也不当发布时间(发布时间取「下载」行「发布于」的 span)。没有「同伴」行,做种 / 下载 / 完成数取基本信息行右侧 `#seeders / #leechers / a[title=完成]`(`umt.detail.fields.seeders / leechers / snatched`);种子 Hash 取 `#copyTorrentHashLink[data-hash]`(`fields.infoHash`,回填 `infoHash`)。副标题行右侧有豆瓣 / IMDb / 烂番茄 / Metacritic 评分徽标(`[title*=评分]`),解析时去掉;评分本身本工具没有字段,不收。标签是 `span.detail-tag-list > span.tags`,只取最内层。上传者在「下载」行的 `span.torrent-uploader-wrap`(匿名发布为「匿名」);另有「发布者带宽」一行,所以上传者标签改为精确匹配。简介开头的站点声明 `#ad_torrentdetail` 用 `umt.detail.page.descriptionStrip` 去掉;MediaInfo / BDInfo 是 `div.codetop`(文件名 / BDInfo)+ `div.show`(摘要表或同一份 BDInfo)+ `div.hide > div.codemain`(原文),三者都从简介里去掉;简介是 `<br />\r\n`,源码换行不再算一行(以前每行之间多一空行)。截图排除豆瓣海报(站点经 `dbimgs.audiences.me/?…doubanio.com…poster` 代理)与站点横幅 `/banners/`。IMDb / 豆瓣 ID 取简介里的链接;TMDB 页面上没有;单种 H&R 标记页面上没有(列表也没有),保持「未知」。文件清单不在页面上(点「N个文件」后 AJAX 加载,端点未核对),暂不另发请求。列表副标题改取 `span.torrent-subtitle-text`(`span.torrent-subtitle-line` 里先是标签,以前副标题前面会粘着「官方中字DIY…」)。
- 截图代理的 Referer:截图与站点同根域(憨憨图床 `pic.hhanclub.net`、`proxy-cover.hhanclub.net`)时带 `Referer: <站点地址>/`(防盗链),外部图床(如 pixhost)不带;任何情况都不带 Cookie。
- 解析:`packages/adapters/src/sites/spider/detail-page.ts`。简介取 MovieClaw `detail_description_css`(缺省 `#kdescr`);MediaInfo 取定义 `umt.detail.page.mediaInfo` → 「MediaInfo」字段标签 → 简介里第一个像 MediaInfo 的引用 / 代码块;文件清单取 `umt.detail.page.files` → MovieClaw `detail_file_list_css` → 「文件列表」字段 → `#filelist` 表格,默认不额外请求;定义配置了 `umt.detail.page.fileListPath` 且页面没有内联清单时,按配置补请求(憨憨为 `viewfilelist.php`)。字段标签除 `td.rowhead` 外可在 `umt.detail.page.labelSelectors` 加(憨憨:`div.font-bold.leading-6`、`span.font-bold`)。`page.probe` 记录页面上找到的字段标签,只存缓存不展示,站点改版时用来排查。馒头从 API 的 `descr`(BBCode)与 `mediainfo` 取同样的结构。
## Agent 能力(W10-pre2)
处理器在 `apps/server/src/modules/sources-identity/capabilities-sites.ts`,调用上面同一组 service。能力 ID 只允许小写 + 下划线(`CapabilityId` 正则),所以规格里的 `site.healthCheck` / `site.refreshLogin` / `site.userData` 落为下划线形式。
| 能力 | 类别 | 风险 | 重放 | HTTP scope | 说明 |
|---|---|---|---|---|---|
| `site.list` | read | low | safe | read | 站点健康、`lastHealth`、整体账号状态与账号摘要;缺省只列启用站点(`includeDisabled`、`keys` 可选) |
| `site.health_check` | execute | low | safe | admin | 同 `POST /sites/{id}/health-check`;幂等,只读首页并写回健康列,所以例外地声明 replay safe |
| `site.refresh_login` | execute | medium | never | admin | 同步执行 `refreshSiteAccount(mode=login)`,上限 30 s(同时跟随任务取消);`accountId` 缺省取第一个启用的非 passkey 账号 |
| `site.user_data` | read | low | safe | read | 各账号最近一次用户数据 + `site_user_data` 按天历史(`days` 缺省 30,最多 400) |
- 授权:两个 execute 能力走通用授权,Agent / API Key / 后台调用必须有 `automation_grant`(`action` 为能力 ID 或 `*`),自动化总开关关闭或只读对账时一律拒绝;控制台管理员从 Web 直接调用视为人工授权。没有计划可绑定,所以不走单次审批。
- 脱敏:输出按白名单逐字段拼装,没有 `secretRef` / `sessionSecretRef` / `passkeySecretRef`、Cookie、密码、登录名、UA 与站点 `config`;凭证只给 `hasSession` / `hasPasskey` 两个布尔。测试 `apps/server/test/sources-identity/site-capabilities.test.ts` 逐项断言。
- 注册数:这组站点能力最初将能力集由 17 扩到 21;当前完整能力集为 41,启动断言 `assertCapabilityCount` 跟随 `CAPABILITY_IDS.length`。
- Agent 默认只读白名单(`agent/runner.ts`)已包含 `site.list` / `site.user_data`;`agent/invoke-limits.ts` 的能力级站点并发上限尚未纳入 `site.health_check` / `site.refresh_login`,底层站点访问仍走站点限流。
## 出口:哪些请求走哪条(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/`),或页面请求返回 200 的挑战页 / 人机验证门页(见下「搜索页人机验证」)
→ `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/vision.test.ts`(系统发起的视觉子流程)。真机:未对真实站点验证(CF-2 只用 mock);插件鼠标点击见 browser.md §13。
**要人处理时**:站点页 Cloudflare 行显示「需要你验证」→ 原站视图接管 → 过验证 → 交还。一直过不去(站点绑定 TLS 指纹、代理 IP 被拉黑):
关掉站点代理或换出口;实在不行关闭该站点的定时快照,只保留 RSS。
### 搜索页人机验证(200 状态的挑战页)
现象(2026-10-06,观众):只有搜索页单独加了盾、实际搜索时间歇触发,列表快照与健康检测都正常;应用当时报
`parse_error: 搜索:页面结构无法识别(可能改版)`,事后重搜又好了,触发那一刻的页面没留下。原因是挑战 / 人机验证页可能以 **200** 返回,
以前只看响应头和 403 / 503 / 429,这类页面落到「页面结构无法识别」,级联完全没触发。现在分两层:
1. **网络层**(`challenge.ts detectChallenge`,经 `cloudflare.ts handle` 与 `http.ts siteFetch`):页面请求(GET + Accept `text/html`)
的 2xx HTML 响应也看正文,只先读 256 KB(`CHALLENGE_PEEK_BYTES`,其余照常流给解析器;非页面请求不读)。判定要同时满足:
正文已读完且不超过 10 万字符、没有站点正常内容(`id="torrenttable"`、`table.torrents`、`logout.php`、`userdetails.php?id=`、
`usercp.php`、`#info_block` 任一出现就不算),再命中 Cloudflare 挑战标题 / 脚本,或人机验证组件(Turnstile:`cf-turnstile`、
`challenges.cloudflare.com/turnstile`;hCaptcha;reCAPTCHA)且没有登录表单(`type=password` / `takelogin.php`,登录页带 Turnstile
仍按登录失效处理)。命中即进入上面的 A→B 级联(过挑战 → cf_clearance 回带重试 → 浏览器代抓)。
2. **适配器兜底**(`spider/adapter.ts`):列表 / 搜索 / 详情认不出结构、即将报「页面结构无法识别」前,用
`challengeRuleOfUnrecognizedPage` 再看一次(不限长度、不看站点内容;也认页面文字里的「人机验证 / verify you are human」),
像就报 `challenge`,不报改版。这类页面级联没跑过,文案提示去原站视图过一次。
错误文案(搜索覆盖、快照错误里能看到):
- 人机验证:`搜索:搜索页需要人机验证(Cloudflare 等),<处理结果>`。处理结果来自级联打在挑战响应上的 `x-umt-challenge` 头:
「自动处理没能通过,需要你在原站视图里过一次验证」(needs_human)、「已自动尝试用浏览器通过,没有成功」(failed)、
「30 分钟内已自动尝试过,之后会再试」(throttled)、「没有可用的浏览器,无法自动通过」、「站点没有已登录的账号,不自动处理」;
适配器兜底认出的是「这类页面没有自动处理,可以在原站视图里过一次验证」。
- 页面结构变化:`搜索:页面结构无法识别(可能改版)(页面样本见站点页技术信息)`。
### 失败样本在哪看
站点请求报 `parse_error`(页面结构无法识别 / 响应为空)或 `challenge` 时,适配器交出一份脱敏页面样本(`SiteAdapterDeps.onFailureSample`),
`runtime.siteAdapter` 接到后异步写进表 `site_failure_sample`(迁移 0077),**每站只留最近 5 份**,写失败只记日志。覆盖所有走
`siteAdapter` 的调用:作品页 / 搜索页的搜索、列表快照、详情刷新、健康检测(首页遇挑战)。目前只有 NexusPHP 通用 spider 适配器留样。
- 内容:请求类型(search / list / detail / health)、类型(parse_error / challenge)、脱敏 URL、状态码、响应头 `server` / `cf-mitigated` /
`cf-ray` / `content-type`、错误文案、命中规则、时间、页面前 64 KB(超出标「只留了开头」)。
- 脱敏(`packages/adapters/src/sites/failure-sample.ts redactPageHtml`):整块删掉 `#info_block`(当前用户的用户名、分享率、魔力);
URL 凭据参数(passkey / authkey / rsskey / token / key …)与 uid 参数、`userdetails.php?id=` 的 id 与链接文字、「UID 12345」、
Cookie 名值(`c_secure_*`、`cf_clearance`、`PHPSESSID` …)、表单 / 脚本里的密钥字段、邮箱、32~64 位十六进制串(passkey 形态)。
- 看:站点页 → 概览 →「站点」一栏的「技术信息」→「最近的失败页面」,展开看请求信息与页面,可「下载文本」;
接口 `GET /api/v1/sites/{id}/failure-samples`(不带正文)、`GET /api/v1/sites/{id}/failure-samples/{sampleId}`(带正文)。
- 排查:类型是人机验证、页面是 Turnstile / 挑战页 → 站点加了盾,看 Cloudflare 行与级联记录;类型是页面结构无法识别、页面是正常的
种子页 → 站点改版,按样本改站点定义;页面是站点自己的提示页(如「搜索太频繁」)→ 看站点访问默认与限流。
**测试**:`packages/adapters/src/sites/challenge.test.ts`(200 挑战页 / 门页、正常搜索页 / 列表页 / 带 Turnstile 的登录页不误判、
读取上限)、`spider/challenge-page.test.ts`(搜索页 / 列表页兜底、登录页不受影响、parse_error 留样脱敏)、`failure-sample.test.ts`、
`apps/server/test/sources-identity/cloudflare.test.ts`(200 门页过挑战重试、200 挑战页走浏览器代抓、转人工时留样)、
`failure-sample.test.ts`(留样、只留 5 份、接口)、`apps/web/app/components/sites/site-failure-samples.test.ts`。
正常搜索页 fixture `packages/adapters/test/fixtures/audiences-search.html.txt`(观众真实搜索页,脱敏、删减到 4 行)。
## 待核验
1. takelogin 表单字段、二次验证字段名、错误文本全部只对 `@umt/testkit` mock 站点验证过,未连接真实站点。
2. 失败文本归类是关键词匹配(`classifyLoginError`),真实站点的措辞可能落到 `unknown`(状态记为 expired,原文截 200 字写进 `last_error`)。
3. Cloudflare:识别与级联见上「Cloudflare(CF-2)」,只对 mock 验证过;真实站点的挑战页类型、cf_clearance 能否给 HTTP 复用(是否绑 TLS)
按站点记 `cfReuse` 在线验证。账密登录遇挑战仍不做浏览器登录。
4. 馒头用户数据:`MTorrent` 解析器的做种列表首页按 GET 请求,真实 API 可能要求 POST。