纯浏览器端生成 Anki 卡组(.apkg):ankipack + sql.js 实战与踩坑记录

纯浏览器端生成 Anki 卡组(.apkg):ankipack + sql.js 实战与踩坑记录

最近在做一个 PDF 转记忆卡片 的功能时,我把 .apkg 文件的生成从服务端整体迁移到了浏览器端。整个过程踩了不少坑,网上又几乎没有中文资料,这里完整记录一下,给后来人省点时间。

一、为什么要放在浏览器端做

.apkg 本质上是一个 ZIP 包,里面装着一个 SQLite 数据库(collection.anki21b,zstd 压缩)和媒体文件。也就是说,"生成一个卡组" = 建一个 SQLite 库 + 写 notes/decks 表 + 打包成 ZIP。

一开始我是放在服务端做的:前端把编辑好的卡片 POST 回来,服务端打包后返回二进制流。能用,但有两个问题:

  1. 这个接口没法收钱也不敢收钱------用户生成卡片时已经扣过费,导出只是重新打包,再扣一次说不过去;
  2. 免费 + 登录即可 = 攻击面。每次请求都是实打实的 CPU 开销(SQLite 建库 + zstd 压缩),刷起来很舒服。

后来想明白一件事:卡片数据本来就 100% 来自前端请求体,服务端纯属充当一个"昂贵的序列化工具"。那为什么不直接在浏览器里做完?服务器不再经手任何数据,上面两个问题直接消失。

二、选型:为什么是 ankipack

浏览器端生成 .apkg 的 JS 库,能找到的大致三个:

状态 备注
genanki-js 2021 年后停更 AGPLv3 协议(商用注意),非 npm 包,要自己拼 sql.js + JSZip + FileSaver
anki-apkg-export 多年失修 webpack loader 时代的产物,有 sql.js 内存限制的已知 issue
ankipack 活跃维护 MIT,TypeScript 原生,官方支持浏览器/Node/Bun

ankipack 几个打动我的点:

  • 写的是 Anki 现代格式(schema V18、protobuf 编码的 deck config、FSRS 调度参数),Anki 2.1.55+(2022 年中以后的所有版本)原生导入;
  • sql.js 是 optional peer dependency,由调用方初始化后传入------这意味着你可以完全控制 WASM 二进制的加载时机;
  • 运行时依赖只有三个(fflate / fzstd / @bufbuild/protobuf),浏览器 bundle 约 63 kB gzipped,都不重。

三、核心实现

思路很简单:ankipack 和 sql.js 都不走首屏,用户第一次点"导出 .apkg"时才动态 import;WASM 文件放静态目录,让 CDN 缓存去扛。

typescript 复制代码
import type { SqlJsStatic } from 'sql.js';

let sqlJsPromise: Promise<SqlJsStatic> | null = null;

/** sql.js WASM 约 640KB ------ 每个页面只加载一次,而不是每次导出都加载 */
function getSqlJs(): Promise<SqlJsStatic> {
  if (!sqlJsPromise) {
    sqlJsPromise = (async () => {
      const initSqlJs = (await import('sql.js')).default;
      return initSqlJs({
        locateFile: () => '/sqljs/sql-wasm.wasm',
      });
    })();
  }
  return sqlJsPromise;
}

export async function exportAnkiApkg(cards: AnkiCard[], deckName: string) {
  const [{ Package, Deck, Note, Notetype }, SQL] = await Promise.all([
    import('ankipack'),
    getSqlJs(),
  ]);

  const deck = new Deck({ name: deckName });
  const basic = Notetype.basic();
  const cloze = Notetype.cloze();

  for (const card of cards) {
    const fields = [toApkgField(card.front), toApkgField(card.back)];
    const notetype = card.type === 'cloze' ? cloze : basic;
    deck.addNote(new Note({ notetype, fields, tags: card.tags }));
  }

  const pkg = new Package();
  pkg.addDeck(deck);
  // 直接拿到字节流,Blob 一下触发下载即可
  return pkg.toUint8Array(SQL);
}

下载部分就是标准操作:

typescript 复制代码
const bytes = await exportAnkiApkg(cards, deckName);
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${deckName}.apkg`;
a.click();
URL.revokeObjectURL(url);

四、踩坑记录(本文最有价值的部分)

坑 1:webpack 客户端构建直接报错

上线前构建就挂了:

复制代码
Module build failed: UnhandledSchemeError: Reading from "node:fs/promises"
is not handled by plugins (Unhandled scheme).

原因:ankipack 的 writeToFile()(Node/Bun 专用方法)里有一句 await import("node:fs/promises")。浏览器导出路径根本不会执行这段代码,但 webpack 会静态分析所有 动态 import,而 web target 不认识 node: 协议。

第一个想到的 resolve.alias 指向空模块------没用 ,alias 对 node: scheme 的请求不生效。最后有效的方案是 NormalModuleReplacementPlugin

javascript 复制代码
// next.config.js
if (!isServer) {
  const webpack = require('webpack');
  config.plugins.push(
    new webpack.NormalModuleReplacementPlugin(
      /^node:fs\/promises$/,
      'data:text/javascript,export default {}'
    )
  );
}

用 Vite/esbuild 的话同理:esbuild 加 --alias:node:fs/promises=./empty.js(指向一个 export default {} 的空文件)即可。

坑 2:sql.js 的 browser 入口默认找的 WASM 文件名不一样

sql.js 的 package.json 里 browser 入口是 sql-wasm-browser.js,它默认去找的文件名是 sql-wasm-browser.wasm ,而 Node 入口找的是 sql-wasm.wasm。如果你的 locateFile 写成拼接文件名:

typescript 复制代码
locateFile: (file) => `/sqljs/${file}`  // browser 下拼出 /sqljs/sql-wasm-browser.wasm ------ 404

dist 目录里这两个 .wasm 文件其实是字节级相同 的。最稳的做法是别管传入的文件名,直接固定路径:locateFile: () => '/sqljs/sql-wasm.wasm'

坑 3:TypeScript 5.7 的 Uint8Array 泛型

TS 5.7 之后 Uint8Array 有了泛型参数,toUint8Array() 返回的 Uint8Array<ArrayBufferLike> 直接塞进 new Blob([...]) 会报类型不兼容(SharedArrayBuffer 缺属性之类的提示看着很吓人)。复制一层即可:

typescript 复制代码
new Blob([new Uint8Array(bytes)])

五、怎么验证产出的 .apkg 是合法的

不用装 Anki 也能做自动化验证,两个层面:

  1. magic 检查 :apkg 是 ZIP,头两个字节必是 PK
  2. 解开看内容 :ZIP 解压后拿到 collection.anki21b,头四个字节是 zstd 的 magic(28 b5 2f fd),用 fzstd 解压后再拿 sql.js 读 notes 表,能查到卡片就算通。

我在真实浏览器里跑了这条完整链路(加载 WASM → 打包 → 校验产物),全部通过后才敢上线。浏览器里直接执行打包逻辑的验证方式也简单:把测试脚本用 esbuild 打成 IIFE,在页面上下文里 eval,检查全局变量上的结果即可。

写在最后

这套方案跑起来之后,导出变成了纯本地操作:用户点按钮,第一次多花一秒左右加载打包模块(之后有缓存),之后所有导出零网络请求。对用户是隐私加分(卡片内容不出浏览器),对站长是砍掉了一个需要限流的免费接口。

我把这套东西用在了自己做的 PDF 转 Anki 卡片 功能里------上传 PDF,AI 起草问答卡或填空卡,浏览器里逐张编辑筛选,然后一键导出 .apkg 直接导入 Anki,全程卡片数据不离开浏览器。如果你也在做类似的功能,欢迎交流。

参考:

相关推荐
曲幽1 个月前
Anki插件开发必知必会:钩子函数与右键菜单定制
python·fastapi·anki·menu·browser·addons
yivifu2 年前
利用CSS隐藏HTML元素并插入替代内容
前端·css·html·anki
FOUR_A2 年前
【Anki】25考研408真题【2009-2023】
数据结构·学习·计算机网络·考研·anki·408·计算机组成原理
繁星蓝雨3 年前
高效学习工具之AnkiMobile新手入门指南(ios端,包括ipad、ihpone设备)————创建、使用、备份、设置参数、相关资料
anki·学习工具·记忆学习·考试复习·fsrs算法·ankimobile
NGC 6043 年前
关于anki的一些思考
anki