豆备 DOUBAK
← 开发日志

舞台剧封面的漏网之鱼,与全链路多账号防线审计

用 3.2 万条真实记录跑完爬虫、解析、多端导出与站点生成的完整闭环后,我们抓出了一张漏网的舞台剧封面;更重要的,是对三套 CLI 工具在面对多账号数据时的碰撞、铁律阻断与协同缺陷做了一次彻底的体检,并完成全链路多账号隔离与子目录递归的落地与实测验证。

在软件工程里,合成数据和单元测试总是温文尔雅的。 字段排列整齐、字符编码干净、URL 格式永远标准规范。 但在真实世界里,豆瓣这个走过二十余年沧桑的网站, 它的每一处旧角落下都沉淀着历史的沉积岩。

今天,我们组织了一场覆盖全生态工具链的端到端大考: 从浏览器抓取扩展(doubak-extension)、规范解析器(doubak-data-parser)、 跨平台导出适配器(doubak-export-adapters)到离线静态站点生成器(doubak-site-generator)。

投入测试的两份真实档案,构成了绝佳的对照组:

数据集账号 UID / 昵称归档模式规模与特征
exports/ 82160871(MewX) 本人全量归档(skip_catalog: false) 34 份 Bundle、852 MB WARC、30,911 条捕获;横跨 15 年,含书影音游、日记(豆瓣私密+自定义私密)、长文、相册、豆列、加入小组与头像。
exports_3rd/ 154611037(三岐闻) 第三方公开轻量(skip_catalog: true) 3 份 Bundle、27 MB WARC、1,167 条捕获;跳过详情页快速抢救,包含图书、电影、早年舞台剧、广播、长文与豆列。

第一幕:一张舞台剧封面的奇幻漂流

在对第三方档案 exports_3rd/ 进行解析时,一条 2012 年的标记吸引了注意: 那是百老汇经典音乐剧《妈妈咪呀!》(舞台剧条目 ID 10944605)。

令人困惑的是:在生成的离线网页中,所有的图书和电影海报都完好无损地显示在本地, 唯独这部音乐剧的封面,在卡片上是一片空白。

翻看 WARC 捕获记录与爬虫规则,谜底瞬间解开:

  • 豆瓣常规电影的海报路径形如:img9.doubanio.com/view/photo/s_ratio_poster/public/p...jpg
  • 常规图书与唱片的封面路径形如:img1.doubanio.com/view/subject/m/public/s...jpg 或 .../spic/...
  • 但早期的舞台剧专属海报,豆瓣服务器给出的路径却是:
    https://img9.doubanio.com/pview/drama_subject_poster/small/public/8972fded86c3674.jpg

在扩展的爬虫过滤正则中,过去的匹配模式是:

/(?:img\d*\.doubanio\.com\/(?:view\/(?:subject|photo)|[blsp]pic)\/[^"'\s\)]+)/i

它只认 view/,却把以 pview/drama_subject_poster 起首的舞台剧图片, 当成了不相关的网页杂质,悄无声息地拒之门外!

我们在扩展的图片发现逻辑中迅速将正则拓宽为:

/(?:img\d*\.doubanio\.com\/(?:view\/(?:subject|photo)|pview\/(?:subject|photo|drama_subject_poster)|[blsp]pic)\/[^"'\s\)]+)/i

并为无论是详情页还是轻量列表页的舞台剧缩略图补充了完整的测试用例。

但比修补正则更让我们感到欣慰的,是下游工具的韧性与优雅降级: 面对这份在修复前就已抓取完毕、本地 WARC 确实缺了舞台剧封面的既有旧包, 站点生成器在从 WARC 抽取图片未果后,自动将该条目的 douban_cover 降级为 null, 无声地呈现为一个优雅的纯文本排版卡片。 整个生成过程零崩溃、零未捕获异常、零非零退出码。 这正是离线归档工具最宝贵的品质:不因任何一张图片的缺席而毁掉整座图书馆。

第二幕:端到端四步畅行——真实的 3.2 万条记录

在单账号正常流程下,两份数据集顺畅地跑通了全生命周期:

1. 解析器(doubak-data-parser)

面对 852 MB 的庞大历史归档,解析器在 10.3 秒内完成了对 67 个 WARC 段文件、30,911 条记录的逐字节 SHA-256 完整性检验。 在拓扑图分析中,它自动识别出用户在不同年份备份形成的 6 处分叉与重叠包,剔除重复段,平滑串接起 2,992 条标记、3,470 条广播、6 篇长文与 34 个加入小组,并精确萃取出第九文件 profile.ndjson。

2. 跨平台导出器(doubak-export-adapters)

在导出至 NeoDB、Letterboxd 与 Goodreads 时,各个适配器展现了极高的辨识敏锐度:

  • NeoDB NDJSON:精准还原了 2,577 条广播状态演变历史;区分出 1 篇豆瓣锁闭日记(按公开导出)与 1 篇用户自定义私密日记(按 visibility: 2 仅提及者可见导出);
  • 针对轻量模式的防御:在处理未抓取详情页的 exports_3rd/ 时,NeoDB 适配器打印出清晰警示:417 条没读到详情页,分不出电影还是剧集,按电影处理;而 Letterboxd 适配器则拒绝猜测,坚决不把未知影视强行导出为电影,将它们全部列入 check 清单,杜绝污染用户的外部观影记录。

3. 站点生成器(doubak-site-generator)

在本地离线环境下,分别生成了 3,182 个页面与 528 个页面。 从 WARC 中解压出 3,132 张与 451 张离线图片,两份站点首页与全站标题准确呈现为《MewX的豆瓣存档》与《三岐闻的豆瓣存档》,头像多尺寸栅格化与客户端搜索索引(5,286 条,473 KB)一气呵成。

第三幕:撞击测试——当多账号档案放在同一个文件夹

如果一位用户在日常使用中,把父母的归档、朋友的归档和自己的归档,随手丢进了同一个 exports/ 根目录,会发生什么?

我们将两份完全属于不同 UID 的档案混在一个目录下,对三款 CLI 工具发起了一场破坏性撞击测试(Chaos Test)。 结果揭示出了极有意思的防御断层:

1. Parser 的守门员铁律:Fail-Fast

运行 node bin/parse.js /mixed/bundles/ /out,控制台瞬间亮起红灯:

✖ 这个目录(含子目录)里混着 2 个账号的档案(154611037、82160871)。
  一起解析会把它们合进同一份 canonical,而且事后分不开。
  请分开放,或者确认它们真的属于同一个人之后加 --ignore-warnings。

解析器果断以退出码 1 退出,没有在磁盘上写下半个受污染的字节。 只有当运维人员明确知晓并附带 --ignore-warnings 时,才允许合并不阻断,但依然在输出报告中记录 multiple_accounts: 1。

2. Site Generator 的审美洁癖:拒绝生成双头怪兽

如果不小心把一份包含了两个 UID 的 Canonical 目录喂给站点生成器, 哪怕你在命令行里强行传入 --site-title="家庭合集",它依然会在启动时立即崩溃退出:

Error: 输入中有多个账号(154611037、82160871)。请按账号分开生成站点。
    at resolveSiteTitle (...)

它坚信:一个静态站点只能对应一个灵魂。如果混入了两个人的标记与动态, 那么头像算谁的?个人主页链接算谁的?书影音统计又算谁的? 坚决不妥协。

3. Export Adapters 的沉默盲区:防御链上的缺口

令人意外的是,当我们将这份混合 Canonical 喂给 doubak-export-adapters 时, 导出器却毫无感知、毫无报错地一路绿灯跑完。

它默默取了第一条记录命中的账号,将两人的观影历史全部打包塞进了同一个 NeoDB NDJSON 和 Letterboxd CSV 里! 如果用户把这个文件导入 NeoDB,另一个人的上千条观影与短评就会直接覆盖、污染他的真实账号。

4. 统一过滤参数的缺失

更进一步,当我们在三个 CLI 工具中尝试输入 --account 82160871,希望「只处理该目录下的这一个人」时, 现实同样骨感:

  • doubak-data-parser 报 Exit Code 2:不认识 --account 开关;
  • doubak-export-adapters 报 Exit Code 2:不认识 --account 开关;
  • doubak-site-generator 虽然在参数解析器里有 --account 的占位符,但根本没有接驳到底层过滤逻辑,依然被多账号校验当场拦截。

换句话说,当前整个生态在多账号安全上,完全依赖于用户在文件系统层面肉身维护目录隔离。 一旦目录混杂,CLI 工具既无法安全指定提取,也无法优雅协同。

第四幕:推荐的修改方案

为抹平三端 CLI 的防御差异,并让豆备在多账号备份与自动化流水线中游刃有余, 我们提出以下四步演进方案:

第一步:补齐导出端的 Fail-Fast 前置拦截(即时防线)

在 doubak-export-adapters 的 loadCanonical 函数中, 读取各 NDJSON 时统一搜集 account.user_id 集合。 一旦发现存在多个不同 UID 且未指定单账号,立刻以退出码 1 阻断:

✖ 输入的 canonical 目录中包含多个账号的数据(154611037、82160871)。
  直接导出可能导致第三方平台(NeoDB/Letterboxd/Goodreads)数据交叉污染。
  请使用 --account <uid> 指定账号,或先按账号分别生成 canonical。

使导出器与解析器、站点生成器保持同一安全水位。

第二步:统一打通全链路 --account <uid> 过滤器

将 --account <uid> 确立为三大核心 CLI 的一等公民参数:

  • doubak-data-parser: 扫描输入目录下的所有 bundle 时,按 manifest.account.user_id 进行预先过滤。 若指定了 --account 82160871,则自动忽略目录下的其他账号 bundle,仅用目标账号的包构建拓扑图并完成解析。 若目录中未找到该账号,给出包含已有账号列表的友好报错并退出。
  • doubak-export-adapters: 支持 --account <uid>。加载 Canonical 时仅过滤保留匹配该 UID 的 marks、broadcasts 与 profile 记录,从而允许用户直接从混合 Canonical 中安全抽离单账号导出。
  • doubak-site-generator: 真正启用 --account <uid>。在内存中对 Canonical 记录和 bundles 图片索引进行预过滤,使多账号混合目录可以直接被单账号站点生成命令消费。

第三步:支持全自动批量分治输出(--all-accounts)

针对需要一次性备份全家或多个账号的脚本化场景, 提供 --all-accounts 选项。 工具检测到多账号时不再报错,而是自动按 UID 分流:

  • Parser 写入:<out_dir>/<uid>/canonical/
  • Export Adapters 写入:<out_dir>/<uid>/exports/
  • Site Generator 写入:<out_dir>/<uid>/site/

省去用户编写 Shell 循环与字符串拼接的繁琐心智负担。

第四步:爬虫封面识别的前缀白名单泛化

为彻底避免类似舞台剧封面的漏网之鱼再次发生, 将扩展爬虫中对封面域名的匹配规则从枚举模式改为安全通配:

/(?:img\d*\.doubanio\.com\/(?:view\/(?:subject|photo)|pview\/[a-z_]+|[blsp]pic)\/[^"'\s\)]+)/i

对豆瓣未来可能采用的诸如 music_subject_poster、播客专属海报等新路径形成天然防御。

第五幕:落地成兵——全链路三阶段改造与深层子目录大考

审计报告出炉后,我们没有让方案停留在纸面上, 而是立即展开了对三套工具链的代码重构、契约同步、单测补全, 并用真实的 exports/ 与 exports_3rd/ 进行了全量破坏性与自动化隔离验证。

1. 三阶段核心改造与子目录递归落地

  • Stage 1:对齐导出端 Fail-Fast 铁律
    在 doubak-export-adapters 的 loadCanonical 与 export.js 中引入多账号识别。 当 Canonical 包含多个 UID 且未显式指定过滤时,立即以退出码 1 阻断, 彻底终结了导出端可能将多人记录混合写入 NeoDB、Letterboxd 或 Goodreads 的安全隐患。
  • Stage 2:统一全链路 --account <uid> 隔离过滤器
    三大 CLI 工具全量打通单账号提取能力:
    • doubak-data-parser:拓扑分析前按账号预过滤 bundle,同时智能沿 previous_bundle_id 与 floor_from_bundle_id 回溯保留链条中的无清单祖先包;
    • doubak-export-adapters:纯内存高效过滤各类 NDJSON 实体,支持单账号安全导出;
    • doubak-site-generator:过滤 Canonical 实体与原始图片索引,使混合数据也能直接生成纯净的个人专属站点。
  • Stage 3:全自动批量分流(--all-accounts)与深层子目录递归感知
    针对用户常见的「多层子文件夹」与「批量自动化」诉求进行深度升级:
    • 子目录递归与环路防护:将 doubak-site-generator 中的图片扫描(indexImages)重构为完整深层递归,引入 realpathSync 防软链接死循环,排除 .git 与 node_modules 噪音,并在命中 index-*.ndjson 时立即止步,绝不无谓下钻 WARC 段;
    • 自动探查 Canonical 目录:在 export-adapters 与 site-generator 中引入 findCanonicalDirs,不论 Canonical 是位于当前目录还是分散在各个子目录,工具均能自动发现;
    • --all-accounts 批量分流:三大 CLI 检测到多账号时不再报错,而是自动将产物隔离写入 <out_dir>/<uid>/(分账号 Canonical、分账号导出包、分账号独立站点)。

代码改动完成后,全生态单元测试套件全部扩充并跑通: 新增了 cli-account.test.js、multi-account-export.test.js 与 multi-account-and-nested-bundles.test.js。 全项目 2,123 个单元测试全量 PASS(2,122 通过、1 跳过、0 失败),扩展端同步的 Vendor 纯函数逐字节对齐。

2. 真实归档大考实测数据

我们构造了一个同时包含 MewX 本人 15 年全量归档(含深层嵌套的 20260806/ 子目录,共 34 个包、852 MB WARC、30,911 条捕获)与三岐闻第三方归档(3 个包、27 MB WARC、1,167 条捕获)的复杂混合目录, 对全链路进行了实地大考:

测试环节与场景执行命令实测指标与运行结果防线状态
Parser 混合阻断 parse.js <mixedDir> <out> 扫描 74 个段(879 MB)、32,078 条捕获并完成完整性校验;精准检测到混有 154611037 与 82160871 两账号;退出码 1 阻断,磁盘零写入。 ✅ 成功 Fail-Fast
Parser 单账号隔离 parse.js ... --account 154611037 仅提取三岐闻 3 份档案(439ms),产出标记 448 条、作品 448 个、广播 461 条、长文 2 篇、豆列 2 份、小组 4 个,零串号。 ✅ 完美隔离
Parser 深度子目录隔离 parse.js ... --account 82160871 成功穿透 mewx_exports/20260806/ 嵌套子目录,合并 34 份档案(19s),产出标记 2,992 条、广播 3,470 条、长文 6 篇、相册 1 个、小组话题 3 篇。 ✅ 完美隔离与递归
Parser 批量分流 parse.js ... --all-accounts 自动识别两账号,顺序分流解析并输出至 canonical_all/82160871/ 与 canonical_all/154611037/ 独立子目录。 ✅ 自动化分治
Export 混合阻断 export.js <mixedCanonical> <out> findCanonicalDirs 自动探测到多个 Canonical 归档子目录,退出码 1 阻断,打印防污染警告。 ✅ 成功 Fail-Fast
Export 单账号导出 export.js ... --account 154611037 自动定位三岐闻子目录,成功导出 NeoDB(标记 448 条、历史 385 条)、Letterboxd(417 条未知影视进 check 防污染)与 Goodreads(30 本图书)。 ✅ 导出纯净
Export 批量分流 export.js ... --all-accounts 自动分流导出至 exports/82160871/ 与 exports/154611037/,各自包含完整的平台导入包与《怎么导入.md》。 ✅ 批量分流成功
Site Gen 混合阻断 generate.js <mixed> <bundles> <out> 自动识别多子目录与混合 UID,退出码 1 阻断,拒绝生成混乱站点。 ✅ 成功 Fail-Fast
Site Gen 单账号+深度图片 generate.js ... --account 82160871 成功从 20260806/ 深层子目录中递归检索出全部图片,生成 3,182 页、提取 covers 2,964 张与 uploads 168 张,站名与头像(up82160871-12.jpg)精准无误。 ✅ 深层图片全量捕获
Site Gen 批量分流 generate.js ... --all-accounts 全自动批量构建,在 site_all/82160871/(3,182 页)与 site_all/154611037/(528 页)分别生成两个主题完整、头像独立、互不干扰的静态站点。 ✅ 批量站点生成
Flat 混合数据容灾 对单目录手工拼合的混合 NDJSON 文件测试 针对极端的混合 canonical 文件,Fail-Fast、--account 与 --all-accounts 记录级内存过滤依然 100% 生效,无数据穿透。 ✅ 极端容灾通过

尾声:守住数据的边界与纯粹

离线数据备份是一项长跑。

一个优秀的归档系统,不仅要能在顺风顺水时跑得飞快、把网页和海报一张不落地点缀在本地; 更要在面对错综复杂的目录、历史遗留的异构格式以及多人混杂的数据时, 拥有守住数据边界、绝不越界制造垃圾数据的清醒与底线。

从那张漏网的百老汇舞台剧海报,到多账号防线的一体化演进, 豆备正在从「能用」,一步步走向真正的「可靠与严密」。

← 更新:破解「载入中...」:算力证明挑战、凭据隔离暗坑,与 417 次无声通关 更早:从一个名字到一张脸:账号头像的 WARC 归档与纯离线安全栅格化 →