很多人第一次听到 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, content='paragraphs', content_rowid='id' );几个设计上的考虑值得解释一下。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', errors='ignore') 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,再失败就用 errors='ignore' 直接忽略非法字符。这样虽然会损失极少数特殊字符,但至少不会让整个导入流程崩溃。
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 之后如果要做目录级别的内容导航,这份笔记也能直接派上用场。