资讯详情

资讯详情

公众号阅读器Liuli:内容归档、正文提取与离线阅读设计

1. 先想清楚这类阅读器到底在解决什么问题我做内容归档这件事有几年了最初的想法很朴素每天在手机上刷到的长文看的时候觉得有用过两周想回头找某个观点翻聊天记录、翻收藏夹、翻搜索历史全都翻不到。平台内的收藏功能只解决存不解决找和读尤其是当一个作者持续产出几十上百篇的时候你根本没有办法像翻一本书那样去翻他。公众号阅读器 Liuli 这个项目本质上就是把这件事拆成了三段把公开内容取回来、把正文从页面里抠干净、把抠出来的东西变成一个可检索可离线阅读的本地库。听起来像三件事但真正动手做过的都清楚这三段的难度是倒着来的——采集反而是最简单的正文抽取是最容易翻车的阅读端体验是最容易被忽略但最决定你还会不会用第二天的。这篇东西我想按源码剖析的路子走一遍把每一层的设计意图、关键参数、以及我踩过的坑都摊开。适合两类人看一类是想自己搭一个内容归档工具、有点 Python 或 Go 基础但没做过完整链路的另一类是已经写过爬虫但只写过抓下来存文件这种初级形态想搞清楚怎么把它做成一个能长期跑、能天天用的东西。代码我会贴关键片段但重点不在抄代码而在为什么这么写。有一点先说明白下文所有采集行为都限定在公开可访问内容的个人归档范围内只讨论调度、解析、存储这些工程问题不涉及任何访问控制相关的手段。这条线必须先划清楚否则后面所有技术讨论都是空中楼阁。2. 架构拆解三个解耦决定了整个项目的上限2.1 采集与阅读解耦为什么不能边抓边看新手最容易犯的错是把采集和展示写在一个循环里抓一篇文章立刻渲染到界面。这样写起来快二十行代码就能跑但它有三个致命问题。第一个是阻塞。网络请求的耗时是不可控的慢的时候三五秒长的时候三十秒超时。如果渲染逻辑挂在请求的同一根线上用户点开列表就得看着转圈体验直接崩掉。第二个是状态污染采集过程中的临时数据、重试计数、异常堆栈会混到展示层的状态里出问题的时候你根本分不清是抓取失败还是渲染失败。第三个是不可回放采集一旦失败前端什么都拿不到你也没法单独重跑那一次抓取。Liuli 的做法是彻底切断这两端采集端只管往库里写阅读端只管从库里读两边通过数据库这个中间态通信。这个设计的直接好处是采集可以放在后台定时跑阅读端永远只查本地响应时间从网络延迟降到磁盘 IO级别。实测在一个十几万篇文章量级的库上列表页首次查询大概几十毫秒翻页基本无感。更隐蔽的好处是可回放。因为原始响应和解析结果可以分开存当你的正文提取规则升级了这种事一定会发生你可以把历史原始数据重新跑一遍解析不用重新抓。这一点在长期维护里价值极高我后面第 5 章会专门讲这个设计怎么落到表结构上。2.2 内容与呈现解耦正文抽取才是真正的核心很多人以为这个项目的技术含量在怎么抓到实际上抓取是这个链条里最标准化的一环——请求、拿 HTML、存下来没了。真正吃功夫的是从一坨混着导航、广告、推荐位、评论区、样式脚本的 HTML 里把正文那一块准确抠出来。为什么难因为页面结构不是为你准备的。你面对的是一个为了在浏览器里好看而生成的文档树正文只是其中一部分而且不同账号、不同排版模板、不同文章类型图文、纯文字、带大量代码块、带表格的结构差异巨大。写死 XPath 的方案换一个模板就全废。Liuli 采用的是基于文本密度和标点密度的启发式评分而不是选择器硬匹配。这个选择的理由很实在选择器方案的上限是你能枚举多少种模板而启发式方案的上限是你的评分函数有多贴合人类对正文的直觉。前者是线性增长后者可以泛化。代价是启发式方案永远做不到 100% 准确所以必须配一套人工兜底和规则兜底的机制这个我在第 4 章展开。2.3 本地优先为什么存储选了 SQLite 而不是别的选型这块我纠结过挺久最后落到 SQLite理由有三条都挺功利的。第一部署成本为零。这个工具的使用者大概率是自己电脑上跑一个服务不是团队协作。上 PostgreSQL 或者 MySQL 意味着用户要先装数据库、配账号、开端口一多半人会在这一步放弃。SQLite 是一个文件复制粘贴就能迁移备份就是cp。第二单机读性能足够。这个场景的并发量是一个人可能加一个手机读写比大概是 100:1。SQLite 在 WAL 模式下读是完全并发的写虽然串行但写入量很小定时批量插入完全构不成瓶颈。我压过一轮单表百万行、带索引的列表查询在普通 SSD 上是个位数毫秒。第三全文搜索能内建。SQLite 自带 FTS5 扩展中文虽然需要自己做分词但方案成熟后面细讲。如果换成别的存储你大概率要再引入一个搜索引擎那是另一个量级的运维负担。注意SQLite 不适合的场景也要说清楚——如果你打算做成多人共享的在线服务或者要做跨设备的实时同步那它的写锁会成为瓶颈那时候该换就换别硬扛。3. 采集层调度、限速、增量三件事决定它能不能长期跑3.1 订阅清单是唯一的真相源整个系统的入口是一份订阅清单里面记录了你关注哪些来源、每个来源的标识、最近一次成功抓取的时间、连续失败次数。这份清单必须是唯一真相源所有调度都从它派生不要在别处再存一份副本否则状态一定会不一致。我在早期版本里犯过一个错把待抓列表缓存在内存里进程重启就丢了导致重启后要全量重扫一遍。后来改成从清单表里按next_run_at排序取任务重启后状态完全恢复代价只是多一次索引查询。调度这块Liuli 用的是最简单的单进程 定时轮询没有上 Celery 或者消息队列。理由是这个场景的任务量级是几十到几百个来源每几个小时跑一轮用重量级方案属于杀鸡用牛刀还会引入一堆依赖问题。单进程的顺序执行反而更好调试出问题看日志一目了然。任务优先级的计算大概是这么个逻辑# 优先级 基础权重 逾期程度 - 失败惩罚 def task_priority(source, now): overdue (now - source.next_run_at).total_seconds() / 60.0 base 100.0 if source.is_starred else 50.0 penalty min(source.fail_count * 20.0, 120.0) # 连续失败最多扣 120 return base min(overdue, 240.0) - penalty这个公式里几个常数的来历值得说一下。overdue封顶 240 分钟是为了防止一个很久没跑的老来源永远霸占队头把新来源饿死。失败惩罚每次 20、封顶 120意思是连续失败 6 次之后就不再继续加罚让它保持一个低优先级但不被彻底放弃的状态——因为很多失败是临时的彻底拉黑反而会让你漏掉恢复后的内容。3.2 频率控制算清楚你的配额别凭感觉限速这块我的建议是先算再写代码。假设你订阅了 80 个来源希望每 6 小时更新一轮每个来源平均每次产出 3 篇新文章每篇文章需要 1 次列表请求 1 次详情请求。那么单轮请求量 80 × (1 3) 320 次。6 小时 21600 秒。平均下来每秒 0.015 次请求也就是大约68 秒一次。这个数字说明什么说明你的请求频率可以放得非常非常慢慢到几乎不可能给对方造成压力同时也意味着你完全没必要做并发——串行跑加上随机抖动是最稳的方案。import time, random class RateLimiter: def __init__(self, base_interval60.0, jitter15.0): self.base base_interval self.jitter jitter self._last 0.0 def wait(self): target self.base random.uniform(0, self.jitter) elapsed time.time() - self._last if elapsed target: time.sleep(target - elapsed) self._last time.time()jitter这个参数不是凑数的。固定间隔的请求在服务端看来是一条完美的等距直线很容易被识别成非人类行为加上 0 到 15 秒的随机抖动之后请求间隔变成一个区间分布更接近正常使用形态。这不是什么绕过技巧就是基本的礼貌——就像你不会每隔整 60 秒准时敲一次邻居的门。重试策略我用的是指数退避 次数上限第 1 次失败等 30 秒第 2 次等 2 分钟第 3 次等 10 分钟最多 3 次然后标记该来源本轮失败等下一个调度周期再试。这个曲线是照着大部分临时故障在几分钟内会恢复这个经验来的。不要用线性重试那会在对方真的出问题时形成密集轰炸。3.3 增量判断唯一键设计错了后面全是坑增量抓取的核心是怎么判断一篇文章是不是新的。这里有个反直觉的结论不要用发布时间做判断依据。原因是发布时间不可靠。有的文章会重新编辑推送时间变了但内容没变有的文章时间字段是相对时间比如3天前每次抓都不一样还有的模板干脆不输出时间。拿它当唯一键你会得到大量重复和漏抓。Liuli 用的是来源标识 文章标识的复合唯一键加一个内容哈希做二次去重。文章标识从详情页里稳定提取链接里的那段固定 ID 是最可靠的内容哈希用正文纯文本算用来兜住同一篇文章被重新推送、ID 变了但内容一样的情况。CREATE UNIQUE INDEX idx_article_uniq ON articles(source_id, article_key); CREATE INDEX idx_article_hash ON articles(content_hash);入库的时候用INSERT OR IGNORE重复的直接被数据库挡掉不用在应用层先查后插——那样有并发窗口虽然单进程场景下问题不大但没必要留这个隐患。提示内容哈希要在正文提取之后算不要在原始 HTML 上算。原始 HTML 里通常带时间戳、阅读数、推荐位这些每次都变的东西哈希值永远对不上。4. 正文提取这个项目的技术重心全在这4.1 三条路线的取舍我用表格说清楚方案实现难度泛化能力准确率上限维护成本适用场景CSS/XPath 选择器硬匹配低极差取决于模板枚举完整度每换模板就要改单一来源、结构稳定可读性启发式评分中好85%~95%低规则可复用多来源、结构各异浏览器渲染后取 DOM高好90% 左右中资源开销大强依赖 JS 渲染的页面Liuli 选的是第二条理由前面说过——泛化。但要说清楚它的边界启发式评分在结构清晰的文章页上表现很好在正文和评论混排、正文被大量图片打断、正文极短比如只有一句话的公告这三类页面上会翻车。所以工程上一定要配兜底评分失败时回退到取页面里最大的一段连续文本块再失败就存原始 HTML 并在界面上标注解析异常让人工介入。4.2 文本密度评分评分函数怎么设计核心思路很朴素正文的特征是文字多、链接少、标点密、段落长。把这几个维度量化成一个分数从根节点往下递归取分数最高的子树。import re PUNCT re.compile(r[。、《》]) def block_score(node): text node.get_text( , stripTrue) if len(text) 30: return 0.0 # 文字长度分对数压缩避免超长块碾压一切 length_score min(len(text) / 500.0, 3.0) # 标点密度分正文标点多导航栏几乎没有 punct_ratio len(PUNCT.findall(text)) / max(len(text), 1) punct_score min(punct_ratio * 10.0, 2.0) # 链接密度惩罚链接文字占比越高越不像正文 link_len sum(len(a.get_text(stripTrue)) for a in node.find_all(a)) link_ratio link_len / max(len(text), 1) link_penalty link_ratio * 5.0 # 段落结构分有多个 p 的块更像正文 p_count len(node.find_all(p, recursiveFalse)) struct_score min(p_count / 5.0, 1.0) return length_score punct_score struct_score - link_penalty几个常数不是拍脑袋定的是调出来的。length_score用对数压缩是因为一篇长文可能一万字一个导航块可能两百字如果线性算长文的分数会高到让所有阈值失去意义。link_penalty的系数 5.0 给得比较重是因为导航块和推荐位几乎全是链接这一项能把它们直接打到负分。递归的时候要注意不要只看叶子节点。很多模板里正文被拆成十几个并列的div单独看每一块分数都不高但合起来是最优解。所以策略是自底向上计算每个节点的分数如果一个节点的分数不如它的子节点之和就用子节点的组合。这个逻辑实现起来大概二十行但它是准确率从 70% 提到 90% 的关键。4.3 图片本地化不做这件事你的归档三年后就是一堆裂图这一点我要重点说因为它是最容易偷懒、后果最严重的地方。如果你在文章里直接引用原始图片链接短期看没问题长期看必然失效——外链会过期、会做防盗链、会限制来源。你的归档库会在半年后变成一堆图片加载失败的占位符那这个库就废了一半。正确做法是下载图片、改成本地路径、同时保留原链接作为备份。实现上注意几个点def rewrite_images(doc, article_id, save_dir): for i, img in enumerate(doc.find_all(img)): src img.get(data-src) or img.get(src) if not src or src.startswith(data:): continue try: data, ext download_with_limit(src, max_bytes5 * 1024 * 1024) fname f{article_id}_{i}{ext} (save_dir / fname).write_bytes(data) img[data-original-src] src # 保留原始地址 img[src] f/static/img/{fname} # 替换为本地路径 img.attrs.pop(srcset, None) # 清掉多分辨率属性 except Exception: img[src] # 失败就留空不阻断整篇 img[data-original-src] src三个细节>-- 来源表 CREATE TABLE sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, source_key TEXT NOT NULL UNIQUE, -- 来源的稳定标识 last_run_at TEXT, next_run_at TEXT, fail_count INTEGER DEFAULT 0, is_starred INTEGER DEFAULT 0, created_at TEXT NOT NULL ); -- 文章表 CREATE TABLE articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL REFERENCES sources(id), article_key TEXT NOT NULL, title TEXT NOT NULL, author TEXT, published_at TEXT, content_html TEXT, -- 清洗后的正文 HTML content_text TEXT, -- 纯文本用于搜索和哈希 content_hash TEXT, raw_html TEXT, -- 原始响应用于重解析 status TEXT DEFAULT ok, -- ok / parse_failed / fetched_failed fetched_at TEXT NOT NULL ); -- 原始响应层单独一张表和 articles 分离 CREATE TABLE raw_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, article_id INTEGER REFERENCES articles(id), url TEXT, body BLOB, fetched_at TEXT NOT NULL );raw_html和raw_pages看起来冗余但这是有意为之articles.raw_html存的是解析时用的那一版raw_pages存的是每次抓取的完整历史。前者用于快速重解析后者用于排查为什么这次抓回来的东西和上次不一样。两张表分离的好处是raw_pages可以定期清理比如只保留最近三个月而articles.raw_html永久保留。5.2 中文全文搜索FTS5 加自定义分词SQLite 的 FTS5 默认分词对中文是按字符切的效果很差——搜内容归档会匹配到任何包含内容归档任一字的文章。解法是给 FTS5 配一个自定义分词器把中文按二元组切分。# 建表时指定分词器这里用 trigram 方案做粗粒度全文索引 # FTS5 自带 trigram tokenizer中文场景实测可用无需额外扩展 CREATE_FTS CREATE VIRTUAL TABLE articles_fts USING fts5( title, content_text, contentarticles, content_rowidid, tokenizetrigram ); # 触发器保持同步 TRIGGERS CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN INSERT INTO articles_fts(rowid, title, content_text) VALUES (new.id, new.title, new.content_text); END; CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN INSERT INTO articles_fts(articles_fts, rowid, title, content_text) VALUES(delete, old.id, old.title, old.content_text); END; trigram分词器的好处是不依赖外部分词库三个字符起步的查询都能命中代价是索引体积会大一些大概是原文的 2 到 3 倍。如果你的库不大十万篇以内这个代价完全可以接受。库再大的话就要考虑只对标题和摘要建索引正文搜索走别的路径。触发器同步这个设计要特别注意批量插入的时候触发器会逐行触发速度会慢。如果一次性导入上千篇建议先关触发器、批量插完、再手动重建索引能快一个数量级。5.3 备份别等到硬盘坏了才想起来SQLite 的备份不能简单cp因为在 WAL 模式下数据分散在.db、.db-wal、.db-shm三个文件里直接复制可能拿到一个不一致的快照。正确做法是用 SQLite 自带的备份 APIsqlite3 data.db .backup backup/data-$(date %Y%m%d).db或者用VACUUM INTOVACUUM INTO backup/data-snapshot.db;VACUUM INTO会生成一个整理过的单文件副本体积更小可以直接拷走。我现在的做法是每天凌晨跑一次保留最近 14 天加上每月一个归档总共占用不到几百兆。6. 阅读端决定你会不会用第二天的地方6.1 列表与详情的信息密度列表页最容易犯的错是塞太多信息。标题、来源、时间、摘要、缩略图、已读状态、标签全放上去一眼看过去全是字反而不知道该点哪个。我的经验是列表页只有三样东西是有用的标题、来源、时间其余全部挪到详情页或者悬停态。已读状态要做得足够轻——一个左侧的细竖条就够了不要用变色整行这种重手法因为大部分文章你其实是扫过去的已读这个状态本身价值不高重要的是未读的视觉锚点。详情页的核心是排版还原。这里有个坑公众号文章的原始 HTML 里带大量内联样式style...直接用会和你自己的主题冲突。Liuli 的处理是保留内联结构样式font-weight、text-align、line-height剥掉颜色相关的属性color、background统一用自己的主题色。这样既保留了作者的行文节奏比如空行、加粗、引用块又保证了深色模式下的可读性。6.2 图片懒加载和离线缓存既然图片已经本地化了理论上不需要懒加载。但实际上一篇文章带几十张图、每张几百 KB一次性全部加载会让首屏卡顿明显。用的是原生的loadinglazy加上decodingasync就这两行属性改造成本极低效果立竿见影。离线这块如果你的阅读端是 Web 页面用 Service Worker 缓存静态资源 页面骨架正文数据走接口请求配合 Cache API 做一层最近读过的二十篇的缓存。实测下来在地铁这种断网环境下最近读过的内容基本都能正常打开。6.3 移动端适配的两个具体坑第一个坑是长表格横向溢出。公众号文章里经常有宽表格在手机上会把整个页面撑开导致左右滑动的时候页面跟着晃。解法是给所有表格包一层overflow-x: auto的容器让表格自身滚动页面不跟着滚。第二个坑是代码块不换行。技术类文章里的长代码行在窄屏上会溢出或者被强制折断导致缩进全乱。正确做法是overflow-x: autowhite-space: pre让代码块内部横向滚动保住格式。同时字号要降到13px左右不然一行代码只能看四个字符。7. 常见问题与排查实录7.1 抓回来的正文是空的这是最高频的问题排查顺序建议按这个走现象可能原因排查方法处理正文块完全为空评分函数把主块判为负分打印每个候选块的分数调低 link_penalty 或调高 length_score 权重正文只有标题内容由 JS 动态注入看原始 HTML 里有没有目标文本确认是否需要渲染后取 DOM正文包含大量导航文字评分选错了祖先节点打印选中节点的 XPath加强 link_penalty或加结构特征正文截断在中途长文分页只取了第一页检查是否有分页标记补分页逻辑或标partial状态打印每个候选块的分数这一步非常关键。我一开始是靠猜的改了半天没效果后来把前十个候选块的分数和文本前五十个字全打出来一眼就看到问题所在——某个推荐位模块因为文字特别长分数排到了第二。7.2 图片批量 403本地化的时候如果大量图片返回 403八成是 Referer 校验。处理方式是在下载请求里带上和页面同源的 Referer同时补一个常见的 User-Agent。这两条能解决绝大多数情况。如果还不行检查是不是图片地址本身有过期签名URL 里带一长串参数这种就只能抓取时即时下载不能延后。这就是为什么图片下载要放在解析流程里同步做而不是异步排队——异步排队可能几分钟后才执行签名早过期了。7.3 同一篇文章入库两次去重失效一般有两个原因。一是article_key提取不稳定比如从 URL 里截取的时候用了带查询参数的完整地址。二是标题被重新编辑过内容哈希对不上。排查方法很简单把那两条记录的article_key、content_hash、published_at打出来对比。如果 key 不同但 hash 相同说明是 URL 变了要把 key 的提取规则收紧到只用路径里那段固定 ID。如果 hash 也不同那可能是真的两篇不同文章作者改了内容重发这时候不该去重反而要保留两个版本。7.4 跑久了内存涨这多半是解析阶段的 DOM 树没释放。如果用的是 lxml 或者 BeautifulSoup每解析完一篇要显式把引用置空别指望 GC 自动回收——Python 的循环引用回收是有延迟的。批量跑几千篇的时候这个延迟会累积成明显的内存增长。我的做法是每处理 200 篇把解析器实例重建一次简单粗暴但有效。另外raw_pages里的body字段如果存了完整的二进制响应读完就关连接别在内存里留副本。提示长期跑的服务一定要加一个看门狗——记录进程的常驻内存超过阈值比如 800MB就优雅重启。别迷信自己能找到所有内存泄漏兜底比完美更重要。8. 一点实际使用的体会这个项目我前后迭代了大概一年多最大的感受是工程上真正难的从来不是那个听起来最唬人的部分。采集、解析这些技术点网上资料一大把写出来能跑也就一两天的事。真正拖时间的是那些不起眼的地方——图片三年后还在不在、搜索能不能搜到三个月前看过的一句话、深色模式下代码块看不看得清、手机上一篇长文滑到一半会不会卡。如果让我给准备动手的人一句建议我会说先把读这一端做出来用假数据都能跑通的那种然后再回去补采集。因为阅读体验决定了你还会不会继续投入而采集的那些坑你只有在天天用的时候才会真正意识到哪些值得填。另外一个我自己踩过的坑是早期太执着于全自动。总想着规则写得足够好就永远不用人工干预。事实证明不可能页面会改模板会变总有一些文章解析得不对。后来我加了一个解析异常的标记把评分低的文章单独列出来人工看一眼顺手把新的脏模式加进黑名单。承认不完美反而让这个工具真正能用下去。后面如果想继续扩展我列了几个方向一是把归档内容导出成标准格式方便接别的阅读器二是加一个简单的全文高亮搜索命中的词在详情页里标出来三是把同一作者的文章聚合成一个合订本视图按时间线读。这几个都是小改动但对使用体验提升挺明显的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →