插画:摊开的古籍书页上写着「道可道」「非常道」,右侧卡片显示上句「孤鸿海上来」经箭头指向下句「池潢不敢顾」,旁连一张赏析小卡
Anki古籍古典文学开源项目SQLite间隔重复

把古籍做成 Anki 卡组,我顺手写了个生成器

最近迷上了读古籍。

不是那种从头啃到尾的读法——而是随手翻到哪句,念两遍,接着往下。《道德经》翻一句,「曲则全,枉则直,洼则盈」,念一遍,觉得对;再翻一句,「祸兮福之所倚」,再念一遍。半小时过去,脑子里已经过了十几句。

这件事之所以顺手,是因为工具对了。Anki 就是干这个的:它不要求你按顺序读完,只要求你今天看这一张,看完给你打个分,明天再问一遍。间隔重复的算法不用我操心,我只管喂它内容。

问题是——内容从哪来。

现成的卡组不够用

我先去找了一圈现成的古诗词卡组,用了两天,问题一个接一个:

排版难看。 打开一看,正文挤在屏幕中间一小块,字号忽大忽小,读起来眼睛累。

字段混乱。 有些卡组正面是「作者是谁」,背面塞了一整首诗的赏析;有些正面直接是上句,背面是下句——这个倒是对的——但混在同一个牌组里,我得靠自己辨认每张卡是哪类。

最要命的是没法增量更新。 Anki 靠 guid 识别笔记身份。你重新导入一个新版本的卡组,如果 guid 变了,Anki 就当成全新的笔记——我之前背了三个月积累的复习记录,全部清零。

第三条是硬伤。文本是会变的:注释会补、错字会改、赏析会写得更好。但如果每次更新都要付出「清空复习记录」的代价,那就只能忍着用一份将就的旧卡组。

干脆自己做

于是我想:既然需求这么明确,为什么不做一个生成器?

想法很简单——输入古籍原文,输出一个排版工整、可以反复增量更新的 .apkg。数据在浏览器里处理,不上传;生成的卡组自己攥着,想怎么改怎么改。

做出来是这个:AnkiMaker。

双卡型:背诵和理解是两种事

做完之后回头看,最满意的一个设计是它分两种卡。

背诵卡只问一句:上句是什么?答案给下句。比如:

上句:孤鸿海上来 下句:池潢不敢顾;

赏析卡反过来:正面给全诗,背面给赏析、译文、注释。

为什么必须拆成两种?因为它们该被复习的时机不一样。背句子是机械记忆,早上地铁上扫一遍就行;理解赏析是慢功夫,得坐下来读。这两类内容混在一张卡里,你没法安排复习——要么背诗的时候被赏析打断,要么读赏析的时候老想起那句还没背下来的诗。

分开之后,还有一个连带的好处:一条笔记最多出一张卡。

实现上靠的是字段空值判断,而不是标签。Anki 模板里能写条件渲染:

  • 背诵卡要求「上句」非空才出卡 → 原文留空的背诵条目只出背诵卡
  • 赏析卡要求「原文」非空才出卡 → 没有上句的整章条目只出赏析卡

两条线天然不串扰。我在道德经上用得最明显:81 章整章条文做成赏析卡,同时按标点把条文拆成 473 条背诵卡——一章两条线,同一本书,两种复习节奏。

稳定 guid:这是能不能用一年的分水岭

前面说的增量更新,具体是怎么做的?

Anki 识别笔记靠 guid 这个 8 字符字段。如果你给每条笔记一个稳定的 guid,那么重新导入新版卡组时,Anki 会认出「这是同一条笔记」,于是更新内容、保留复习记录。反过来,guid 变了就是新笔记,复习记录清零。

所以我给每条笔记设计了一个「身份证」:

guidSeed  "tang300:001:01"     ← 书名:序号:类型,稳定不变
   ↓ FNV-1a 64 位散列(BigInt 运算)
   ↓ 映射到 Anki 的 base91 字符集
   ↓ 取 8 个字符
guid      "Kx7mQ2pR"            ← 写进数据库,跨版本一致

关键在于 guidSeed 只依赖内容的身份(这是第几首诗的第几个背句),不依赖内容本身。将来我给《感遇》补了一句更准的注释,重新生成、重新导入——那条笔记还是原来那条,你的复习记录好好地在那儿。

这件事在 AnkiMaker 里是写死的约定:一旦发布就绝不能改。改了等于给所有笔记换身份,等于让所有人的复习记录作废。所以它在 README 里被单独列为「不能动的约定」的第一条。

牌组 id 同理,也是从牌组名稳定派生的,重复生成不会创建出「唐诗三百首 (1)」「唐诗三百首 (2)」这种垃圾。

牌组分到每一首诗

还有一处是用户提出来后我改的:原本背诵卡和赏析卡混在一个牌组里。

用户说:我想背诗的时候,只想看到诗;想读赏析的时候,只想看到赏析。

于是改成每首诗一个牌组:

