
阅读习惯坚持不下来买书如山倒读书如抽丝这大概是所有阅读爱好者共同的痛点。去年我用业余时间做了个阅读助手APP把“读完一本书”这件事拆成了几个可以量化的动作上传书籍自动生成摘要摘出核心观点和好词好句随手标注笔记每天记录阅读时长最后形成一张阅读打卡日历。这篇文章就把我落地这个项目的完整过程、技术选型、核心实现和踩坑记录都写出来给想动手做同类产品的朋友一份能直接参考的复盘。这个项目并不是那种到处都需要黑科技的大型系统但它把“阅读”这个流程中容易被忽略的技术点都串了一遍文件解析、中文分词、抽取式摘要、标注存储、阅读计时、日历聚合。过程中有不少细节只有做到了才会踩到比如PDF解析后文本全是乱码、TextRank算法跑出来的摘要把“但是”和“此外”当成了核心句、计时在后台被系统杀掉导致打卡断签等等。下面我按项目的实际推进顺序来写从需求拆解到技术实现再到问题排查尽量做到可以照着复刻。1. 产品定位与功能拆解1.1 阅读爱好者的真实需求是什么做产品之前最忌讳一上来就写代码。我对身边喜欢读书的朋友做了一圈调研发现大家共同的痛点不是“找不到书”而是“读不完”“记不住”“没反馈”。囤了上百本电子书真正读完的不到十分之一读完一本书记不住核心观点过两个月跟没读一样每天阅读时间零散总觉得没进度。于是这个APP的核心价值被我定义为帮用户在碎片化的阅读场景里快速抓住一本书的要点并持续获得正向反馈。围绕这个定义我把产品的功能清单拆成了六项上传书籍/文章自动解析文本内容自动生成阅读摘要提炼核心观点提取好词好句方便摘抄和后续回顾支持对原文进行标注写阅读笔记记录阅读时长形成个人阅读数据按日期生成阅读打卡日历建立打卡习惯这六个功能看起来独立实际是一条完整的产品链路输入上传/阅读→ 加工摘要/词句→ 沉淀笔记/数据→ 激励打卡日历。如果只做其中的摘要或只做打卡用户体验会很单薄把它串起来之后用户的每次阅读都会变成可积累的数据资产。1.2 优先级排序与MVP范围这个产品最初版本我做了严格的功能取舍。自动摘要和好词好句是差异化亮点但没有上传解析就无从谈起所以第一步必须打通“上传→解析→展示”阅读计时和打卡是用户持续打开APP的动力属于低成本高价值的功能尽早放进来阅读笔记则要在阅读器稳定之后才能做好否则标注内容容易存废。最终的MVP版本只包括TXT/EPUB/PDF上传、纯文本阅读器、抽取式摘要、好词好句展示、划线笔记、阅读计时、周视图打卡日历。像账号系统、云端同步、分享海报、书单管理这些都放到第二版。这样做的理由是先把核心路径跑通避免一开始就被账号和同步的复杂性消耗过多精力。2. 技术选型与整体架构2.1 客户端为什么选Flutter而不是原生客户端选了Flutter原因很直接项目是我一个人业余开发不可能同时维护iOS和Android两套原生代码。Flutter在跨端场景下的渲染一致性做得不错阅读器这种文本密集型界面它的TextPainter和自定义选择器也能满足需求。具体用到的核心依赖大概有file_picker负责选择本地文件epub_view/pdfx处理EPUB和PDF预览sqflite本地存储笔记和阅读记录shared_preferences轻量偏好设置自研的TextSelection扩展实现划线标注有朋友问我为什么不直接用原生开发套壳WebView我的回答是阅读器需要精细的文本选择、翻页动画、亮度调节和夜间模式WebView在这些交互上做起来很别扭尤其是中文文本标注的坐标换算用原生组件反而更稳定。2.2 服务端职责与数据处理管线摘要提取、好词好句筛选、阅读时间统计这些计算放在了后端前端只负责上传文件、展示结果和收集用户行为。前后端用FastAPI搭接口模型层直接用Python生态这样后面接更复杂的NLP模型时也不需要重写。服务端的处理管线我设计成了五步接收文件按扩展名分流解析清洗文本去空行、去乱码、去目录和版权页中文分词、词性标注构建候选词集使用TextRank算法对句子排序生成摘要基于摘要句和高频词提取好词好句写回JSON这个管线的设计原则是“每个环节的结果都可解释”。比如摘要的权重矩阵可以打印出来方便排查为什么抽到了某一句好词的来源可以直接追溯到分词结果不会出现“词都不在原文里”的尴尬。2.3 摘要与关键词提取抽取式比生成式更稳文本摘要大体分两类抽取式从原文挑重要句子和生成式让模型重写一段话。生成式效果上限更高但模型响应慢而且容易“一本正经地瞎编”把原文没有的观点加进去。对阅读场景来说用户要的是“这篇文章到底讲了什么”而不是“模型觉得这篇文章讲了什么”所以我选了抽取式。抽取式摘要的核心是排序。句子重要度来自关键词频率、位置权重、与标题的相似度等信号。我在此基础上叠加了TextRank它和PageRank一个思路把句子当节点如果两个句子有重复词就建立一条边反复迭代后得到每个句子的权重然后按权重取TopK。这样做的好处是不需要标注数据任何一本书都能跑缺点是遇到文中大量的转折词和并列句时容易抽出没有结论的过渡句。后面我在预处理阶段做了去停用词和按“段首句加分”的修正效果才稳定下来。3. 核心功能实现详解3.1 文件上传与格式解析上传环节是整个产品的地基格式兼容性直接决定用户会不会流失。我第一版只支持TXT后来加了EPUB和PDF结果在解析时踩了不少坑。TXT文件是最简单的但编码是个大坑。Windows上常见的TXT是GBK/GB18030Mac和手机端多为UTF-8如果直接按UTF-8读GBK文件会出现满屏乱码。我在后端用chardet自动检测编码再配合bytes.decode()进行转换基本能覆盖95%以上的情况。EPUB文件本质是个ZIP包我用ebooklib解析循环读取get_items_of_type(ebooklib.ITEM_DOCUMENT)拿到HTML内容再用BeautifulSoup去掉标签、还原段落。这里要注意EPUB里的章节顺序不一定按文件名排列很多时候是随机的需要用spine字段维持阅读顺序否则摘要会乱序。PDF文件最麻烦。文字型PDF用pdfplumber提取还比较顺但很多电子书是扫描版必须挂OCR。MVP阶段我没有直接做OCR而是先提示用户“当前PDF不支持文字提取建议上传TXT或EPUB”后续再考虑接入PaddleOCR。不要小看这个提示它能帮你过滤掉大量无效解析请求。3.2 自动摘要生成与核心观点提取摘要模块我用的是“TextRank 位置加权 去重合并”的组合方案。完整实现可以拆成四步。第一步是文本预处理。把文章按句号、问号、感叹号拆成句子去除段落标题、版权页、目录等噪声接着用jieba分词并标注词性过滤掉停用词和数字。第二步是构建句子相似度矩阵。两句话的共同词越多相似度越高。这里的相似度不是简单的Jaccard系数而是做了一次词频加权核心词越罕见权重越高。计算时如果两句话之间没有共同词就认为无关这样能避免句子数量很多时图变成完全图计算量过大。第三步是TextRank迭代。阻尼系数我取0.85窗口大小用整篇文档的句子图不再额外设定滑动窗口。迭代20轮后每个句子会得到一个稳定分数。然后在这个分数上叠加一个“位置加成”每个小节的第一句话加0.5分最后一句加0.3分因为作者通常会在段落首尾放核心观点。第四步是抽取摘要句。默认按原文长度的1%到3%抽取句子比如一篇5000字的文章大约抽150字左右的摘要。抽取时还有一个重要步骤是去冗余如果两个候选句的相似度超过0.7只保留分数更高的那一句。这个操作能避免摘要反复围绕同一个词转圈。观点提取则是在摘要句基础上做的。我把摘要句两两合并然后统计句子里出现的高频名词短语把它们归类成3到5个主题词再为每个主题词找到它在摘要中对应的句子作为“核心观点”。这样用户在摘要页看到的不是一个整块而是“主题对应句子”的卡片形式阅读负担会小很多。import jieba.analyse import numpy as np from collections import Counter def textrank_summary(sentences, top_k3): # 简化版用词频矩阵近似句子相似度 tokens [set(jieba.analyse.extract_tags(s, topK10)) for s in sentences] mat np.zeros((len(sentences), len(sentences))) for i in range(len(sentences)): for j in range(i1, len(sentences)): common tokens[i] tokens[j] if len(common) 0: score len(common) / (len(tokens[i]) len(tokens[j]) 1e-6) mat[i][j] mat[j][i] score score np.ones(len(sentences)) for _ in range(20): score 0.15 0.85 * (score mat / (mat.sum(axis1) 1e-6)) ranked np.argsort(-score)[:top_k] return [sentences[i] for i in sorted(ranked)]3.3 好词好句的筛选逻辑好词好句的判断天然带主观性但产品不能完全主观所以我把筛选逻辑分成了两层第一层是“硬条件”保证词句本身质量第二层是“软排序”把更符合阅读审美的句子排到前面。好词筛选我用的是“词性白名单 词频 长度限制”。先利用jieba.posseg把词汇按词性过滤只保留形容词、动词、成语和部分名词然后过滤掉口语词和停用词最后按照“文档频率”排序保留出现次数适中、但具有一定书面语色彩的词。举个例子在小说里“凝视”比“看”更有价值“心不在焉”比“走神”更值得摘录。这里我还做了一个自定义词库把常见的好词如“淋漓尽致”“脍炙人口”提前加进去。好句筛选则分三步第一步句子必须以句号、问号或感叹号结束长度在20到80字之间太短没有信息量太长阅读体验差第二步句子至少包含一个好词或一个关键词第三步计算句子情感强度对带感叹号或明显情感词“悲伤”“喜悦”“愤怒”等的句子加分。经过这三步之后再从高往低取10到20句。这里有个经验之谈千万不要只按“包含关键词”去抽好句否则会抽出大量背景介绍和过渡句比如“此外他还提到了一个重要观点”这种。更好的办法是把TextRank的句子权重和好词覆盖度做加权组合权重各占50%这样抽出的句子既有信息量又有文采。3.4 阅读笔记与标注系统设计标注系统的核心难点不在画高亮而在“如何让高亮在下次打开时还能从原文中定位到”。我在MVP里用了一个简单的方案记录三件事书籍ID、段落索引、段落内字符偏移。具体做法是解析文本时把整本书按章节和段落拆成结构化对象每个段落有一个稳定ID。用户在某段文字上划选时记录start_offset和end_offset。这里有个小坑如果用Unicode字符偏移在Emoji或特殊标点上会计算不准所以我统一使用UTF-16码元偏移字符串的substring方法和底层文本处理都能对齐。笔记数据模型设计成三张表paragraphs表存段落内容highlights表存标记位置comments表存用户笔记。用户写笔记时通过highlight_id关联到原文段落这样即使后续调整了排版只要段落ID不变笔记就不会丢。class Highlight { final String bookId; final int paragraphIndex; final int startOffset; final int endOffset; final String content; final String note; final DateTime updatedAt; }阅读器页面上的交互是这样的长按选择文本后弹出菜单点击“高亮”生成一条标记点击“笔记”会打开底部弹窗输入文字。所有标注先写本地SQLite再在Wi-Fi状态下异步同步到服务端。同步方案我用了最简单的“时间戳软删除”每条记录带updated_at同步时两边取较新的版本。这个方案应付单人单设备绰绰有余但如果是多设备同步还需要引入UUID和冲突合并策略建议第二版再加。3.5 阅读时长记录与阅读打卡日历阅读时长统计的门槛比想象中高。用户不是随时都在屏幕上翻页间隙、切后台回微信、锁屏听播客这些状态都要处理。我采用的是“前台计时 后台暂停 定期上报”策略。APP进入阅读器页面时启动计时器每隔15秒向Local DB写入一条累计时长APP进入后台时暂停计时从后台回前台时先检测当前时间与上次记录的间隔如果超过3分钟就认为这段时间不算阅读时长因为用户大概率切出去干了别的。为了过滤异常数据服务端还会做二次校验单次阅读时长小于10秒的记录直接丢弃单次阅读时长超过3小时的记录拆分到3小时上限每天总阅读时长最多只取12小时打卡日历则是读数据库里的阅读记录按日期聚合总分钟数。只要当天阅读超过5分钟就算打卡成功。日历视图我用了table_calendar把每天的阅读时长映射成圆环和大数字连续打卡天数在顶部展示。这里要注意时区问题如果服务器按UTC存时间客户端直接格式化会出“昨天打卡丢失”的Bug所以记录时间要统一用ISO 8601带时区存展示时再转本地时区。4. 实操过程与核心环节实现4.1 环境搭建与依赖清单整个项目我用了前后端分开的仓库。后端是Python 3.10 FastAPI uvicorn前端是Flutter 3.x。这里我给一份我实际用的关键依赖清单版本号不一定最新但组合起来很稳。模块技术选型主要用途文件解析pdfplumberebooklibchardetPDF/EPUB/TXT解析与编码检测NLPjiebajieba.analysenumpy分词、词性标注、TextRank后端框架fastapiuvicorn提供上传和摘要API移动端框架flutterdartiOS/Android跨端APP本地存储sqfliteshared_preferences笔记缓存、阅读记录日历组件table_calendar打卡日历界面建议在Python虚拟环境里装包不然jieba的词典加载会和系统Python发生冲突。Flutter端我用了dart pub add没有手动改过pubspec.yaml这样依赖版本不容易冲。4.2 服务端摘要接口实战服务端核心接口是POST /api/summary接收文件返回摘要、好词好句和核心观点。代码实现上我把解析和摘要拆成两个service方便单独调试。from fastapi import FastAPI, UploadFile from services.parser import parse_file from services.summarizer import summarize app FastAPI() app.post(/api/summary) async def create_summary(file: UploadFile): content, meta parse_file(file.filename, await file.read()) result summarize(content) return { title: meta.get(title, file.filename), summary: result[summary], keywords: result[keywords], good_sentences: result[good_sentences], }跑起来之后可以用uvicorn main:app --reload启动然后通过FastAPI自带的/docs页面手动上传文件测试。第一次测试我传了一本《活着》的EPUB摘要出来后发现结尾把“苦根也死了”当成了核心观点原因就是这段文本在书里很短但情感权重极高后来我在好句筛选里加了“是否为中心人物出现”的上下文判断才缓解了这个问题。移动端上传代码很简单用file_picker拿到文件路径后直接以multipart/form-data形式POST到后端。为了照顾大文件我限制了上传大小为20MB超过的会提示用户压缩后再试。4.3 移动端阅读器与标注的实现阅读器页面是整个APP里最复杂的一块。文本展示我用了ScrollableRichText而不是WebView因为要做自定义选择回调。核心思路是把段落列表渲染成一列每段一个RichText当用户长按触发系统选择时通过TextSelection回调取到起止位置再换算出段落索引和偏移量。SelectionArea( onSelectionChanged: (selection) { if (selection ! null !selection.isCollapsed) { final offset selection.baseOffset; final paragraphIndex _getParagraphIndex(offset); final start _getLocalOffset(paragraphIndex, offset); _showAnnotationMenu(paragraphIndex, start); } }, child: Column(children: _buildParagraphs(_paragraphs)), )这里有一个真实的踩坑经历如果直接用SelectionArea包裹整个ColumnAndroid和iOS的选中回调行为不一致iOS经常拿到全局坐标而不是局部坐标。后来我在每个段落外面单独包了一层SelectionArea再通过TextPainter做坐标换算选择定位才稳定下来。4.4 数据存储与同步方案移动端本地存储我用SQLite表结构大致如下CREATE TABLE books ( id TEXT PRIMARY KEY, title TEXT, author TEXT, file_path TEXT, created_at INTEGER ); CREATE TABLE highlights ( id TEXT PRIMARY KEY, book_id TEXT, paragraph_index INTEGER, start_offset INTEGER, end_offset INTEGER, content TEXT, updated_at INTEGER ); CREATE TABLE reading_sessions ( id TEXT PRIMARY KEY, book_id TEXT, start_time INTEGER, duration_seconds INTEGER, date TEXT );同步到云端时我会把highlights和reading_sessions中updated_at大于上次同步时间的记录查出来POST到云端的/api/sync接口。云端同样是PostgreSQL表结构和本地保持一致。冲突策略简单粗暴谁后修改谁赢。对于单人阅读工具来说够用了。建议不要把“每日阅读时长”算在客户端而是让服务端按reading_sessions里的date字段聚合。这样即使用户换了设备打卡日历也能恢复。5. 常见问题与排查技巧实录5.1 上传PDF后中文全是乱码这个问题出现频率最高。原因一般是PDF本身的文本编码不是标准的Unicode或者字体嵌入导致提取不出来。我用的pdfplumber对文字型PDF效果好但遇到部分中文老书会乱码。排查方法先打开PDF文件看能否复制文字如果能复制但乱码就要用pdfminer.six并开启codecutf-8如果连复制都不行那就基本确定是扫描版必须走OCR。我为了方便用户在解析失败时直接返回错误码UNSUPPORTED_PDF前端弹窗提示换成文字版。5.2 摘要生成结果句不成句读起来很生硬TextRank抽出来的句子虽然重要但有时候单独拿出来缺少上下文。我试过把摘要句直接拼接结果读起来像“因为没有…所以…另外…”完全没有可读性。解决方法是增加一个“上下文扩展”步骤对每个摘要句如果它是以连接词“但是”“然而”“因为”“所以”“此外”开头就把它的前一句也带上。同时如果摘要句长度小于15个字也把后一句顺进去保证摘要句的信息完整。这个规则非常简单但实测能让摘要的自然度提升一大截。5.3 阅读计时经常少算或者多算少算多见于用户连续阅读但APP在后台被系统回收多算则常见于用户停在阅读器页面去干别的事。我最终做法是定时器可以继续跑但要求每30秒记录一次用户的触摸或滚动事件如果超过5分钟没有交互就停止计时。另外在前台切换时用WidgetsBindingObserver监听生命周期进入paused状态立刻暂停计时。还有一个小细节如果用户阅读时打开了系统“深色模式切换”生命周期也会触发一次短暂暂停计时会少几秒。这问题不大但如果要求严格可以在计时器里做一个2秒内的“白噪”过滤把生命周期抖动造成的中断忽略掉。5.4 打卡日历时区错位昨天打卡跑到今天我的服务端一开始用CURRENT_TIMESTAMP存UTC客户端用本地时间显示结果晚上11点的阅读记录被归到了第二天。解决办法是客户端在开始阅读时记录本地时间的ISO字符串带上时区偏移比如2025-06-01T23:30:0008:00服务端解析时统一转成UTC存储聚合时再按用户选择的时区分组。这样每个用户看到“今天”都是他自己时区的今天不会错位。下面这张表是我整理的高频问题速查适合开发到后期放给自己看现象常见原因排查思路TXT乱码非UTF-8编码用chardet检测后手动decodePDF解析为空扫描版转OCR或提示用户用文字版摘要全程重复去重失效检查句子相似度阈值好句全是大白话关键词过滤太松提高词性白名单的要求打卡断签阅读时长不足5分钟检查计时是否被切后台日历跨UTC错位时区处理不一致统一用带时区的ISO时间最后再分享一个实操技巧不要一开始就把TextRank参数调得太猛。先把默认参数跑通再用二三十本不同类型的书做回归测试然后把“哪些句子被抽到了但你不想要”记录下来反过来调整停用词表和位置权重。实际上我花在调参上的时间远超写算法本身但效果也最明显。项目做下来我自己最大的体验是阅读工具的技术门槛不在单点功能而在把解析、摘要、交互、存储串成一条流畅的路径。只要这条路径走顺了用户自然会因为打卡日历带来的仪式感而每天打开APP这才是产品能持续跑下去的关键。