本仓库为项目源码。 项目官方主页请访问 https://doubak.com。
豆备(Doubak)的数据解析核心工具。负责将抓取端生成的 bundle(WARC 归档 + 索引 + 元数据清单)转换为结构化的标准 canonical 数据(包含完整的版本修订历史),供静态站点生成器渲染发布,或导出至 NeoDB 等第三方平台。
node bin/parse.js <包含 bundle 归档的目录> [输出目录] [--ignore-warnings] [--no-verify]
node bin/verify.js <包含 bundle 归档的目录> # 仅执行完整性校验,不执行数据解析
npm test # 运行测试套件(使用 Node 内置 test runner,零外部依赖)运行环境要求:Node ≥ 20。
npm test 包含 270+ 个测试用例,全量通过(基于 26 份真实归档样本,耗时约 80 秒)。
package.json 中的 --test-concurrency=2 是经过性能与内存实测量化后的配置:
测试套件中有 4 个测试文件需要分别对真实归档执行全量解析,而 node --test 默认并发度等于 CPU 核心数。在多核环境中高并发解析会导致单进程瞬间吞吐几百兆字节数据,引发系统内存压力导致测试进程被操作系统异常终止(仅返回空泛的 'test failed' 提示,难以区分是断言失败还是资源受限)。因此限制并发度是保障真实归档测试稳定通过的必要工程防线。
此外,多数可共享结果的真实归档测试会通过 realParse() 缓存一次只读解析产物;验证输入顺序无关或分叉并集的用例则会按测试目的额外解析。该设计将测试时长从数分钟大幅收敛,避免过长的执行耗时降低测试频率。测试归档路径统一配置于 test/real-archive.js 中,可通过环境变量 DOUBAK_ARCHIVE_DIR 自定义。
命令首个参数为包含 bundle 归档的目录路径(可为单一归档,亦可包含多次导出的完整归档集合)。解析引擎需要全局视野,才能将同一条业务记录在不同时间维度的捕获状态串联为完整的版本修订历史。多次导出的归档合并处理完全幂等,用户可直接将历次备份归档放置于同一目录下。
递归检索子目录:归档文件可能包含解压目录层级、按月份分类的文件夹,或多次导出的平铺归档。解析引擎会自动向下递归检索:
- 检索到包含 bundle 标识的目录后即停止向下递归(避免多层嵌套干扰);
- 忽略软链接(防止循环递归死锁)。
node bin/parse.js ~/downloads/exports ~/downloads/canonical归档基于链式引用组织(记录 previous_bundle_id 以及各路线的 floor_from_bundle_id)。在实际使用中,删除重抓、多设备备份或同日多次增量均可能产生分叉:
多源分叉合并具备完全的幂等性与信息增益性:捕获记录本质上是带时间戳的客观观测,不同分支是同一账号在不同时间区间的观测集合,合并后的数据互为补充。以包含 7 份链式归档与 1 份独立重抓归档的真实样本测试为例:
| 输入集合 | 标记记录数 | 标记修订数 | 长文篇数 |
|---|---|---|---|
| 单独输入链式归档 | 2940 | 2943 | 4 |
| 单独输入独立归档 | 155 | 155 | 5 |
| 合并输入全部 8 份 | 2940 | 2943 | 5 |
合并结果严格等于数学并集:观测总数完全累加(3863 + 155 = 4018),而最终有效标记仍为 2940 条,修订总数依然稳定为 2943 次 —— 重复的观测数据不会产生任何虚假的新版本修订。这证明了归档系统对多源分叉合并的幂等性保证。如果任意舍弃其中一条分支,都会造成真实数据的遗漏。因此解析器不会武断舍弃数据,而是在日志中如实输出归档拓扑:
档案不是一条单链:检测到 2 个起点(起始 ID:3eef52、0fb09c)
在此基础上,系统会主动拦截并报错的两类真正异常:
- 混杂了不同账号的归档(默认直接报错中断):一旦跨账号数据合并入同一份规范化输出,后续将无法逆向拆分。若用户明确需要合并多账号,可显式传入
--ignore-warnings参数放行。即使绕过阻断,系统仍会在最终元数据中保留type: multiple_accounts告警记录; - 水位线基准(floor)指向的归档缺失(输出告警):若增量归档所依赖的历史基准未包含在输入目录中,意味着基准线以下的历史区间存在覆盖盲区,系统会输出明确的空洞告警。
解析产出为 5 个标准 NDJSON 文件,每行单条完整 JSON 对象,便于流式处理及使用 jq 直接检索分析:
| 文件名 | 内容描述 |
|---|---|
marks.ndjson |
标记记录:包含想看、看过、在看状态,以及评分、短评、标签等字段 |
subjects.ndjson |
作品目录数据:包含作品标题、封面图 URL、原始未拆解元数据文本 |
broadcasts.ndjson |
广播动态:包含精确到秒的时间戳、正文文本、配图 URL、完整动作描述以及可见性状态 |
longform.ndjson |
长篇创作:包含日记与书评、影评的完整正文 |
doulists.ndjson |
豆列数据:包含豆列元信息及其收录条目上的个人评语批注 |
# 查询评分为 5 星的电影短评:
jq -r 'select(.medium=="movie") | .revisions[-1].fields | select(.rating==5) | .comment' marks.ndjson | head
# 检索存在历史状态变更的标记项(修订版本数 > 1):
jq -c 'select(.revisions|length > 1) | {id: .subject.id, status_history: [.revisions[].fields.status]}' marks.ndjson解析流程结束时会输出统计汇总信息,例如:
档案 8 份 · 列表页 571 张 · 观测 7565 次 · 1869 ms
产出 标记 2940 条(修订 2943)· 作品 2940 个 · 广播 3394 条(修订 3394)· 长文 5 篇
跳过: { 'verdict:blocked': 2 }
可离线救回 2 条(页面已在档案里,改抽取器重跑即可,不必重抓):
note.item 2
告警: 无
- 标记修订数高于标记总数:差值代表用户真实发生过的历史修改(如改评分、补写短评或修改观看状态)。正常情况下修订增量较小,若异常偏大则需检查抽取器是否存在判定漂移;
- 广播数与广播修订数基本一致:广播发布后不可二次编辑,两者通常呈 1:1 关系;
- 跳过记录(如
verdict:blocked):抓取时触发了豆瓣反爬验证码等风控,归档了拦截壳页面,解析时自动剔除,不计入有效业务数据; - 可离线救回项:原始网页已完整留存在 WARC 中,因当时抽取规则未能识别而暂未提取,只需完善抽取规则后重新运行解析即可补齐,无需重新请求远端豆瓣服务;
- 告警记录:标识页面模板出现未知结构,提示开发者需跟进适配。
当存在重复捕获覆盖时,终端还会附带提示:
另有 2 条无法独立抽取的捕获,其对应 URL 在归档中已存在成功的捕获记录 —— 数据完整,重新解析仅会补充时间维度的修订观测。
当相同 URL 已存在成功捕获时,数据本体并无缺失。该设计遵循最小待办原则,避免因已证伪的历史异常污染未决修复清单。
豆瓣在用户名后附加的动态行为文本(如“收藏游戏到豆列”、“上传了17张照片到相册”等)早期曾简单截断为纯文本前缀,导致关联的目标对象(豆列名、作品名及超链接)丢失。
当前解析规则采用基于 HTML 结构的提取策略:
从用户名链接节点之后提取,直至内容容器标签(如 <blockquote> 或 .text 的 </div> 闭合标签),去除多余标签并折叠多余空白字符,与浏览器渲染行内 HTML 的方式一致。
| 指标 | 改进前 | 改进后 |
|---|---|---|
| 动作字段为 null | 33 条 | 2 条(日记同步广播,页面本身未包含动作词) |
| 携带状态的广播 | 3265 条 | 3265 条(准确提取,无冗余误判) |
| 文本长度分布 | ≤7 字符 | 中位数 2 · 90% 分位 2 · 最长 76 字符 |
| 广播修订总数 | 3898 次 | 3873 次 |
标点符号规范化消除虚假修订:豆瓣历史页面曾混合使用半角 : 与全角 : 作为分隔符。通过在提取阶段进行标点归一化,消除了 25 条因符号微调产生的虚假历史修订,保证了广播发布后不可变的业务语义。
如果动作描述仅保留纯文本,静态渲染后的文本将失去原始可点击链接。因此在保留整句 action 文本的同时,新增结构化的 action_parts 数组:
"action": "上传了17张照片到 寂静之人 的 相册",
"action_parts": [
{ "text": "上传了17张照片到 " },
{ "text": "寂静之人", "url": "https://www.douban.com/game/30246116/" },
{ "text": " 的 " },
{ "text": "相册", "url": "https://www.douban.com/game/30246116/photos/" }
]- 拼接一致性不变量:按序拼接
action_parts中各片段的text,其结果必须严格逐字等于action字段。消费端无需进行繁琐易错的子字符串正则匹配即可完整还原富文本; - 紧凑存储策略:若动作文本中不包含任何链接,
action_parts显式置为null而非单元素数组,避免无意义的冗余存储; - URL 保持原始状态:链接地址保持源站原始形式,不作主观格式改写。
抓取任务运行于用户登录态下,因此捕获的时间线中会包含仅用户本人可见的私密动态(例如发布私密日记时自动同步的广播)。
解析引擎将可见性判定严格绑定在内层容器 div.status-item 的 class 属性上(匹配 status-item private):
- 容器精准匹配:避免在外层父容器上模糊搜索关键字,杜绝正文中恰好包含
private单词的公开广播被误判为私密; - 按 Token 独立比对:避免简单的字符串前缀或子串包含检查,保障样式类名重排时的鲁棒性;
- 容器未识别时保持安全缺省:若无法定位有效容器,可见性字段置为
null,下游生成器会按最严格的私密策略处理,确保隐私安全不泄露。
实测样本(1112 页时间线、21996 个状态块)验证,仅精准识别出真实的私密记录,字段引入前后业务记录与历史修订数保持严格一致。
豆瓣图片分发使用了多个 CDN 节点主机(如 img1.doubanio.com 至 img3.doubanio.com)。同一作品海报在两次抓取间可能仅发生 CDN 主机名变更,而图片路径与内容哈希完全一致:
https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2888584528.webp
https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2888584528.webp
此类网络分发层面的微调不属于作品信息的实际修订。实测在 28 份归档的 8172 条作品修订中,有 111 条纯属此类 CDN 切换引起的虚假版本。
为此引入 cover_url_key 索引字段:
cover_url:忠实保留网页上观察到的原始完整 URL,该字段不参与版本修订判定;cover_url_key:将已知的 CDN 分片主机名替换为统一占位主机,生成规范化 URL 字符串,作为版本修订的判定基准。
核心约束原则:
- 仅归一化主机名,完整保留资源路径:路径中的图片 ID 已具备全局唯一性;
- 严禁剥离尺寸标识(如
s_ratio_poster、small等):不同尺寸代表不同的物理像素数据; - 未经验证的域名格式保持原样:仅针对实测量化过的豆瓣自营 CDN 主机执行规整,避免过度假设。
全量重跑验证:作品修订数精准回落(8172 → 8061,准确减少 111 次虚假变更),其余所有业务字段均零差异。
在实际使用中,下载目录中常会同时存在多个归档的解压产物(包含多个 index-*.ndjson 与分段文件)。
解析引擎支持从混合目录中完整提取所有归档实例:
- 唯一标识以索引文件名与记录 ID 前缀为准,避免依赖易被覆盖混淆的单份
manifest.json; - 严格校验 manifest 与分段归属:仅当 manifest 声明与索引 ID 匹配时才采纳其元数据证据,防止将单份归档的水位线错误套用至其他归档;
- 缺失 manifest 归档的安全处理:按目录上下文推断归属账号进行归并,但在广播抽取等涉及身份鉴权的场景下采取保守策略(标记
no_owner告警),杜绝第三方数据串入个人档案。
若同一份归档在多个子目录中存在重复备份(如解压生成的副本),系统通过检验索引记录的前缀包含关系进行自动去重: 由于索引文件在抓取阶段仅执行追加写入,导出操作仅是文件复制,因此较早导出的索引必然是较晚导出索引的前缀(两份文件完全相同则是其特例)。
- 构成前缀关系:判定为同一归档的局部拷贝,保留记录更完整的一份,并静默略过冗余副本;
- 不构成前缀关系:判定为内容冲突的异常情况,保留两份数据并输出警告供用户排查。
提供轻量快速的归档物理校验工具,专注于验证数据文件与索引声明的物理一致性:
node bin/verify.js ~/downloads/exports # 退出码 0 表示校验通过,1 表示发现异常
node bin/parse.js ~/downloads/exports out # 执行解析前默认会先执行完整性校验
node bin/parse.js ~/downloads/exports out --no-verify # 跳过校验步骤(提升速度)- 默认启用完整性校验:解析执行前默认自动校验;能定位到
capture_id的异常捕获会被排除,其余数据仍继续解析,若存在任何校验发现则命令最终以非零状态退出; --no-verify与--ignore-warnings职责分离:前者控制底层数据文件的物理哈希校验,后者用于控制业务层跨账号逻辑的告警阻断;- 五项物理检验指标:
- WARC 分段文件的 SHA-256 校验和与字节大小;
- 索引文件(index.ndjson)的 SHA-256 校验和与行数;
- 各分段记录总数一致性(
record_count); - 索引记录中的
warc_record_id与 WARC 实际记录 ID 交叉比对; - 记录声明的
content_sha256与正文物理内容哈希匹配。
与 doubak-data-specs 提供的语义校验器(validate.py)不同,verify.js 不关注元数据的业务合规性(如水位线连续性),而是快速定位底层的物理撕裂或字节错位(24 份归档/2.6 GB 实际正文校验仅需约 8 秒)。
- 绝对离线运行:数据解析全流程杜绝任何网络请求。所有派生 canonical 数据均能完全由本地的 captures 归档无损重建。
- 纯函数与全集幂等:解析逻辑对输入归档集合具备纯函数特性。无论分次解析还是合并全量解析,系统均能稳定产出确定性的规范化数据。
除 bundle-source.js(处理 Node 本地文件系统 I/O)外,src/ 目录下的所有业务解析逻辑均为纯 JavaScript 编写,不依赖任何 Node 内置模块。代码通过 tools/sync-vendor.mjs 同步至浏览器扩展并在 OPFS 存储之上无缝运行。
统一采用纯 JS 实现的同步 sha256.js 计算字段摘要,避免因异步接口(如 Web Crypto API)给同步计算管线带来繁重开销,经全量测试向量验证具备严格的算法一致性。
数据行为严格遵循 doubak-data-specs/canonical/ 规范约定:
INGESTION.md:归档有效性摄入规则与语义推断边界;IDENTITY.md:条目身份唯一性定义与跨时间捕获状态关联;FIELDS.md:规范化字段定义与提取边界约束;v1/*.schema.json:规范化产出数据的 JSON Schema 契约。
基于 17 份真实生产归档(覆盖 2026-07-31 至 2026-08-20 时间段)的全量基准测试表现:
- 标记与作品:2950 条有效标记(对应 2959 次历史修订,9 次真实状态变更);2950 个作品对象(对应 2971 次修订);
- 广播动态:3411 条广播(3480 次修订,其中 69 次修订均严格限定于引用的作品卡片标题变更,广播正文零变动);
- 长篇内容与多媒体:收录 5 篇长篇创作(日记 3 篇、评论 2 篇);提取广播附图 132 张(与归档中
asset.status_photo集合严格一致); - 豆列数据:收录 6 份豆列(共 134 个条目,其中 62 个条目包含个人评语,包含 1 份私密豆列)。
配合 doubak-import-adapters,可将历史第三方工具归档转换为合规 bundle。实测合并历史导入归档后,标记修订数从 2959 扩展至 3105 次,平滑补齐了长达 20 个月的历史版本修订记录。
解析器专注于高效数据提取与版本推导;若需对归档进行严格的规范合规性审计,可直接运行规范仓库提供的语义校验器:
python3 <doubak-data-specs>/bundle/v1/validate.py <归档目录>规范化 canonical 数据产出后,可供下游组件自由接入:
- 使用
doubak-site-generator渲染为独立可浏览的静态站点与本地图片库; - 使用
doubak-export-adapters转换为 NeoDB、Letterboxd 或 Goodreads 等平台的结构化导入文件。