- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
adapters/rss
RSS 2.0 / RSS 1.0(RDF) / Atom 抓取与解析,按 MoviePilot e6820a8 app/application/rss.py(RssHelper._parse_impl / get_rss_link)迁移,再叠加任务书的安全、条件请求、退避、熔断、脱敏与去重。仅对 @umt/testkit 的 mock RSS 服务器验证,未连接真实站点 Feed。
能力
| 能力 | 入口 | 说明 |
|---|---|---|
| 安全解析 | parseFeed(xml, {maxItems, maxChars}) |
fast-xml-parser 关闭实体处理;预检拒绝 <!ENTITY 与内部 DTD 子集(XXE、billion laughs);只解码 XML 预定义实体与数字引用;条目数超限截断并标 truncated |
| 抓取 | createRssFetcher(opts).fetchFeed(url, state) |
条件请求(ETag / Last-Modified → 304)、解压后字节上限(同时约束 gzip/br 解压量)、超时、指数退避 + Retry-After(秒数或 HTTP-date,取较大者)、熔断(连续 N 次失败 → open,resetFeedCircuit 手动恢复,可选冷却后半开探测) |
| 状态 | FeedFetchState |
纯数据,由调用方持久化(feed_source);fetchFeed 不抛业务错误,返回 ok / not_modified / skipped / error |
| 脱敏 | redactUrl / redactText / unredactUrl / extractSecretParams |
query 中 passkey/token/authkey/sign/apikey 等替换为 [REDACTED],userinfo 抹除;所有错误消息与 lastError 已脱敏 |
| 去重 | applyFeedItems(cursor, items) |
重叠窗口游标 FeedCursor{lastGuidSet, digests, lastFetchedAt};GUID 新增 → newItems,同 GUID 摘要变化 → updatedItems;乱序/置顶不产生新条目;与上次窗口零重叠 → possibleGap(调度补漏) |
与 MP RssHelper 的对应
| MP | 本项目 |
|---|---|
.//item | .//entry 全树收集,MAX_RSS_ITEMS=1000 |
同;超出截断并标 truncated |
MAX_RSS_SIZE=50MB、非 < 开头拒绝、resolve_entities=False |
同(按解压后字节流式限制);另外预检拒绝 <!ENTITY 与内部 DTD 子集 |
| title 为空跳过;description|summary;link 文本或 href;enclosure@url,缺失回退 link,两者皆无跳过;enclosure@length 纯数字才取;pubDate|published|updated;creator → nickname | 同(字段见下) |
| 非 200 返回 False;过期提示返回 None | FeedFetchResult.kind='error';过期提示 → FeedError('expired') |
rss_link_conf / get_rss_link |
rss-link.ts:buildRssLinkRequest / extractRssLink(只含 default 与 audiences.me) |
| —(MP 无) | guid、categories、rawDigest、条件请求、退避、Retry-After、熔断、脱敏、重叠窗口去重 |
FeedItem
{guid, title, link, enclosureUrl?, enclosureHasSecret?, size, publishedAt?, nickname?, categories[], description?, rawDigest, fetchedAt}
guid/link/enclosureUrl均为脱敏形态,可直接入库与作为去重键;passkey 轮换不改变 GUID 与摘要。- 需要真实下载链接时,由调用方从 secret store 取 passkey,用
unredactUrl(enclosureUrl, {passkey})在内存中还原,结果不得记录。 publishedAt是来源发布时间,fetchedAt是抓取时间,二者不可混用(REQ §4.3)。
默认值
超时 30s、响应上限 50 MiB、条目上限 1000(同 MP)、退避 60s 起 ×2 封顶 6h、熔断阈值 5、熔断默认只能手动恢复。均可在 createRssFetcher 覆盖;真实站点默认值待联调后确定。