Files
UHD_Media_Tool/docs/runbook/sites.md
T
rootandClaude Opus 5.5 901b01d1a1 fix(sites): HHClub parsing, resource list layout and request restraint
HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).

Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
  rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
  name / H&R with null because the HHClub div detail page lacks them;
  detail values now fall back to the list snapshot (mergeDetailOverList),
  and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
  HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.

Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
  (longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
  defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
  burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
  concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
  health checks skip sites checked within the interval; detail refresh is
  served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
  (site_request_count, migration 0046); GET /sites/{id}/request-stats;
  site overview and resource list show requests in the last 24 hours

Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.

Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).

Takes effect after the stack is rebuilt (migration 0046).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:18:15 +08:00

13 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。

端点

端点 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)添加

  • 站点内添加(站点详情「快照与 Feed」→ 新建 Feed,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(components/workbench/feed-form.tsx):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 X · 不关联」,手动改过站点后不再自动改。

定时任务

key 名称 默认 行为
sites.user_data 站点数据刷新 每 6 小时,一个周期后首跑 启用站点里 status=active 的非 passkey 账号逐个入队 refreshSiteAccount{mode:"userData"};会话失效的账密账号自动重登一次;只读对账模式跳过
sites.health_check 站点健康检测 每小时扫描,一个周期后首跑 启用站点(非 generic_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 固定桌面 Chrome UA 账号未设 UA 时用于登录与用户数据
proxyUrl null 站点 proxy=true 时,适配器、登录、用户数据请求都经此代理(Deno.createHttpClient)
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)。

每从站点限流器拿到一个令牌(列表、搜索、详情、下载、登录、用户数据、原站视图导航)记一次请求,写 site_request_count(迁移 0046,按小时聚合,多进程共用,保留 7 天)。GET /sites/{id}/request-stats(read)返回最近 24 小时总数、按小时明细、快照实际周期与合并后的访问默认;站点页概览「近 24 小时请求」与资源页列表头显示。计数只在升级后开始累计。

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,启动断言 assertCapabilityCount 跟随 CAPABILITY_IDS.length。
  • 未接:agent/invoke-limits.ts 的站点并发上限还没把 site.health_check / site.refresh_login 算作外部站点调用;Agent 默认只读白名单(agent/runner.ts)也还没加 site.list / site.user_data。两处都在 agent 模块,留给该模块的负责人。

待核验

  1. takelogin 表单字段、二次验证字段名、错误文本全部只对 @umt/testkit mock 站点验证过,未连接真实站点。
  2. 失败文本归类是关键词匹配(classifyLoginError),真实站点的措辞可能落到 unknown(状态记为 expired,原文截 200 字写进 last_error)。
  3. Cloudflare 站点只识别并转待处理,不做浏览器登录。
  4. 馒头用户数据:MTorrent 解析器的做种列表首页按 GET 请求,真实 API 可能要求 POST。