这是源码仓库。 项目主页在 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,零依赖,不需要 npm install需要 Node ≥ 20。
npm test 里那个 --test-concurrency=2 不是随便写的:有 4 个测试文件会各自
把那份真实档案(619 MB、23962 条捕获)完整解析一遍,而 node --test 默认按
核数并发。8 核的机器上就是 8 份同时在跑,每份峰值几百 MB——实测会把整个测试
文件整体打挂,而报出来的只有一句没有断言的 'test failed',看不出是资源
问题还是真的坏了。单独跑那个文件又是绿的,于是它表现为「时好时坏」。
第一个参数是装着一堆 bundle 的目录,不是单份 bundle —— 解析器要同时看到全部档案才能把同一条记录在不同时间的样子拼成修订历史。多喂一份档案不会丢掉任何东西,所以直接把历次导出都放进同一个目录就行。
子目录也会一起找。 解压出来带一层外壳、按月份分了文件夹、几次导出堆在一起 —— 真实的下载目录就长这样,让人先手工摊平只会换来「摊漏了一份」,而漏一份档案没有任何声响:产出照样是一份看着完整的 canonical,只是少了一段历史。两条边界:一个目录认出是 bundle 之后就不再往里钻(里面不会再套一份),软链接不跟着走(一个指回上层的软链接足以让递归转不出来)。
node bin/parse.js ~/downloads/20260806 ~/downloads/20260806-canonical档案是成链的(previous_bundle_id,以及每条路线各自的 floor_from_bundle_id),而一个目录里出现多个起点、出现分叉是正常的:删掉一份重抓、换台机器、同一天跑两次增量,都会分叉。
分叉不需要你做选择,也不影响结果。 捕获是带时间戳的观测,两条分支只是同一个账号的两批观测 —— 合并起来是信息更多,不是信息打架。实测那八份档案恰好就是两个起点(一条 7 份的链 + 一份独立的全量重抓):
| 喂进去的 | 标记 | 修订 | 长文 |
|---|---|---|---|
| 只有那条链 | 2940 | 2943 | 4 |
| 只有那一份 | 155 | 155 | 5 |
| 八份一起 | 2940 | 2943 | 5 |
合并的结果恰好是并集:标记观测 3863 + 155 = 4018,一次不多一次不少;而修订数仍然是 2943 —— 多出来的 155 次观测没有凭空产生一条修订。那才是「分叉不是矛盾」的真正含义。
反过来,挑任何一条链都会丢东西:挑那条链丢掉一篇日记,挑那一份丢掉 2785 条标记。所以解析器不替你取舍,只把拓扑说出来:
档案不是一条单链:2 个起点(起点 3eef52、0fb09c)
真该拦下来的是另外两件事,它们看起来也像「目录里的东西不对」,但性质相反:
-
混了不同账号 → 直接报错。 合进同一份 canonical 之后事后分不开,而它太容易发生了 —— 把两次导出解压到同一个下载目录就够了。递归找子目录之后这一条更要紧:目录指得越大,捞进别人档案的机会越多,所以它是那次改动的配套,不是附带。
确实是同一个人的两个账号的话,加
--ignore-warnings放行。它绕过的是「停下来」,不是「说出来」 —— 那条告警照样会出现在输出的告警列表里(type: multiple_accounts)。 -
地板指向的那份档案不在目录里 → 告警。 增量只看了地板以上,地板底下那段谁也没看过。这是个真实的覆盖空洞,而它看起来一切正常:条数、连续性、其余告警全是好的。
五个 NDJSON,一行一条记录,jq 直接能查:
| 文件 | 是什么 |
|---|---|
marks.ndjson |
标记:想看/看过/在看,连同评分、短评、标签 |
subjects.ndjson |
作品:标题、封面 URL、那行没拆的元信息 |
broadcasts.ndjson |
广播:秒级时间戳、正文、附图 URL |
longform.ndjson |
日记与评论的全文 |
doulists.ndjson |
豆列:清单本身,以及每个条目上你自己写的评语 |
# 我给几部电影打了五星?
jq -r 'select(.medium=="movie") | .revisions[-1].fields | select(.rating==5) | .comment' marks.ndjson | head
# 哪些标记被改过(修订多于一条)?
jq -c 'select(.revisions|length > 1) | {id: .subject.id, 状态: [.revisions[].fields.status]}' marks.ndjson档案 8 份 · 列表页 571 张 · 观测 7565 次 · 1869 ms
产出 标记 2940 条(修订 2943)· 作品 2940 个 · 广播 3394 条(修订 3394)· 长文 5 篇
跳过: { 'verdict:blocked': 2 }
可离线救回 2 条(页面已在档案里,改抽取器重跑即可,不必重抓):
note.item 2
告警: 无
- 修订 2943 / 标记 2940 —— 多出来的 3 条是三次真实的状态迁移。这个差值应当很小:它每多一条,就是有人改了什么,或者抽取器出了问题。
- 广播 3394 / 修订 3394 —— 一比一。广播发布后不可编辑,所以这里但凡不相等就值得去看。
- 跳过 —— 那次抓取被豆瓣拦下了,页面内容不是真的。不当成数据,也不假装没发生。
- 可离线救回 —— 页面字节已经在档案里,只是当时没抽出来。改抽取器重跑就行,不必重抓豆瓣。
- 告警 —— 抽取器跟不上页面了。不是「有点脏」,是该去量一量。
有几份 index-*.ndjson,就是几份档案。 这不是假想的形状——一个普通的下载
文件夹就会长成这样:
~/downloads/old/
index-20260730T102904Z-f4ef8c.ndjson ← 10 份档案的索引
index-20260731T051333Z-786e5c.ndjson
…
data-*.warc.gz catalog-*.warc.gz ← 13 个段文件
manifest.json ← 只有一份,属于其中一份档案
Screenshot 2026-08-02.png NeoDB - Home.html …
从前这里只取按文件名排第一的那份索引,配上目录里那份 manifest.json——于是读出来
是一份自相矛盾的源(manifest 说 786e5c,索引第一行是 f4ef8c#000001),
另外 9 份档案的 573 条捕获一声不吭地没了。而漏一份档案没有任何声响:产出照样是
一份看着完整的 canonical。
现在逐份读出来,并且:
- 编号取自索引文件名,不取自 manifest。 索引文件名与它每一行的
capture_id前缀同源,那才是这份数据自己的身份;manifest 是一份关于某个编号的说明。 - manifest 说的不是这一份就不认。 认错的代价不是少点信息——
crawl_state/coverage会被当成这份档案的完整性证据用,拿另一份的水位线去判断「缺的就是 删掉的」。不认它只是少授予一些权限,那个方向是安全的。 - 照样报出来。 混放本身要说给人听,因为其中至多一份配得上 manifest,其余的 完整性证据就都没有了:数据还在,能下的结论少了。
没有 manifest 的档案按目录里唯一的那个账号归并(account_adopted 告警会说
是哪几份)。原来它们写的是 account: 'unknown',而退化身份键是
d:<账号>:<媒介>:<作品 id>——'unknown' 不是一个账号 id,是「没有 id」,
拿它当键的一部分等于凭空造出第二个人。实测:9 份没有 manifest 的档案并进来,
2955 个作品出了 2960 条标记,5 部舞台剧一分为二(两半的 status / rating /
marked_at 完全相同)。只有舞台剧中招,因为它的列表页没有 data-cid,走的正是
退化键;带上游 id 的走 u: 键,与账号无关,照常合并。与 0.9.0 修掉的
data-cid 那个 bug 是同一个形状,只是换了个字段来劈。
认领只用来归并,不用来筛广播:拿它去比对每条广播的 data-uid 是在授权,
认错了会把转发进来的第三方内容写进档案主人的 canonical。所以那些档案里的广播
仍然一条都不抽(no_owner 告警)。严格授予、宽松否定。
它只回答一个问题:这份档案的字节,是不是它自称的那些。
node bin/verify.js ~/downloads/20260806 # 退出码 0 干净 / 1 有发现
node bin/parse.js ~/downloads/20260806 out # 默认就会先核一遍
node bin/parse.js ~/downloads/20260806 out --no-verify # 跳过(快,但坏的捕获会照常进去)默认是查的。 这一条是被前一版自己教会的:validate.py 一直都在,覆盖的情况也
几乎全,可它在 24 份真实档案里有 4 份永远报错(冻住的生产者 bug),于是从来没人
跑它——一个要人主动去跑的完整性检查等于没有。默认关着的开关,是把「要不要
相信这些字节」推给一个此刻根本不知道有这回事的人。代价是那份 619 MB 的真实档案
上多 8.3 秒。
--no-verify 是唯一的绕开方式,它和 --ignore-warnings 是两回事,不要合并:
后者管的是「混了多个账号还要不要继续」,那是一个致命条件的闸门;完整性发现根本
不致命(坏的那几条排除掉,其余照常摄取)。一个开关管两件性质不同的事,用它的人
就不知道自己关掉了什么——想绕开账号检查的人,不该顺手把字节校验也关了。
查五样,全部是完整性,一样语义判断都不做:段文件的 sha256 与字节数、index 的
sha256 与行数、每段的 record_count、每条捕获的 warc_record_id 是否真的在那条
记录里、每条捕获的 content_sha256 是否真的等于正文。实测一份 24 份档案 / 619 MB /
23962 条捕获的真实目录:8.3 秒,解压出 2.6 GB 正文全部摘一遍。
它不是第二个 validate.py。 规范仓库里那个参考校验器问的是「这份档案合不合规」
——crawl_state 的不变量、coverage 的可追溯性、schema 的形状。两件事必须分开,
因为 bundle 一旦产出就冻结,合规性错误里有一部分永远修不掉:实测同一个目录里
4 份档案因为一个早已修好的生产者 bug 永远报 45 个错。混在一起,那 45 条会把一条真的
字节损坏挤到屏幕外面,而一个永远有内容的失败列表就是一个没人看的失败列表。
(validate.py 现在也把两类分开了,退出码 2 是完整性、1 是合规性。)
那为什么解析器这边还要有一份?两个量出来的理由:
- 解析器压根不打开图片行。 一份真实档案 23962 条捕获里 6134 条是
surface: asset,parse()一条都不读。往 assets 段里翻三个字节再解析,产出与基线逐字节相同—— 坏消息要等到生成站点那一步才冒出来,而且是一句 zlib 的栈回溯,离起因隔着两个工具。 validate.py要 Python。 这条链路其余每一环都是零依赖的 Node。
校验不中止解析,只排除:查出问题的捕获落进 跳过: { 'verify:字节与索引对不上': N },
其余照常摄取(canonical/INGESTION.md §2.3——丢弃的是凭它能下的结论,不是数据)。
产出照写,退出码非零——文件回答「还能救回什么」,退出码回答「这趟干不干净」。
最要紧的那条是 warc_record_id,不是 content_sha256。索引与字节之间只有它这一处
交叉引用:偏移量整体错位一条记录时,gzip 解得开、CRC 也过、正文还是一个合法页面,
除了它谁也看不出来指错了地方。实测把一份真实档案的 offset 前移一条:解析器只报了
status_mismatch: 93,读起来像抽取器坏了——归因完全错,而那正是不核字节的代价:
不是没人发现,是发现的人会去查错的地方。
不联网。 一个网络请求都不发。这不是自律,是可执行的判据——把所有派生数据删掉、只靠 captures 重建必须能跑通,而这个解析器就是那条重建路径本身。
对全集的纯函数。 不是「在上次结果上打补丁」。对 N 份档案跑一遍、再对 N+1 份跑一遍,第二次不得丢掉第一次得到的任何东西。做成纯函数,这条性质是免费的。
src/ 下除了 bundle-source.js 之外的每一个文件都不碰 node 内建模块,
浏览器扩展把它们逐字节拷进 src/vendor/parser/,在 OPFS 上跑同一套解析
(tools/sync-vendor.mjs --check 由两边的 CI 守着)。test/portable.test.js
按目录和按依赖闭包各扫一遍,加了个 node: import 就会红。
界线是 「字节从哪儿来」各写各的,「字节怎么解释」只有一份。所以 parse() 不收
目录,收的是一组满足八项契约的源:
status · manifest · bundleId · index · crawlState · coverage · payload · close
只有 payload(row) 做 I/O,也正因为它 parse() 是 async 的——Node 这边同步读文件,
扩展那边读 OPFS 只有异步接口,而同步实现照样能 await,所以命令行这一路行为没变。
契约由 portable.test.js 钉着:多认一个方法,扩展那边就会少实现一个,而缺的那个
只在运行时才炸。
同一个理由下 digest.js 不再用 node:crypto,改用本仓库的 sha256.js(同步、纯 JS)。
crypto.subtle 会把 fieldDigest 染成 async,而它是逐字段调的——一份真实档案约 7.5 万次。
实测慢 4.2 倍,整趟合计半秒。手写哈希在这里可以接受,是因为标准答案就在旁边而且比对
是穷尽的(test/sha256.test.js 拿 node:crypto 对拍官方向量、0–130 每个长度、代理对、
以及真实页面里抽出来的每个字段);摘要算错不会抛异常,只会让所有记录同时看起来被
编辑过——canonical 只比较同一 parser_version 的修订,摘要一偏移那道保护就失效了。
行为规定在 doubak-data-specs/canonical/:
INGESTION.md |
哪些档案能读、读出来能推出什么结论 |
IDENTITY.md |
一条标记是什么,两次抓取之间怎么知道是同一条 |
FIELDS.md |
能拿到什么,哪些不该在解析时拆 |
v1/*.schema.json |
产出格式 |
改选择器之前先去量。 那些文档里的每个数字都是对着真实档案数出来的,不是照着页面「看起来该是这样」写的。这个项目一贯的失败形状是:抽取器瞎了,而所有指标看起来都正常。
标记、作品、广播、长文、豆列五类都做了。拿十七份真实档案(2026-07-31 → 2026-08-20)跑通:
标记 2950 条(修订 2959) 多出的 9 条是真实的编辑
作品 2950 个(修订 2971)
广播 3411 条(修订 3480) 正文一个字没变;多出的 69 条全都只差 target_title
长文 5 篇 日记 3 + 评论 2,全文
广播附图 132 张 与档案里 asset.status_photo 的集合**逐个相等**
豆列 6 份(134 个条目) 其中 62 条带自己写的评语;1 份是私密的
广播那一行值得读两遍。正文冻结这条性质在真实数据上成立——3411 条广播的 text/rating/posted_at 一次都没变过。而修订数不是一比一:挂在广播下面那张作品卡是豆瓣在你打开页面那一刻现渲染的,所以它改个片名就会产生一条修订。那 69 条差异每一条都只差 target_title,一条都没碰正文。(曾经有一条断言写的是「修订数恒等于记录数」,它太强了——把豆瓣自己的改名当成了抽取器故障,而那些改名恰恰是档案该留住的东西。)
附图那一行守的是解析端与抓取端对「哪些图算数」的判据一致 —— 解析端更松会在 canonical 里留下抓取端从没取过的 URL,更严则等于悄悄丢图,两种都很难发现。
doubak-import-adapters 把第三方工具(目前是前代的 its-my-data/doubak)存下来的页面转成合规的 bundle,这个解析器一行都不用改就能读——导入进来的档案只是目录里多出来的几个起点,而分叉不需要你做选择。
实测把 2022-12 → 2024-08 那 7 份导入档案和上面 17 份放进同一个目录:
标记 2950 → 2955 广播 3411 → 3413 (记录只多了 7 条)
标记修订 2959 → 3105 广播修订 3480 → 3856
多修订的标记 8 → 147
价值不在记录数,在那 20 个月的编辑史。 上面那 17 份档案整部编辑史只有 9 条修订,因为第一次抓取是 2026-07。
一句注意:解析器不校验档案完整性,它只要求有一份能解析的 index-*.ndjson。段的 sha256、record_count、每行的 schema、以及每行都带着的 content_sha256,一个都不查(读不出来的捕获会记一条 unreadable 告警,gzip 自己的 CRC 也挡得住位翻转,但仅此而已)。要验完整性请跑规范仓库自带的那个 ——它是另一边写的,而这正是它的价值:
python3 <doubak-data-specs>/bundle/v1/validate.py <某份档案的目录>两条,互不依赖:把 canonical 交给 doubak-site-generator 生成静态站,或者交给 doubak-export-adapters 产出 NeoDB / Letterboxd / Goodreads 的导入文件。