- RSS: split NexusPHP "[category]name[subtitle][size][uploader]" titles in the site adapter layer (category id/name/media type, clean title, subtitle, size); snapshot fields win on merge; lazily renormalize old RSS rows and re-emit release.observed; unknown list fields show an em dash - Detail: HHClub section layout (douban info block as description, screenshots section, IMDb/TMDB from MediaInfo lines, upload time field, on-demand viewfilelist.php once); site CDN screenshots get a Referer - Site request count now includes site-bound RSS fetches - Origin view shows "自动化" when idle and "Agent" only after recent automation actions (lastAutomationAt); agent step titles no longer get squeezed to a sliver by long notes Co-Authored-By: Claude Opus 5.5 <[email protected]>
132 lines
18 KiB
Markdown
132 lines
18 KiB
Markdown
# 站点运行手册(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 条目规范化(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`):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 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)。
|
||
|
||
每从站点限流器拿到一个令牌(列表 / 快照、搜索、详情、详情的文件清单、下载、健康检测、登录、用户数据、原站视图导航)记一次请求;挂站点的 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` 同一路径。列表快照、搜索不抓详情。
|
||
- 缓存:内容放 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,豆瓣只用于本地召回。是否查 TMDB 仍按惰性识别规则(DEC-1),外部 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`:标签名称与顺序来自真实页面,嵌套按已验证的标题 / 副标题结构推定,部署后打开一条憨憨资源的详情抽屉核对一次。
|
||
- 截图代理的 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` 表格,不额外请求 `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,启动断言 `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。
|