唐诗三百首
└── 背诵
    └── 01 感遇(四首之一)
唐诗三百首
└── 赏析
    └── 01 感遇(四首之一)

代价是牌组数量暴涨——光唐诗三百首一部就生成了 642 个牌组,Anki 的牌组列表长得让人绝望。好处是每张牌都待在该待的地方,用起来非常干净。

我想了想,还是按用户的要求改了。工具好不好用,最终听用的人的。

真正的难点:让它能被 Anki 认出来

到这里都是数据结构的事。真正折磨人的是最后一步——Anki 认不认这个文件。

.apkg 本质是个 zip,里面装着一张 SQLite 数据库。听起来简单,但我第一次拿生成的包导入 Anki,迎接我的是:

JsonError: decoding deck config: missing field 'new'

一个一个坑踩过去,总结成一张表(都在 README 里):

报错 根因 修法
missing field 'new' 牌组配置缺新卡参数 补齐 new / rev / lapse 三组
missing field 'rev' 用了 Anki 2.1 的老键名 按 schema v11 改新键
decoding decks: JsonError decks 的 key 用了字符串名 key 必须是数字 DeckId
导入成功但全挤在一个牌组 解析失败时兜底返回了顶层牌组 建反向索引

最后一个坑最有意思:它不报错。程序以为自己成功了,Anki 也确实导入了——然后你打开一看,1980 条笔记全在同一个牌组里,分组完全没生效。不报错的错最费时间,因为你会怀疑是自己代码写错了。

现在生成时加了一道自检:下载前先回读一遍牌组配置结构,对不上就拒发并提示硬刷新缓存。这个行为有点倔,但比让用户拿到一个坏包、对着 Anki 报错发懵要好。

数据从哪来

6 部古籍,1980 条笔记,来源都要可查:

  • 道德经(554 条)—— 维基文库《老子匯校版》,王弼本通行文字
  • 唐诗三百首(640 条)—— Project Gutenberg #52323,蘅塘退士编,繁体
  • 宋词三百首(566 条)—— 维基文库朱孝臧辑本,1924 年刊
  • 金刚经(204 条)—— 鸠摩罗什译,按昭明太子 32 分则
  • 心经(9 条)—— 玄奘译
  • 逍遥游(7 条)—— 维基文库《莊子·逍遙遊》

都是公有领域文本,译文注释是自己整理的。

宋词三百首那 283 首的赏析,是我逐首写的题解——工程量不小,但这是这个项目里唯一不能自动化的部分。解析器能把原文变成结构化条目,「这首词好在哪」得有人说。

全在浏览器里跑

技术上没什么花活:sql.js(SQLite 的 WASM 版)在浏览器里建库,JSZip 打包成 zip。没有后端,没有 API,没有一行数据离开你的电脑。

这带来一个实际好处:断网能用。文本全部内置在页面加载的 JS bundle 里,不联网也能生成卡组。

代价是首次加载要下载 1MB 左右的数据 bundle 和 680KB 的 wasm。可以接受——毕竟换来的是「数据不出本机」这个卖点,而对一个要处理 1980 条文本的工具来说,这个承诺值得兑现。

现在能用了吗

能用。Anki 26.9.3 真实导入通过,双重校验都过了:

  • npm test —— 生成 zip → 解包 → 回读断言(表结构、字段分隔符、模板引用、guid 稳定性)
  • verify_apkg.py —— 用 Python 的 zipfile + sqlite3 独立再读一遍,跟 Node 生成的结果对账

第二条是我特意加的。同一个实现自己验自己,容易变成自说自话。换一个完全不同的技术栈重新读一遍,才知道包是不是真对了。

页面长这样:

AnkiMaker 界面:左侧六本古籍可选,右侧显示唐诗《感遇》的背诵卡与赏析卡双栏预览

不想装环境的话,直接打开 kingsir.work/AnkiMaker 就能用——选书、点生成、下载 .apkg,全程在浏览器里,不注册也不上传数据。

一点感想

做完之后回头看,最有价值的其实不是那个生成器,是逼自己想清楚的一件事:

「我到底想怎么读这些古文?」

不是从《道德经》第一章读到第八十一章——那太重了,厚到没人真的读完。是随手翻到哪句,念两遍,接着往下。前者是任务,后者是习惯。

Anki 恰好是为后者设计的工具:碎片时间、内容随机、靠重复留下来。而一旦按这个目标去设计,卡型怎么分、牌组怎么放、guid 怎么稳,全都跟着清楚了。

工具适配使用方式,而不是反过来。中文博大精深,但一次能消化的量就那么点。把它切成两千张卡,反而是认真对待它的方式。


项目开源了,代码随便看随便用:AnkiMaker;想先试试的走在线版:kingsir.work/AnkiMaker。如果你也读古籍,欢迎提 issue 说你想加哪本书——加一本书的流程我写在 README 里了,照着做就行。

MIT 许可,随便拿去改。