# 站点运行手册(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=` → 查所属站点后打开该 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:`,subject `site_account`。选项 `use_cookie`(input 带 siteId / accountId,前端深链到凭证对话框)、`retry`(仅 challenge;选中后入队重新登录)、`dismiss`。更新凭证或登录成功时关闭该事项和 `site-auth:`。 ## 刷新登录流程 - **账密**(仅 `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 改为 `-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`(原文),三者都从简介里去掉;简介是 `
\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。