豆备 DOUBAK
← 开发日志

把下游搬进扩展,靠的是抄而不是重写

NeoDB 的维护者问「扩展能不能直接导出 NDJSON」。做的时候我差点在扩展里重写一遍图片判定——三处全错,三处都不报错。真正的工作不是写代码,是把纯函数从做 I/O 的文件里拆出来。

那个路牌 PR 谈完之后,维护者顺口问了一句:

Will you be updating your Chrome extension so it can export an NDJSON zip directly? If so, I'll merge and release after you're done.

之前一直担心「扩展塞太多东西,商店审核会麻烦」。这次去查了一下, 发现那个担心指错了变量:审核看的是权限、远程代码、 以及你怎么声明数据流向,跟代码行数没什么关系。而这次改动新增的权限是, 网络请求是,远程代码是——它全是对已经躺在 本地的字节做纯计算。要说有影响,是往好的方向:「所有事情都在你自己机器上发生」 这句话变得更真了,而不是更假。

一半的活早就干完了

这是查下来最意外的部分。扩展里早就有一个 src/vendor/parser/, 装着解析器五个抽取器的逐字节拷贝,还有一条同步脚本和一条 CI 检查盯着它别漂。档案页那个「查看内容」也早就在浏览器里跑解析了。 读字节那一层(按 offset 定位、解压、切出 HTTP 正文)也有现成的。

所以问题从来不是「浏览器里能不能解析」,而是还有几个纯函数被困在 做 I/O 的文件里

真正的工作:四次拆分

四个上游文件都在同时干两件事——「把字节读进来」和「拿字节算点什么」。 前者两边必然不同(Node 读文件系统,扩展读 OPFS),后者必须只有一份

  • digest.js 不再用 node:crypto,改用自带的一份同步 SHA-256
  • zip.js 只留格式,压缩函数改成传进来的
  • pages.jsgenerate.js 里拆出来:排页面是纯计算
  • image-index.jsimages.js 里拆出来:「哪张图是哪张」是判定

最后那一条最值得记,因为我差一点就没拆

差点重写的那一段

当时想的是:不就是从 index 里挑出图片记录、编成两张表吗,照着字段名写一遍就完了。 我真的写了一版。三处,全错:

  • content_type 筛,而正确的判据是 surface === 'asset'
  • row.parent_url 找作品 id——这个字段根本不存在, 真正的路是顺着 parent_capture_id 回到作品详情页那条记录的 URL
  • 同名图留最早那份,而上游留的是最新那份(字节一样,但读新的能少解一个旧的大段文件)

三处都不会抛异常。出来的两个站点打开都很正常,只是图片路径不一样。 而上游那份代码里还写着一条我根本想不到的规则:不能只按 URL 找封面—— canonical 里的封面地址取自列表页缩略图,档案里存的却是详情页封面。 多数媒介恰好是同一个文件,舞台剧不是。实测 95 张明明就在档案里的图会全部落空。

「这段逻辑很简单,我照着重写一遍就行」——这句话的危险不在于难,在于你不知道它里面还压着几条实测出来的规则。 那 95 张封面、那个不存在的字段名、那个「留最新不留最早」, 没有一条能靠读字段名推出来。它们是有人对着真实数据撞出来的, 而重写一遍恰好会把它们全部丢掉,且一声不响。

为什么是抄,不是 submodule

每次说到「一份代码两个地方用」,submodule 都会被提出来。这次没用,理由很具体, 不是风格问题:

git clone 不加 --recursive,会留下一个存在但是空的目录。 而扩展的打包脚本只在路径不存在时才报错——空目录它照收不误。 于是打包成功,装出来的扩展里没有解析器,而且一声不响。 那个白名单当初就是为了避开这种「静静少了东西」的失败,不能从后门把它放回来。

现在的做法是老规矩:产物提交进仓库(跑测试不需要另外三个仓库在场, 零构建步骤的前提不变),新鲜度由 --check 守着,本地缺仓库时「带原因跳过」, CI 里则算失败——跳过在 CI 里等于没测。一共 23 个文件。

顺手被现有的规矩纠正了两次

写导出页的进度条时,我写了 bar.style.width = '42%'。 测试立刻红了:这个项目有一条「JS 里不许出现行内样式」的硬规矩, 因为样式一旦能在 JS 里随手写,就没有任何力量阻止下一块界面再发明一套。

被逼着换了个写法之后,换来的东西比原来好:用原生的 <progress>。它自带无障碍语义(读屏会念出进度), 而且不写 value 就是「不确定」状态—— 正好对应「正在生成文件」这种算不出分母的阶段。 自己拿 div 拼的话,那几段只能显示 0%, 而 0% 看起来像卡住了,跟「在动,但不知道还有多久」是两件必须分清的事。

一条挡住你的规矩,有时候是在把你推向本来就更好的那个解法。 禁掉行内样式的理由是「统一不了的根源是没有唯一的地方」, 而它这次顺带解决的是无障碍和「不确定状态」——两件我压根没在想的事。

那几条回答

中间产物不落盘。 解析结果只活在内存里,出完文件就扔。 理由不是省空间——派生数据落了盘就是第二个真相来源, 而这个面板已经为这件事付过三次代价。代价是每次导出重新解析一遍, 换来的是那条不变量继续成立:删掉所有派生数据、只靠原始捕获重建,必须能跑通。

一次只跑一个。 三种产出共用同一次解析,同时跑两个等于把最慢的那步做两遍。

直接写进你选的文件夹,不在扩展里中转。 浏览器的 createWritable() 写的是临时文件,只在关闭的那一刻整体换上去—— 所以中断留下的是「没有这个文件」,而不是「半个文件」。

后来对着真实档案验了一遍

写完之后拿一份真实的 18 份档案跑了一趟,再跟命令行那条路的产出逐条比:

  • 结构化数据:标记 2950、作品 2950、广播 3411、长文 5、豆列 6 ——两边一条不差,每条的最终内容逐字段相同。 只有 9 条修订数不同,而那 9 条全都来自「一边有、另一边没有」的那两份档案。
  • NeoDB 包:每一种记录的条数与命令行那份参照完全一致, 系统自带的 unzip -t 也认。
  • Markdown:3131 个页面的文件名一个不差, 内容只有 8 个文件不同——而那 8 个全都指向同 5 张封面, 两边各自找到的是同一张海报的两个尺寸。

第一版对比脚本得出「47 条内容不同」,而那个数字是假的。 标记记录没有顶层 id,我那个「取 id,取不到就退回整条 JSON 的前 200 个字符」 的键把成千上万条塌成了几个键。数字看着挺像回事,我差一点就当成发现报出去了。 换成解析器自己用的那个键(媒介 + 作品 id)、并且先断言键是唯一的 之后,答案是 0。

还是那句。 Letterboxd 和 Goodreads 这两路仍然没有做过真实往返, 所以扩展的导出页里没有它们—— 一个按钮就是一句「这条路能走」,而那句话现在还说不出口。

更早:把代码删光之后,证据反而更硬了 →