中文 · English README
这是源码仓库。 想了解这个工具是什么、能做什么,去 https://doubak.com; 想看它生成出来长什么样,去 https://sample.doubak.com。
豆备 (Doubak) 的浏览器插件,整个项目的所有功能都可以在这个插件里找到入口。
它在你自己的浏览器、用你自己的 IP 和节奏抓取豆瓣,把页面原样存成标准 WARC 档案。登录凭据和会话 cookie 不离开你的设备。验收标准是:把服务器关掉,插件依然能产出一份完整可用的本地档案。
抓完之后不用再装任何东西:「导出」页当场把档案算成结构化数据、NeoDB 导入包、
或者一棵 Markdown 站点(图片一并导出)。用的就是下游那三个仓库的同一份代码
(src/vendor/,逐字节拷贝 + CI 查新鲜度),所以扩展出的东西和命令行出的东西是同一个东西。
v1.0。 全量与增量抓取都对着真实豆瓣跑通了,图片(自己上传的、作品封面)、 长文正文、豆列、导出与导入都在里面。相册与移动应用(app)都明确不做, 但理由不是同一个:app 是上游已废弃,相册是那些照片属于条目而不属于你的档案 ——见下方「现状」。
- DESIGN.md —— 功能规划、抓取边界模型、三份实测附录
- docs/toolchain.md —— 工具链选型与理由
- docs/ui.md —— 界面设计:说什么话、怎么显示进度才不撒谎
- docs/permissions.md —— 权限审计:声明了什么、刻意不要什么、 以及「哪个执行上下文有哪个 API」那张咬过人的表
- docs/release.md —— 怎么发一个版本,以及版本号为什么值得小心
- 档案格式定义在另一个仓库:
doubak-data-specs
零依赖、零构建步骤。仓库里的源码就是浏览器里跑的东西。
npm test # 跑测试(Node 内置的测试运行器,不需要 npm install)
node tools/package.mjs # Chrome / Edge 的 zip → dist/doubak-<版本>.zip
node tools/package.mjs --firefox # AMO 的 zip → dist/doubak-<版本>-firefox.zip
node tools/package.mjs --list # 只看会打包哪些文件
node tools/package.mjs --stage dist/unpacked # 摊成一个可直接加载的目录
node tools/make-manifest.mjs # 重新生成 manifest.firefox.json(--check 只核对)需要 Node ≥ 20。
两个包,同一份文件名单。 差别只有 tools/package.mjs 的 TARGETS 表里那三格:
用哪份 manifest、文件名后缀、去掉哪两个在 Firefox 上永远走不到的文件
(host-offscreen.js 与 offscreen.html)。manifest.firefox.json 是算出来的,
手改会被测试打回——它与 Chrome 那份分家的方向是「Firefox 那份少了一个新权限」,
症状是装得上、某个功能悄悄不工作。
四条路,装出来是同一个扩展。Firefox 用的是另一个包(-firefox 结尾的那个),
两份 manifest 不一样,装错了是直接加载失败,而失败信息不会提到是拿错了包。
从 Chrome 应用商店装(多数人走这条): https://chromewebstore.google.com/detail/hilmaopahndgbiolohgefnbeedobpafe (链接只写扩展 ID、不带名字那一段——带名字的形式在改名之后会失效)。Edge 也能从 这里装。
下一个发布版(不想走商店时):从 Releases 拿 zip,先解压, 然后按浏览器分两条路——那两份都是商店的提交格式,收件人是商店,不是浏览器:
| 拿哪个 | 怎么装 | |
|---|---|---|
| Chrome / Edge | doubak-<版本>.zip |
chrome://extensions → 开发者模式 → 加载已解压的扩展程序 → 选解压出的目录 |
| Firefox | doubak-<版本>-firefox.zip |
about:debugging#/runtime/this-firefox → 临时载入附加组件… → 选目录里的 manifest.json |
Chrome 那份与商店里的是同一个包:同一条 CI 打出来,时间戳清零,逐字节相同。
Firefox 的「临时载入」是字面意思,关掉浏览器就没了;但 OPFS 里的档案还在,下次
载入还能接着增量抓。AMO 上架之前只有这一条路(#11)。
从仓库直接装:Chrome / Edge 同样是「加载已解压的扩展程序」,选本仓库根目录。
这个项目没有构建步骤,源码就是浏览器里跑的东西,所以这条路一直有效——代价是它带着
test/ 和 docs/,不是分发出去的那份。Firefox 走不了这一条:仓库根上那份
manifest.json 是 Chrome 形状的,载入时 Firefox 直接拒绝,原话是
Could not install add-on: background.service_worker is currently disabled.
Add background.scripts.
先 node tools/package.mjs --firefox --stage dist/unpacked-firefox,再选那个目录里的
manifest.json。
试 main 上还没发版的改动:GitHub 的
Actions → package
里挑一次运行,下载 doubak-<版本>-unpacked(Firefox 是 -firefox-unpacked),解压
即可加载。里面只有运行时真正需要的文件。下载构建产物需要登录 GitHub(匿名取是
401),所以面向用户的地方一律指向 Releases。
发版流程见 docs/release.md。
有些东西 Node 里测不到:OPFS、持久化存储许可、配额。装载扩展后打开
chrome-extension://<扩展ID>/selftest/index.html
点「开始自检」,它会在真实浏览器里跑:
- FileStore 契约(与 Node 里跑在内存实现上的是同一组断言),两遍——一遍直接对 OPFS, 一遍经由 Worker RPC(抓取真正走的是后者)
- IndexedDB 的 KvStore 契约(Node 里没有 IndexedDB,这是它唯一的真实覆盖)
- 完整的 bundle 写入与崩溃恢复
- 环境探测:持久化存储许可、配额、写入吞吐
跑完点「复制报告」拿到纯文本,[PASS] / [FAIL] 前缀,失败项汇总在开头。
装在移动版 Chromium(安卓 Edge、Kiwi 之类)上时,豆瓣按 User-Agent 里的手机标记
把每一条路线都跳到 m.douban.com:
www.douban.com/people/<u>/ → m.douban.com/people/<u>/
movie.douban.com/people/<u>/collect → m.douban.com/people/<u>/movie/done
book.douban.com/subject/4820710/ → m.douban.com/book/subject/4820710/
而手机版那张页面(12.5 KB 的壳)上,登录标志与数字用户 ID 一个都没有, 于是抓取停在「无法判断登录状态」上——那句话过去不会说出真正的原因 (#12)。
所以扩展在这类浏览器上会装一条 declarativeNetRequest 规则,把发给豆瓣的
User-Agent 里那个手机标记去掉。三条边界:
- 只删词,不编 UA。 安卓 Chromium 去掉
Mobile之后,得到的正是同一个浏览器 在安卓平板上发的那一个——同一系统、同一浏览器、同一份 TLS 指纹。规范里 「生产者不得伪造 UA」给的理由就是「伪造出的 UA 与指纹不一致,反而更容易被风控 识别」,而拼一个(X11; Linux x86_64)出来正好撞在那句话上。认不出的形状 (iOS 的Mobile/15E148是个带版本号的整体,删掉得不到任何真实存在的 UA) 一律不动,宁可不支持。 - Firefox 安卓不在覆盖范围内,而这不是「还没做」。 它的两种真实形态
(
Mobile;/Tablet;)都会被跳到手机版,唯一能拿到桌面版的是货真价实的 桌面 UA——在安卓上发它就正好是上面那句禁令针对的东西。这个方案在 Firefox 上 不成立,留给 #11 在真机上定。 - 只改扩展自己发的请求(
tabIds: [-1])。你在标签页里逛豆瓣照样是手机版—— 在手机上多半就是想要手机版。这也是浏览器那个「请求桌面版网站」开关帮不上忙的 原因:它按标签页生效,而抓取跑在离屏文档里。 - 桌面浏览器上这条规则压根不装,发出去的 UA 与之前逐字节相同。
抓出来的档案会在 manifest.notes 里写明这件事;producer.user_agent 里记的
仍然是浏览器真实的那一个。调试页的「环境自检」表上有这两个 UA,可以直接核对。
这一条只解决内容那一侧:页面回到桌面模板,分类器与抽取器就都对得上了。 离屏文档在移动版浏览器上能不能扛住几小时的抓取,是另一个问题,还没有实测。
点工具栏图标直接开面板标签页(已经开着就切过去):
| 标签 | 内容 |
|---|---|
| 概览 | 各路线进度、抓不下来的页面(可重试 / 可确认收尾)、开抓前预检 |
| 覆盖率 | 合起来 / 这一份两个视角。默认合起来——增量之后完整性是整条链的属性 |
| 档案 | 档案的一切都在这一页:导入、导出、「验一验」、删除、用量;记录预览;标出新增 / 又抓了一次;跨链的版本历史。选中一份之后有两个互斥的视角——翻看捕获(生的:URL、判定、原始字节)与查看内容(熟的:解析成条目,看得见自己写的短评、广播、豆列评语) |
| 日志 | 抓过的 URL、重试、停机、错误,存在本机、可复制 |
| 调试 | 演练(零网络请求)、小范围试跑、环境自检 |
| 模块 | 状态 |
|---|---|
| 脚手架、工具链、基础类型 | ✅ |
| bundle 写入器(段轮转 / index / manifest) | ✅ 产出通过规范校验器 |
| 崩溃恢复 | ✅ 真实 OPFS 上验证过 |
| OPFS 存储后端 + Worker RPC | ✅ 与内存实现共用一份契约测试 |
| 会话守卫、分类器、路线注册表、frontier | ✅ 分类器对着 6341 个真实页面校准 |
| 抓取引擎、主循环、编排 | ✅ 对真实豆瓣跑通 |
| offscreen 架构 | ✅ 抓取跑在 offscreen document 里,service worker 只调度 |
| 生命周期自恢复(心跳、崩溃哨兵) | ✅ |
| 抓取状态持久化(IndexedDB) | ✅ |
| 增量抓取 | ✅ 下界按路线沿链回溯(规范 §5.5);有缺口的路线不提供下界,从头重走。抓过的作品页 / 长文正文 / 图一律跳过(见下) |
| 完整面板 | ✅ 唯一的界面;popup 已拆掉(docs/ui.md §1.1) |
| bundle 读取器与「验一验」 | ✅ |
| 导出(File System Access)+ 回读校验 | ✅ |
| 导入(把导出过的档案搬回来) | ✅ 认 bundle_id 而不是目录名;先列出「将要发生什么」再写;残缺 / 撞编号 / 别的账号一律不导 |
| 存储管理(列表 / 删除 / 配额) | ✅ 并进档案页 —— 原来两页列的是同一批档案,只换了几列 |
| 「导出」页(canonical / NeoDB / Markdown) | ✅ 三种产出共用一次解析,中间产物不落盘,直接写进用户选的文件夹 |
| 通知与角标 | ✅ |
| 路线 | 状态 |
|---|---|
| 广播 | ✅ |
| 标记列表(书 / 影视 / 音乐 / 游戏 / 舞台剧) | ✅ |
| 作品详情页 | ✅ 由列表页派生,受广播门控 |
| 日记 / 影评书评 | ✅ 列表页只有截断的摘要,所以正文页单独抓一遍;两种日记 URL 形状都认 |
| 用户上传的图片 | ✅ 广播附图与日记正文内嵌图,只取本人上传的原图 |
| 作品封面 | ✅ 随作品详情页抓,单独成段 |
| 相册 | ❌ 明确不抓(2026-09-06 定)。个人相册这个账号确实是「创建 0」;而传到作品相册里的照片属于那个条目,不属于你的档案——实测 17 条广播、51 张。宣布这件事的那 17 条广播照抓,照片本身不抓,见下 |
| 豆列 | ✅ 只抓自己编的(doulists/all)与每份的内容页。值钱的是条目上自己写的评语——实测 134 个条目里 62 条有 |
| 移动应用(app) | ❌ 不支持,上游已废弃:列表页打得开,作品详情页全部 404(2026-08-14 实测)。你的 app 标记本身随广播照抓 |
| 为什么要紧 | |
|---|---|
| 不在这张表上了 —— 它不是「还没做」,是上游没有了,见上面的路线覆盖表 |
相册从「还没做」挪到了「明确不抓」。原来这一行写着「这个账号一个相册都没有, 没有样本可量」——那句话是错的,而且错得很安静:个人相册的确是 0,但实测有 17 条广播写着「MewX 上传了 N 张照片到《寂静之人》的相册」,一共 51 张, 其中 46 张的缩略图早就躺在已抓下来的时间线页里。样本一直在,只是没人去数。
不抓是范围决定,不是抓不到:传进作品相册的照片是对那个条目的贡献, 跟着条目走而不是跟着账号走——所有人都看得见,保留与删除由条目那边决定。这正是 「用户创作内容与目录数据永远不放在一张表里」那条规矩,所以它归目录那一侧。 上传这个动作是你的,上传的东西属于条目:那 17 条广播(你写的那句话、日期、 指向哪个相册)照进档案,51 张照片不进。真要留是 enricher 之后的事,甚至不是 enricher。
写清楚而不是删掉,是因为「查过了,决定不做」与「还没做」必须分开——下一个人 照样会数出那 51 张,然后重推一遍这件事,而这里每一次「重查」都要对着真实豆瓣点。
长广播的全文这一条查下来根本不缺:实测 804 条带正文的广播里有 2 条被截断,
而两条的「(全文)」都指向日记,两篇日记本来就完整地在档案里。所以既不用重爬也不用
加路线,只是当时没人顺着那个链接看一眼。截断本身现在记在 canonical 里
(text_truncated + full_text_url)。在给「缺的东西」造一条抓取路径之前,
先确认它是不是已经在档案里的别处。
图片抓取(F-04e)与长文正文都做完了 —— 前者曾是「备份必须联网才能看」的病根。 一次完整的全量抓取也跑过了:4 小时 16 分、5880 条捕获、零失败。 中断续导(F-08i)也做了:导出被打断之后,再导一次只补缺的那些。 导入也做完了:换机器、清过站点数据之后,以前导出的档案搬得回来, 之后照样能增量抓取——否则「导出之后可以安全删除」这句话只成立一半。
测试:npm test(零安装即可跑,85 个测试文件、1888 个测试)。装了可选开发依赖后会额外用
webrecorder 的 warcio 独立验证 WARC 输出;同级目录有 doubak-data-specs
时会额外跑跨仓库一致性检查(规范常量的新鲜度、产出与校验器的一致性)。
- Chrome 应用商店:已上架 https://chromewebstore.google.com/detail/hilmaopahndgbiolohgefnbeedobpafe
- AMO(Firefox):还没上架(
#11), slug 已定为doubak。提交要用的那几样(扩展 id、联系邮箱、版本下限、上架地址该写成 哪个形式)在docs/firefox.md,上架那天要改的几处也列在那儿。
完整流程(改版本号 → 打标签 → CI 建 release → 核对哈希)在
docs/release.md。打了 v* 标签之后 release 上挂的两份 zip 就是
提交用的,不需要另外打。想在本地各打一份:
node tools/package.mjs # dist/doubak-<版本>.zip
node tools/package.mjs --firefox # dist/doubak-<版本>-firefox.zip几件要知道的:
- 打包用白名单,不是黑名单。 名单在
tools/package.mjs的INCLUDE里。黑名单漏一条会多打进去一个不该有的东西且没人发现;白名单漏一条则是扩展装上就报错 —— 后者一眼就能看见。有测试守着test/tools/docs/不许进包。 selftest/必须打包进去。 它没有被manifest.json引用,只被调试页那个按钮getURL打开 —— 漏了它,那个按钮就是个死链。_locales/同理,而且更狠。manifest.json的default_locale指着它,漏了它 Chrome 整个拒绝加载这个扩展;而collect()只在名单里的路径不存在时才抛,名单里压根没有的目录它不看 —— 本地全绿,问题要到上传审核时才出现。test/locales.test.js守着这一条。- zip 是自己写的,不调系统
zip。 与整个工具链一致(不依赖外部程序),而且时间戳一律写 0,所以同样的源码打出逐字节相同的包 —— 才能核对「上传的到底是不是我构建的那个」。 - 版本号只能升不能降。 改
manifest.json,package.json跟着改(有测试钉住两者一致)。
逐条权限理由见 docs/store-listing.md —— 那份是直接可粘贴的。隐私政策 URL 用 https://doubak.com/privacy/。
商店的标题和简短说明不在表单里,它们取自 manifest.json,后台只读。那两格写的是 __MSG_extName__ / __MSG_extDescription__,真正的字在 _locales/<语言>/messages.json 里 —— 现在有 zh_CN / zh_TW / en 三份。分语言的商店页只对带了对应 _locales/<语言> 目录的语言开放,所以这件事的开关在代码里。界面本身没有翻译(仍然只有简体中文),_locales 只放商店那几行字,繁体和英文的说明里都写明了这一点。
「导出」页当场就能出三种东西,不用装 Node、不用 clone 任何仓库:
| 产出 | 是什么 | 拿它干什么 |
|---|---|---|
| 结构化数据(canonical) | 五个 NDJSON,每行一条 JSON | jq 直接查;也是下面两个的输入 |
| NeoDB 导入包 | 一个 zip | 传到 NeoDB「设置 → 数据 → 导入 NeoDB 备份」 |
| Markdown 站点 | 带 YAML 头的 Markdown 树 + 图片 + 搜索索引 | 交给 Hugo / Astro / Eleventy / Jekyll |
三种都是现场从档案算出来的:解析结果不落盘(派生数据落盘就是第二个真相来源), 直接写进你选的文件夹,一次只跑一个。样张:sample.doubak.com。
命令行工具做的是同一件事,适合要改参数、接进脚本、或者只想拿一部分数据的人:
| doubak-data-parser | bundle → canonical(结构化、带修订历史、纯离线) |
| doubak-site-generator | canonical → Markdown + 图片 → 静态网站 |
| doubak-export-adapters | canonical → NeoDB / Letterboxd / Goodreads 的导入文件 |
src/vendor/ 是那三个仓库里 23 个纯函数文件的逐字节拷贝,
tools/sync-vendor.mjs --check 由测试与 CI 守着新鲜度。
一次跨仓库的改动要先合上游。 CI 按各自的默认分支检出兄弟仓库,所以 vendor 名单里 新加的文件在上游合并之前根本不存在,这边的 CI 一定是红的。这不是坏事——它就是 「规范先行」那条规矩在这里的样子(见根目录 CLAUDE.md 的最后一条约定)。
不用 submodule 有个具体的理由:git clone 不加 --recursive 会留下一个存在但是空的
目录,而 tools/package.mjs 的 collect() 只在路径不存在时才抛——空目录它照收不误。
于是打包成功,装出来的扩展里没有解析器,而且一声不响。白名单当初就是为了避开这种
「静静少了东西」的失败。
界线是「字节从哪儿来各写各的,字节怎么解释只有一份」:读文件系统的那几个
(bundle-source.js、canonical.js、generate.js、images.js)不搬,
扩展这边在 src/pipeline/ 里有对应的 OPFS 版本。
什么都不用做,尤其不要删掉那份档案。
停机原因有十几种(验证码、软封锁、掉登录、断网、切账号、配额、手动暂停、关掉浏览器……),
没有一种会让你损失已经抓到的东西。每抓到一页就落一次盘,所以无论停在哪儿,
在那之前的每一页都已经在档案里了。连写盘出错也不例外:修复的方向永远是向后截断,
被截掉的那一条会在续抓时重新抓一遍(src/bundle/recovery.js),重复是免费的。
没收尾的档案没有 manifest.json —— 它只在收尾时写一次。于是账号、体积、
覆盖率证据都还看不到,但那不是损坏:
| 没收尾的档案 | |
|---|---|
| 解析 | ✅ 照常 —— 读的是索引与页面(src/pipeline/opfs-bundle-source.js 的 status) |
| 导出到磁盘 | ✅ 照常 |
| 再导入回来 | ✅ 照常,只是校验只能核对字节数(src/bundle/importer.js 的 no_manifest 告警) |
| 当下次增量的基准 | ❌ 进不了链 —— 下次会退回全量 |
| 证明某条路线抓全了 | ❌ 没有覆盖率证据 |
中止(status: aborted)与「没收尾」不是一回事:中止是会写 manifest 的,
缺口如实记进去、受影响路线的水位线不推进,所以它仍然可以当增量基准,
下次只会重走那一段。
唯一一种真的「失败」是个别页面抓不下来:那是逐页的,概览页会点名列出(哪几页、 什么原因),可以重试,也可以确认「就这样收尾」。
真正会让你永久丢东西的只有一件事 —— 没抓。广播删掉不留任何痕迹。
再导一次,选同一个文件夹。它会先看清楚上次导到哪儿,然后只补缺的那些:
上次导出到一半:12 个文件已经完整(180.4 MB),还差 8 个。
继续只会补齐缺的那些,已经完整的不动。
没有进度文件。 目的地目录本身就是进度 —— 每个文件要么完整、要么不在。记一个 export-progress.json 反而更糟:它会与真实情况不同步(用户手动删了一个文件、盘满了写了一半),而崩溃恰恰是最没机会写下状态的时刻,偏偏崩溃正是续导要解决的场景。
这条判据站得住,是因为 createWritable() 写的是临时文件、只在 close() 那一刻整体换上去:中断留下的是「没有这个文件」,不是「半个文件」。
但仍然要验,不能只看在不在。盘满、U 盘拔掉、上次导的是别的档案,都会留下一个同名却对不上的文件 —— 那种情况照样重抄。
增量的前提是**「已经抓到的东西都没有变过」,所以它跳过的不只是列表页上的旧条目。分界线是上游那份东西还会不会变**:
| 这一类 | 增量 | 为什么 |
|---|---|---|
| 列表页 | 抓到上次那条为止 | 按时间排,往回走到上次的位置就够 |
| 作品详情页 | 只抓新出现的 | 评分与短评会变,但它占档案九成体积 |
| 日记 / 影评正文 | 只抓新出现的 | 可编辑,所以重抓有意义 —— 但那是用户明说要做的事 |
| 广播 | 只抓上次之后的 | 发布后不可编辑,抓过的永远不会变 |
| 图(自己上传的、封面) | 永远跳过 | 图片地址是内容地址,重抓拿回来必然是同一批字节 |
中间两档是「增量抓取,并重新抓取可以编辑的内容」那个选项管的。图那一档没有对应的选项,因为重抓一张已有的图拿不到任何新东西 —— 那不是一个「要不要」的选择。
这条规则是被一个真实的浪费逼出来的。 一次普通增量重抓了 11 张已经在档案里的图(0.77 MB),其中 3 张已经被抓过三遍(ba57a3 → 0fb09c → 157e63,每一份的 parent_capture_id 都指向自己那一趟刚抓的广播页)。
成因是派生:asset.status_photo 是从广播页上抽出来的,而增量必须重读最新那几页广播(不然发现不了新条目)。于是那几页上的图每趟都被重新派生一遍 —— 而派生这条路上从来没有过「我是不是已经有了」这道判断。作品详情页有跳过名单,长文和图都没有。
失败过的会自动重试,而这一点是整套跳过名单唯一必须保住的性质:名单里只放 verdict: ok 的那些。被拦下、没抓着、当时已被删掉(gone —— 条目可能又回来了)的,下一趟都会再试。多收一条的代价是那份东西再也不会被抓,而少收一条只是白抓一遍 —— 两个方向差着一个量级,所以判据是白名单而不是「除了列表页以外都算」(把列表页收进去,后果是整趟抓取一页都不抓)。
分档规则在 src/crawl/known-captures.js,是纯函数:offscreen 在 node 里 import 不进来(chrome.*、Worker),写在那里的逻辑就只能拿正则去比对源码,而那种判据挡不住语义错误。同一个分层理由见 crawl/backlog.js。
档案页 →「导入档案…」,选中 doubak-bundle-… 文件夹,或者它的上一级(往下找三层,所以按月归档、解压出来多套一层都认得出)。
它存在的理由: 我们一直在劝用户「导出之后可以安全地删掉扩展里那一份」。那句话只有在它回得来的时候才诚实 —— 否则下一次增量抓取找不到基准,退回全量:几小时,还要把几千个作品详情页重抓一遍。
三件事值得单独说:
① 身份是 bundle_id,不是目录名。 用户会改名、会放进 备份/2026-08/、解压两遍会得到 xxx (1)。目录名只当线索,真相在 manifest.json 与 index 文件名里 —— 而这几处必须互相对得上。对不上意味着两份档案的文件被放进了同一个目录,那时任何一种猜法都可能写出一份自相矛盾的档案(index 说自己属于 A,段文件名里写着 B)。
这一条的检查第一版写错过,而且错得很值得记:实现只取了第一个 index 文件,而排序后先出现的那个恰好与 manifest 对得上 —— 于是「三处必须一致」全票通过,另一份档案的段文件跟着一起导了进来。收全部 index 文件再比才对。
② 绝不覆盖已有档案 —— 这一条不靠界面拦着。 导入用的 Worker 只能新建文件,碰不到任何已经在那儿的字节;判据是去问存储「这个文件此刻在不在」,不是听调用方声称(src/storage/opfs-rpc.js)。它比「只许往空目录里写」更准,也更有用:补齐上次没导完的天然合法(缺的文件是新建),而后者会把这种情况一起挡掉、逼用户先删掉半份档案。
③ 先看清楚,再动一个字节。 点完文件夹先扫描、先判断、先把整份清单列出来,确认了才写。每一种「不导」都有自己的名字和理由 —— 合成一句「跳过」等于什么都没说:
| 说什么 | 什么情况 |
|---|---|
| 已经有了 | 文件与字节数完全一致,不必再导 |
| 补齐 | 上次导到一半,只补缺的那几个 |
| 重复 | 同一份档案在选的目录树里出现了不止一次 |
| 编号撞了 | 同编号但内容不同。不覆盖 —— 说不清哪份是对的 |
| 别的账号 | 混在一起之后解析器会拒绝整个目录,而合过之后拆不开。会问一次 |
| 不能导 | 段文件缺了、字节数与 manifest 对不上、一个目录里两份档案。提示里写出具体是哪个文件 |
拒绝的代价是零 —— 源文件还在盘上,什么都没丢;而导进来一份残缺档案的代价是一份看起来正常的坏档案:能选中、能导出、能被解析器读,只是索引里有偏移量指向不存在的字节。