- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀, w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名 - vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行); pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s - CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争) - ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单 Co-Authored-By: Claude Opus 5.5 <[email protected]>
32 KiB
站点运行手册(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)→ GETindex.php,loginSuccess(缺省a[href*="logout"])命中即成功。Cookie 存session_secret_ref(purposesite_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):NexusPHPtorrentrss.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 段方括号,只有出现大小段时才把它前面一段当副标题、后面一段当上传者;去掉「[新]」。大小优先用 enclosurelength(精确字节),没有才用标题里的显示值。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_cacheregionsite.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 次(走同一限流器、计数),失败只是没有清单。fixtureHHANCLUB_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。简介取 MovieClawdetail_description_css(缺省#kdescr);MediaInfo 取定义umt.detail.page.mediaInfo→ 「MediaInfo」字段标签 → 简介里第一个像 MediaInfo 的引用 / 代码块;文件清单取umt.detail.page.files→ MovieClawdetail_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/)→ SiteError("challenge")。不是凭据错误:快照 / 详情不调
onAuthError、不置账号失效、不排自动重登;健康检测记 challenge(degraded);刷新登录 / 用户数据遇挑战只写 last_error,账号状态不变
(验证码、二次验证仍是账号状态 challenge)。
级联(站点请求经 siteFetch 自动走,调用方无感):
- 站点在浏览器代理期(
config.cloudflare.browserUntil未到)且请求是页面(GET + Accepttext/html)→ 直接由浏览器代抓,不发 HTTP; - 发 HTTP;满足复用条件时带上浏览器拿到的
cf_clearance(__cf_bm不带); - 遇挑战 → 记
lastChallengeAt/lastRule;只处理有登录态账号的站点(第一个启用、未失效、确有 Cookie 的 Cookie / 账密账号); - 过挑战:同站 30 分钟最多一次(
lastAttemptAt条件更新,跨进程)、进程内同一时刻只跑一次,浏览器用该账号 profile 打开同一地址: ① 轮询页面等它自己过(≤ 15 秒)→ ② 「我是人类」复选框 / Turnstile:派视觉子流程点一下(系统发起、没有父会话,预算取设置,最多 2 次; 自动化总开关关闭或只读对账时跳过)→ ③ 选图 / 输码类或仍没过:会话转等待(cf_challenge),待处理事项「<站点>需要你在原站视图里过一次验证」, 站点记pending = human。不接任何打码服务; - 通过 → 读回
cf_clearance与navigator.userAgent;只有账号 UA 来源 = 跟随浏览器、UA 相同、两端出口相同(浏览器出口按站点下发成功才算) 时才存进账号(site_account.user_info.cloudflare = {secretRef, userAgent, egress, at},值在 secret 里),HTTP 重试 1 次:过了记cfReuse = ok,仍被挑战记cfReuse = fails(之后跳过复用;UA 或出口变了自动清掉重试); - 仍被挑战 → 页面请求改由浏览器导航取 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。
待核验
- takelogin 表单字段、二次验证字段名、错误文本全部只对
@umt/testkitmock 站点验证过,未连接真实站点。 - 失败文本归类是关键词匹配(
classifyLoginError),真实站点的措辞可能落到unknown(状态记为 expired,原文截 200 字写进last_error)。 - Cloudflare:识别与级联见上「Cloudflare(CF-2)」,只对 mock 验证过;真实站点的挑战页类型、cf_clearance 能否给 HTTP 复用(是否绑 TLS)
按站点记
cfReuse在线验证。账密登录遇挑战仍不做浏览器登录。 - 馒头用户数据:
MTorrent解析器的做种列表首页按 GET 请求,真实 API 可能要求 POST。