很多人第一次听到 REA 这个名字都会以为是某个开源项目的缩写后缀或者某款工具的精简代号。其实它是我自己写的一个本地阅读与标注管理工具全称是 Reading Efficiency Assistant这名字有点绕一般我都直接叫 REA。做这个项目的起因特别朴素我日常要读的东西太杂了PDF 论文、EPUB 图书、公众号导出的 Markdown、零散的 TXT 摘录这些文件散落在电脑、平板、手机各个角落划线笔记也是各存各的真到想找一条之前标记过的观点时往往要翻好几个软件折腾得没脾气。REA 想解决的核心问题就是这一件把阅读、标注、检索和进度管理收敛到一个工具里所有数据留在本机不上传任何云端不需要注册账号导出来的数据也随时可以带走。这篇文章我会把整个项目的需求拆解、技术选型、核心实现、踩坑记录完整过一遍。如果你也打算做一个类似的自用工具或者想入门本地优先应用开发这篇文章基本能当一份完整的参考笔记用。1. 项目概述REA 到底做了什么1.1 核心需求与用户场景先说说我最初列出的需求清单其实只有四条能统一导入 EPUB、Markdown、TXT、PDF 这四类最常见的文档格式阅读时支持高亮划线和随文批注批注要能导出全文检索速度要快几万条标注也能秒级返回结果记录每本书的阅读进度和阅读时长隔多久打开都知道读到哪这四条看起来不多但每一条单独拿出来都有一堆坑。比如 EPUB 本质是个 ZIP 压缩包里面是 HTML 文件组成的解析时要处理章节顺序、样式、图片资源PDF 的文本提取则受编码和排版影响容易漏字乱码。更麻烦的是标注系统和文档结构要绑定划线的位置一偏移高亮就串行。所以这个项目真正花时间的不是功能堆砌而是把这些细节磨到能日常使用。1.2 为什么还要自己造轮子肯定会有人问市面上的阅读器那么多为什么要重复造轮子我当时的判断是这样的云端笔记类应用同步方便但数据都在别人手里导出格式还受限本地优先的开源阅读器大多只专注单一格式要么只管 EPUB要么只管 PDF极少有工具能把四类格式统一处理还能顺手做全文检索。我需要的不是大而全的商业产品而是一个“数据完全可控、格式足够宽容、检索足够快”的私人工具。REA 的思路很明确它不做多端实时同步不做社交分享不搞推荐算法只专注本地阅读和标注这两件事。你可以把它理解成给自己搭的一个小型数字书房所有东西都摆在自家书架上规则由自己定。2. 技术选型与架构设计2.1 本地优先数据必须握在用户手里“本地优先”在 REA 里不是一句口号而是贯穿所有设计决策的底层原则。具体表现有几点首先所有解析、索引、存储都在本机完成没有任何外部 API 调用其次导出功能直接生成标准格式的文件包括 Markdown 标注清单、JSON 备份和 ZIP 打包最后数据库文件就是一个普通 SQLite 文件拷走就能迁移不需要额外服务。这个设计带来的直接好处是隐私和可迁移性。我喜欢划线时会写一些比较私密的感想这些东西如果放在某个云笔记里总感觉被人看着。放在本地就算电脑被偷只要没有解密手段数据也只是一堆二进制文件不会直接被别人读走。另外迁移成本极低把数据库和导入的原始文档目录复制到新机器上所有标注和进度原样恢复实测几分钟就能搞定。2.2 存储引擎选择SQLite FTS5存储层我选了 SQLite而不是 MySQL 或者直接在文件系统里铺 JSON理由很实际SQLite 几乎没有运维成本一个文件搞定支持事务还有内建的全文搜索扩展 FTS5。对于 REA 这种单机应用SQLite 的性能完全够用甚至在几万条标注、几十万条段落记录的规模下查询依然是毫秒级。FTS5 是 SQLite 自带的全文索引模块支持中文分词需要做一点额外处理但整体上比自己在应用层写倒排索引省事得多。我在设计时把文档段落拆成单独的表每个段落都建 FTS5 索引这样搜索粒度可以精确到段落而不是整本书。关于中文分词的细节后面第 4 章会详细展开。2.3 服务与界面轻量服务加浏览器前端架构上 REA 采用了一个非常轻的本地服务模式后端用 Flask 提供 API前端是纯 HTML、CSS、JavaScript 的单个页面通过浏览器访问本地端口。为什么不直接用 Electron 或者 Tauri 那种桌面壳因为对我这种自用工具来说Electron 打包体积大、内存占用高Tauri 又需要 Rust 工具链。用 Flask 加浏览器方案开发简单调试直观而且手机同网段的设备也能直接访问同一个服务临时想在平板上看图也很方便。启动方式就是把服务跑起来然后自动打开浏览器。后端负责所有数据处理前端只负责渲染和交互两者之间通过 JSON 格式的 API 通信。界面做得很朴素左侧是书架列表中间是阅读区右侧是标注面板没有多余的设计。3. 核心功能实现细节3.1 多格式解析四种文档的统一结构REA 的第一步难点就是把四种格式的文档转成统一的内部结构。我设计了一个通用的“章节-段落”两级模型不管原始格式是什么最终都变成这样一层结构。EPUB 解压后会得到一个 OPF 文件里面声明了阅读顺序和内容文件列表按照顺序解析每个 HTML 文件去掉标签后按自然段切分并保留关键锚点。Markdown 和 TXT 相对简单按空行切分段落即可。PDF 比较特殊我优先用文本提取库抽取内容按页面和行重建段落遇到扫描版 PDF 只能识别出图片这种情况我会在书库里标记为“图像版本”提醒自己这个文件没法做精细标注。这里有个重要的设计细节每个段落保存一个 64 位的哈希值作为稳定 ID。这样即使文档重新导入、章节顺序发生变化只要段落内容没有改标注就能通过哈希重新关联上大大提高了标注的鲁棒性。3.2 标注模型高亮、批注与文档结构的绑定标注系统是整个 REA 最核心的部分。每一条标注数据都包含段落 ID、起始偏移、结束偏移、选中文本快照、批注正文和创建时间。保存文本快照是为了防止原文档被替换后高亮内容无法显示至少还能在标注面板里看到当初选了哪句话。高亮映射的逻辑是阅读区渲染每段文本时把段落里的偏移量转换成前端 DOM 的字符索引范围用一个高亮标记包裹。这个方案说起来简单实际踩过不少坑尤其是中文文本在浏览器里的字符偏移是相对稳定的但遇到 Emoji、组合字符就很容易错位所以我在前端统一按 Unicode 码点来算偏移而不是简单的字符串长度。3.3 阅读进度与统计阅读进度我用两层数据来记录一是每本书当前读到的章节 ID 和段落 ID二是按天累计的阅读时长。进度保存的逻辑是每阅读一个段落就记录一次“最后阅读位置”同时每隔 30 秒把这段时间计入今天的阅读时长统计里。页面重新打开时直接根据保存的位置跳转到指定段落。为了防止跳转位置过于粗糙我还给每个段落生成了“锚点行号”跳转时先对齐章节再按照行号滚动到具体文本行。3.4 全文检索从段落索引到秒级返回检索功能依赖 FTS5 虚拟表我索引的是每个段落的纯文本。查询时支持关键词匹配、多关键词 AND/OR 组合以及按书名过滤。为了得到更符合阅读场景的结果我把段落所属的书名、章节名也冗余进了索引这样搜索“分布式 共识”这种组合词时即使词出现在不同段落也能通过关联查询聚合到同一本书。关于中文分词的坑我单独在后面列了一节。4. 实操过程与关键代码4.1 数据库初始化脚本先贴一下最基础的数据库表结构这部分直接决定了后面所有功能的开发效率。CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT DEFAULT 未知作者, format TEXT NOT NULL, source_path TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)), last_read_at TEXT, progress REAL DEFAULT 0 ); CREATE TABLE sections ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL REFERENCES books(id), section_index INTEGER NOT NULL, title TEXT, anchor TEXT ); CREATE TABLE paragraphs ( id INTEGER PRIMARY KEY AUTOINCREMENT, section_id INTEGER NOT NULL REFERENCES sections(id), para_index INTEGER NOT NULL, content TEXT NOT NULL, content_hash TEXT NOT NULL UNIQUE ); CREATE TABLE annotations ( id INTEGER PRIMARY KEY AUTOINCREMENT, paragraph_id INTEGER NOT NULL REFERENCES paragraphs(id), start_offset INTEGER NOT NULL, end_offset INTEGER NOT NULL, selected_text TEXT NOT NULL, note TEXT, color TEXT DEFAULT yellow, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE VIRTUAL TABLE paragraphs_fts USING fts5( content, book_title, section_title, contentparagraphs, content_rowidid );几个设计上的考虑值得解释一下。paragraphs 表的 content_hash 加唯一约束用来避免重复索引同样的段落FTS5 表使用外部内容模式这样段落的增删改只需要同步维护索引不需要复制一份冗余数据。外部内容模式比较适合段落量大的场景因为段落可能有几十万行冗余一份全文太占空间。4.2 导入与解析流程导入流程是一个流水线文件上传到临时目录根据扩展名分发到对应的解析器得到统一的章节段落结构后写入数据库最后重建 FTS 索引。下面以 EPUB 解析为例。import zipfile from xml.etree import ElementTree as ET def parse_epub(filepath): chapters [] with zipfile.ZipFile(filepath) as zf: names zf.namelist() opf_name next(n for n in names if n.endswith(.opf)) with zf.open(opf_name) as f: tree ET.parse(f) ns {opf: http://www.idpf.org/2007/opf} spine_items tree.findall(.//opf:itemref, ns) manifest {item.get(id): item.get(href) for item in tree.findall(.//opf:item, ns)} for item in spine_items: href manifest[item.get(idref)] html_path href with zf.open(html_path) as f: html_content f.read().decode(utf-8, errorsignore) text extract_text_from_html(html_content) chapters.append(text) return chaptersEPUB 解析里最容易出错的就是命名空间和路径问题。OPF 文件里的 href 是相对路径需要跟 zip 内实际路径做拼接不同出版商的 EPUB 文件结构差异又大所以我写了一个通用的路径规范化函数把所有../和./都解析干净避免出现“文件找不到”的尴尬。PDF 解析则是另一套逻辑。PDF 的文本提取质量完全取决于文件本身有些 PDF 的文本层是完整嵌入的提取出来直接可用有些则是图片扫描件提取出来是空的。所以我加了检测机制如果提取出来的字符数少于页数的 10 倍就判定为图像型 PDF直接标记成不可标注。这一步测试时至关重要否则后期做全文检索会频繁出现“搜不到内容”的假象。4.3 标注接口与前端交互标注的交互流程是拖选一段文字松开后弹出一个小工具条点击“添加标注”就把选中文本发送到后端。这里有一个不少人会忽略的细节前端必须把选中文本的绝对字符偏移算出来否则高亮位置不准。我的做法是在渲染段落时给每个字符包一层带索引的 span通过遍历选中范围的 DOM 节点累计偏移量再换算成段落内的起止偏移。后端接口设计得非常简短一个新增标注、一个查询标注、一个删除标注app.route(/api/annotations, methods[POST]) def add_annotation(): data request.get_json() para_id data[paragraph_id] start data[start_offset] end data[end_offset] text data[selected_text] note data.get(note, ) cur db.execute( INSERT INTO annotations (paragraph_id, start_offset, end_offset, selected_text, note) VALUES (?, ?, ?, ?, ?), (para_id, start, end, text, note) ) db.commit() return {id: cur.lastrowid}前端拿到返回的标注 ID 后把自己的高亮标记写入对应区间这样一个标注就算完整创建了。批注编辑我采用了“点击高亮再编辑”的方式点一下高亮文字右侧面板会显示该标注的详细信息和笔记编辑框修改内容实时保存。这个交互不是最优的但在纯前端页面里实现起来最直接不依赖任何富文本编辑器降低了不少复杂度。4.4 中文检索的分词与查询SQLite 的 FTS5 默认分词器对英文友好对中文就是按单字拆分查“软件”能把“软”和“件”分开索引结果就是搜什么都能匹配一堆无关内容。我的解法是分词后在应用层先做一步处理。我用了一个轻量的分词思路先按中文分词库把段落切成词序列再把这些词用空格拼接后存入 FTS5。由于外部内容模式下索引内容和原表内容是分离的所以我可以单独维护一个分词后的索引文本检索时同样先把查询语句分词再交给 FTS5 查询。import jieba def tokenize_chinese(text): words jieba.cut(text) return .join(w.strip() for w in words if w.strip()) def rebuild_index_for_book(book_id): rows db.execute( SELECT p.id, p.content, b.title, s.title AS section_title, p.content AS raw_content FROM paragraphs p JOIN sections s ON p.section_id s.id JOIN books b ON s.book_id b.id WHERE b.id ? , (book_id,)).fetchall() for row in rows: tokens tokenize_chinese(row[raw_content]) db.execute( INSERT INTO paragraphs_fts (rowid, content, book_title, section_title) VALUES (?, ?, ?, ?), (row[id], tokens, row[title], row[section_title]) ) db.commit()查询时把用户输入也做同样的分词处理然后构造 FTS5 的 MATCH 语句。需要注意的一点是jieba 这类分词库的词典对专业词汇覆盖不够比如某领域的专有名词会被切开。我的处理是在项目里维护了一个自定义词典文件把自己经常读的领域术语手动加进去实测检索准确率提升非常明显。4.5 进度保存与恢复进度保存的代码反而最简单但逻辑上容易被忽略。每次阅读时前端报告当前可见的首个段落 ID后端只在“段落 ID 变化”时更新一次。恢复时根据保存的段落 ID 找到所在章节和行号前端跳转定位。阅读时长统计则是用一个后台定时器每 30 秒向服务端发一次心跳服务端累计时间。这个方案不算精确但对“大概知道这本书花了多少时间”这个需求完全够用。5. 常见问题与排查技巧实录5.1 编码问题乱码率最高的几种情况EPUB 内部 HTML 的编码声明不一定准确有的文件声明 UTF-8 实际是 GBK有的声明 UTF-8 却夹杂非法字节。我的处理是先尝试用声明的编码解码失败则回退到 UTF-8再失败就用 errorsignore 直接忽略非法字符。这样虽然会损失极少数特殊字符但至少不会让整个导入流程崩溃。TXT 文件的编码问题更普遍。Windows 上常见的本地 TXT 是 GBK 编码导入时如果默认按 UTF-8 读所有中文都变成乱码。我的做法是使用一个检测策略先尝试 UTF-8如果解码过程出现异常就改用 GB18030 解码这个编码兼容 GBK能覆盖绝大多数中文 TXT 文件。5.2 检索索引与数据不同步外部内容模式的 FTS5 表有一个天然问题如果往原表里直接插入数据而不同步更新索引表搜索就会漏结果。我踩过的坑是在开发早期直接在段落表里新增数据忘了触发索引重建。排查起来特别隐蔽因为段落本身能看到搜索却查不到。后来我加了一个简单的一致性检查任务定期对比原表和索引表的行数不一致时自动重建。另一个坑是删除原表数据时FTS5 会出现内容索引不匹配的报错。所以我在删除段落时一律先删索引表里对应的行再删原表行顺序不能反。5.3 高亮偏移错位高亮偏移错位是标注工具最常见的体验问题。我总结出几个高频原因前端把换行符算进了偏移后端按不含换行的文本存导致高亮整体前移段落内容里有连续空格浏览器渲染会合并但字符偏移没变视觉上高亮位置偏用 innerHTML 处理文本时HTML 实体被转义字符数对不上我的解决办法是统一在解析阶段把段落里的换行符全部替换为空格保持前后端文本完全一致并且对 HTML 实体做反转义后再计算偏移。这里建议在开发阶段就写一组偏移自检测试用一个包含中文、英文、数字、标点的样本段落反复校验。5.4 实践心得给标注数据加快照前面提到我在 annotations 表里保存了 selected_text 快照这是实际使用中非常重要的一步。有一次我导入了一个 PDF后来发现原文件排版有问题重新转换后再导入段落哈希全部变了原来的高亮全部失效。但因为保存了选中文本快照至少标注内容没有丢还能通过搜索快照找回。这个教训让我后来在每个核心实体上都尽量保留冗余的可读信息宁可多占一点存储也不能让用户的笔记因为格式迁移而消失。6. 项目复盘与后续扩展6.1 还能继续做的方向REA 目前是一个能用的状态但离一个完整的产品还有距离。我自己列了几个后续优先级较高的方向一套基于 WebSocket 的实时协同标注方便两台设备同时看书针对 PDF 的侧栏笔记模式目前扫描版 PDF 完全不可用至少在界面上可以做一个图片分页浏览加浮动笔记的替代方案还有批注的语义搜索用嵌入向量做近似检索这样即使不记得原文关键词也能用一句大意找到相关笔记。6.2 几点实在的经验做完这个项目我有几个体会想分享给也想做自用工具的朋友。第一本地优先不是技术选型而是产品立场所有功能都要围绕“数据随时可迁移”来做否则很容易在后期被云服务绑架。第二解析层是这类工具的隐形工作量四类格式看起来不多真正落地时每一类都是一个小项目建议先把最常用的格式做扎实再扩展偏门格式。第三检索功能不要在早期投入太多先实现一个能用的简单版本随着数据量增长再优化REA 的第一版甚至没有检索等标注数据过了 5000 条才补上 FTS。最后说一个实际使用中的细节。现在每次导入新书我都会顺手把书的目录页单独存一份纯文本笔记归到“目录”这个特殊章节里哪怕格式有点乱也没关系。这样查找某一章讲什么的时候直接搜索目录内容往往比全文检索更快也更准确。这个习惯是从一次找“第 7 章关于缓存的问题”翻了很久全文之后养成的REA 之后如果要做目录级别的内容导航这份笔记也能直接派上用场。
阅读完成 · 觉得有帮助?