Files
UHD_Media_Tool/docs/runbook/sites.md
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

32 KiB
Raw Blame History

站点运行手册(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/)→ 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:识别与级联见上「Cloudflare(CF-2)」,只对 mock 验证过;真实站点的挑战页类型、cf_clearance 能否给 HTTP 复用(是否绑 TLS) 按站点记 cfReuse 在线验证。账密登录遇挑战仍不做浏览器登录。
  4. 馒头用户数据:MTorrent 解析器的做种列表首页按 GET 请求,真实 API 可能要求 POST。