☰
公众号采集阅读器 Liuli 源码拆解:采集、解析与归档链路
2026/10/31 5:55:55 网站建设 项目流程

1. Liuli 的定位:一条完整的数据处理流程,而不是「下个软件就行」

第一次看到「公众号采集阅读器 Liuli」这个名字,大多数人的第一反应是「又一个把网页存成 PDF 的小工具」。真去翻源码会发现不是这么回事——它其实是把一条完整的数据处理流程拆成了三段:采集(collector)→ 结构化(parser/normalizer)→ 归档与阅读(store + reader),每一段都有自己的输入输出约定和失败处理策略。这种「拆分思路」才是它源码里最值得看的部分,比任何单个函数都值钱。

我做内容归档这一类工具断断续续有几年了,见过太多项目死在第二段:抓取能跑,但正文抽不干净,图片全是防盗链,最后存下来一堆「标题 + 一堆导航栏文字 + 一个裂图」。Liuli 的代码在正文抽取和资源本地化上花了不少篇幅,这个取舍很能说明作者自己的真实使用场景——他是把它当「个人资料库」在用,不是当「数据抓取脚本」在用。

这篇拆解面向三类人:想读懂一个中等规模采集类项目怎么组织代码的初中级开发者;自己动手做过类似工具、卡在正文提取或增量更新上的实践者;以及只想搞清楚「这东西值不值得拿来改」的技术选型者。下面每一节的结论都尽量给到能直接抄的配置和代码片段,涉及具体实现细节的地方,我按这类工具的通行做法来推断,实际仓库的行号可能对不上,但逻辑结构基本一致。

提示:本文讨论的是「读取自己有访问权限的公开内容并做本地归档」这一使用场景。所有频率控制、缓存、断点续采的设计,前提都是尊重平台的访问规则,不绕过任何身份校验。

1.1 它和普通 RSS 阅读器的根本差别

RSS 阅读器的模型是「订阅一个 feed,拿到一堆条目」,采集端由平台负责,客户端只做展示。而公众号这类内容的分发机制里,没有一个稳定的、面向第三方的条目列表接口,你能拿到的往往是一个需要登录态、需要翻页游标、条目字段还带着大量冗余信息的列表页。

这个差别直接决定了架构:RSS 阅读器可以做成纯前端(甚至纯静态),而 Liuli 必须有服务端采集进程,必须有本地存储,必须处理「登录态过期」这种 RSS 世界里根本不存在的问题。你去看它的代码,有一个明显的特征——几乎所有模块都在为「不确定性」做准备:网络会失败、页面结构会变、图片会 403、账号会掉线。这种「防御性编程」的比重,是判断一个采集类项目成熟度的最好指标。

1.2 三个真实痛点撑起了这个项目

我把它的功能列表和自己的使用经历对照了一下,能站得住的痛点就三个:

痛点传统做法的结果Liuli 的做法
好文章看完就找不到了手动收藏,几个月后链接失效本地归档正文 + 图片,脱离原页面可读
追更麻烦,要一个个点开每天手动翻几十个号增量采集,只拉新条目
归档文件乱七八糟一堆文件名诡异的 HTML统一的目录约定 + Markdown 导出

第一个痛点最要命。很多人以为「收藏了就等于存下来了」,实际上页面删除、账号迁移、图片外链失效,任何一环断了你那份收藏就是空的。真正有价值的归档,必须做到正文和图片都在本地,这一点在源码里体现为「资源本地化」这一整块逻辑,而不是顺手做的小功能。

1.3 什么人不适合用它

说句实话:如果你只是偶尔看看,不需要归档,那用它的成本高于收益——部署、配置、处理登录态、跑定时任务,一套下来半天没了。它的价值曲线是随「你归档的量」线性上升的,几百篇以下基本没有收益。

另一类不适合的是想要「全自动、大并发、无人工干预」的人。这类采集天然要求低频率和人工维护登录态,任何试图把它做成「一天几万条」的想法,最后都会撞到访问限制上,而且撞的方式很难看——不是报错,是返回空内容,让你以为程序没跑。

2. 从目录结构反推架构:模块边界是怎么划的

拿到一个陌生项目,我的习惯是先不看main,先看目录树。目录命名是一个人对自己系统理解的最直白暴露。Liuli 这类项目通常长这样(我按通行做法还原一个典型布局):

liuli/ ├── collector/ # 请求、会话、分页、限流 │ ├── session.py │ ├── pagination.py │ └── ratelimit.py ├── parser/ # HTML -> 结构化条目 │ ├── list_parser.py │ ├── content_parser.py │ └── sanitizer.py ├── store/ # 落库、去重、状态 │ ├── models.py │ └── repo.py ├── exporter/ # Markdown / 电子书 / 静态站 ├── web/ # 本地阅读界面 └── cli.py # 命令行入口

这个划分里最关键的一条线是collector和parser严格分离。前者只负责「把 HTML 拿回来」,返回的是字符串或者字节流;后者只负责「把字符串变成对象」,不碰任何网络。这个约束看着简单,但它决定了三件事能不能做成:能不能离线重放、能不能单测解析逻辑、能不能在不改采集代码的前提下适配页面改版。

2.1 一次采集任务在代码里的完整链路

把链路摊开看,一次典型任务大概经过这些步骤,每一步都有明确的产物和失败边界:

  1. cli.py读配置,构造任务清单(要采哪些号、从哪一页开始)
  2. collector/session.py加载本地保存的会话信息,构造请求上下文
  3. collector/pagination.py按游标循环请求列表接口,产出原始 JSON/HTML
  4. parser/list_parser.py把列表页解析成条目数组(id、标题、发布时间、封面、摘要)
  5. store/repo.py按去重键比对,过滤掉已存在的条目
  6. collector对剩余条目逐条请求详情页
  7. parser/content_parser.py抽取正文、清洗、转 Markdown
  8. exporter把图片下载到本地目录并重写引用路径
  9. store写库,更新采集状态

实际跑起来你会发现,第 5 步的位置特别关键。去重必须放在详情页请求之前,否则你每跑一次都会把几百篇文章的详情页重新拉一遍——这是新手最容易犯的性能错误,也是最容易触发频率限制的错误。

2.2 采集与解析分离带来的三个直接收益

第一个收益是可重放。解析器改了一个 bug,你不用重新联网,直接把上次存下来的原始 HTML 从缓存目录里读出来重跑一遍就行。我自己维护的一套工具里,缓存原始响应这个设计救过我至少十次——每次页面结构微调,都是靠重放历史数据来验证解析器改动是否兼容旧页面。

第二个收益是可测试。解析器的输入是字符串,输出是对象,天然适合「喂一个样本文件,断言抽取结果」的测试写法,不需要搭 mock 服务。

第三个收益是可替换。哪天你想把采集端换成浏览器渲染(比如页面改成前端渲染了),只需要在collector里换实现,parser一行不动。这个解耦的价值,在项目活过半年之后会变得非常明显。

2.3 配置驱动的取舍:为什么有些东西写死在代码里

配置项不是越多越好。看这类项目的配置结构,通常只把三类东西暴露出来:采集目标(账号列表、起始位置)、节奏参数(并发数、请求间隔、重试次数)、输出路径(数据目录、导出格式)。而正文抽取的规则、噪声剔除的标签名、字段映射关系,这些一律写死在代码里。

理由很实在:前者是「用户会变的需求」,后者是「页面结构决定的实现」。把抽取规则做成配置,看似灵活,实际上会让配置文件膨胀成一个没人敢改的 DSL,而且一旦页面改版,你还是得改代码,配置层只是个中间商。

3. 采集层:请求封装、会话维持与节奏控制

采集层是整个项目里最容易写、也最容易写烂的部分。写一个能跑的版本只要二十行,写一个跑三个月不出事的版本,需要的设计要多得多。Liuli 的采集层我认为处理得比较到位的地方在于:它把「节奏」当成一等公民,而不是事后打补丁。

3.1 请求里那些「不像参数」的字段

列表接口的请求参数里,通常混着两类东西:一类是有业务含义的(账号标识、页码、每页条数),另一类是上下文相关的一次性字段(会话标识、时间戳、某些签名片段)。后者是采集代码里最脆弱的环节——它们会过期,会随会话变化,会在你没改任何代码的情况下突然失效。

处理这类字段有三条经验:

  • 不要硬编码。硬编码的结果是「用了两周突然全空」,而且报错信息完全不指向真正原因。
  • 从当前会话上下文中动态取。会话信息存本地文件,程序启动时加载,请求时拼装。
  • 失败要能区分。一次请求返回 200 但内容是空的,和返回 403,是完全不同的两种故障,处理方式也完全不同。

我在自己的工具里做过一个笨但有效的改进:给每个请求记录「响应体长度」和「条目数量」两个指标,落进日志。当条目数量从平均 20 突然掉到 0,日志会非常显眼地把问题暴露出来,比你去翻几百行响应体快得多。

3.2 分页与游标:最容易写出死循环的地方

分页这块有个隐蔽的坑:有些接口在「没有更多数据」时不是返回空列表,而是返回最后一页的重复数据。如果你只判断len(items) == 0就退出,恭喜你,程序会一直跑下去,直到你手动 Ctrl+C。

稳妥的循环条件要写成复合判断:

def iter_pages(fetch_page, start_cursor=None, max_pages=200): cursor = start_cursor seen_cursors = set() for _ in range(max_pages): if cursor in seen_cursors: break seen_cursors.add(cursor) page = fetch_page(cursor=cursor) items = page.get("items") or [] if not items: break yield items next_cursor = page.get("next_cursor") # 游标没变,说明平台已到底,但仍在返回旧数据 if not next_cursor or next_cursor == cursor: break cursor = next_cursor

max_pages这个兜底参数看着多余,但它救过我不止一次。任何「靠外部返回值决定循环次数」的循环,都必须加一个硬上限。

3.3 重试、退避与缓存三件套

采集层的稳定性靠三个机制叠加,缺一不可:

机制解决什么关键参数建议
重试偶发网络抖动、瞬时限流最多 3 次,只重试幂等请求
指数退避触发频率限制后不要继续加压基数 2~5 秒,上限 60 秒,加随机抖动
本地缓存重复请求、解析调试按「账号+游标」做 key,保留 7 天

退避里的「随机抖动」经常被忽略,但它很重要。如果一批任务同时失败,全部按 2 秒、4 秒、8 秒退避,它们会在同一时刻再次同时发起请求,形成新的尖峰。加上 ±30% 的随机偏移,压力就被摊平了。

缓存这块要说清楚定位:缓存不是为了加速,是为了减少对平台的请求次数。它的正确用法是「同一账号同一游标,一天内只真正请求一次」。调试解析器时把缓存打开,你会发现自己一天能省下几百次请求。

# config.yaml 片段 collector: concurrency: 2 # 并发别贪,2 已经够用 interval_ms: 1500 # 请求间隔,宁慢勿快 timeout_s: 20 retry: max_attempts: 3 backoff_base_s: 3 backoff_cap_s: 60 jitter_ratio: 0.3 cache: enabled: true ttl_hours: 168 dir: ./data/cache

注意:并发数提高带来的收益非常有限,因为瓶颈在「平台允许的请求速率」而不在你的机器。把并发从 2 提到 8,多半只是让触发限制来得更快。

4. 正文抽取:从整页 HTML 到能读的 Markdown

这一节是整个项目里技术含量最高的部分,也是最难「抄」的部分。因为正文抽取没有通用解,每个平台的页面结构都不一样,最终一定是一套「通用算法 + 站点特化规则」的混合体。

4.1 正文容器的定位思路

通用的定位方法绕不开三个信号:文本密度、段落连贯性、链接占比。通俗讲就是——正文区域的文字多、被标签切得碎(很多<p>)、里面的链接少;导航区和页脚则是文字少、链接多、标签层级浅。

一个可用的打分函数大概长这样:

def score_node(node): text = node.get_text(" ", strip=True) text_len = len(text) if text_len < 80: return 0.0 link_len = sum(len(a.get_text(strip=True)) for a in node.find_all("a")) link_ratio = link_len / max(text_len, 1) # 段落数量:正文通常被切成很多块 para_count = len(node.find_all(["p", "section", "blockquote"])) density = text_len / max(len(node.find_all()), 1) return ( text_len * 0.4 + para_count * 8 + density * 0.6 - link_ratio * text_len * 1.2 )

把候选节点(通常是div、article、section)全部打分,取最高分作为正文根节点。这个方法的鲁棒性不错,但有两个已知失效场景:整页只有一张图的文章(文本长度不够,打分归零)、大量短句 + 每句一个链接的排版(链接占比惩罚过重)。

所以实际项目里一定要做兜底:打分结果的最大值低于阈值时,回退到「站点特化选择器」,再失败就回退到「整页 body 去噪」。三级兜底是这类工具的标配,只做一级的项目,遇到异常页面就是一片空白。

4.2 噪声剔除清单,越具体越好

去噪比定位更琐碎,因为噪声是按站点变的。我整理过一份通用的高频噪声清单,按优先级排列:

  1. 明确的交互控件:按钮、输入框、分享组件
  2. 版权声明、免责声明段(常以固定短语开头)
  3. 「相关阅读」「推荐」「猜你喜欢」区块
  4. 上一篇 / 下一篇导航
  5. 空段落、纯&nbsp;段落、只含一个<br>的段落
  6. 内联样式里带display:none的元素

第 5 条值得单独说。很多人清洗完发现导出的 Markdown 里全是空行,原因就是原页面用了大量空段落做间距。清洗时必须做「连续空行压缩」,否则一篇两千字的文章导出后能有八十个空行,读起来非常难受。

import re def normalize_markdown(md: str) -> str: md = re.sub(r"[ \t]+\n", "\n", md) # 行尾空白 md = re.sub(r"\n{3,}", "\n\n", md) # 连续空行压成一个空行 md = re.sub(r"\n+(#{1,6} )", r"\n\n\1", md) # 标题前保证空行 md = re.sub(r"!\[\]\(\)", "", md) # 去掉空的图片占位 return md.strip() + "\n"

4.3 图片、代码块、表格的保真处理

这三类元素是「归档质量」的分水岭。纯文字存下来容易,但这三类处理不好,文章就失去了可读性。

图片的核心问题是外链失效。必须做本地化:下载到data/images/<文章id>/下,文件名用内容哈希的前 16 位(这样同一张图在多个文章里出现只存一份),然后把 Markdown 里的引用路径改成本地相对路径。下载失败不能中断整篇文章的归档,用占位符记下原地址,后续可以单跑一个补图任务。

代码块的关键是保留language-xxx这个 class。很多页面的代码块是高亮组件渲染出来的,里面全是<span>,直接取 text 会丢失换行。稳妥做法是先判断父元素是不是pre,是的话取pre的完整文本,再从相邻节点的 class 里反推语言标记。

表格要防的是「布局表格」。老式页面用<table>做排版,直接转成 Markdown 表格会变成一堆无意义的单元格。判断依据是:列数少于 2、或者单元格内容平均长度超过 60 字,基本就是布局表格,应该按普通块级元素处理而不是转表格。

5. 数据模型与增量:让第二次采集只花二十分之一的时间

第一次跑全量采集花两小时很正常,但如果第二次、第三次还是两小时,这工具就没法用了。增量能力是这类项目的生命线,而增量的基础是数据模型设计得对不对。

5.1 四张表撑起整个系统

一个够用的模型只需要四张表,多了就是过度设计:

-- 账号表 CREATE TABLE account ( id TEXT PRIMARY KEY, -- 平台侧稳定标识 name TEXT NOT NULL, last_sync_at TEXT, enabled INTEGER DEFAULT 1 ); -- 条目表(列表页拿到的元信息) CREATE TABLE item ( id TEXT PRIMARY KEY, -- 平台侧条目标识 account_id TEXT NOT NULL, title TEXT, published_at TEXT, cover_url TEXT, digest TEXT, status INTEGER DEFAULT 0, -- 0待抓 1已抓 2失败 3跳过 retry_count INTEGER DEFAULT 0, fetched_at TEXT ); -- 正文表(详情页抓到的内容,单独一张避免大字段拖慢列表查询) CREATE TABLE content ( item_id TEXT PRIMARY KEY, html_raw TEXT, markdown TEXT, word_count INTEGER, fingerprint TEXT, created_at TEXT ); -- 资源表(图片等) CREATE TABLE asset ( id TEXT PRIMARY KEY, -- 内容哈希 item_id TEXT, src_url TEXT, local_path TEXT, bytes INTEGER, ok INTEGER DEFAULT 1 );

把content单独拆出来这个设计很值得学。正文是几 KB 到几十 KB 的大文本,如果和item混在一张表里,你每次查「有哪些待抓条目」都会顺带扫一遍这些大字段,几百篇之后查询明显变慢。拆表之后,列表查询只碰小字段,速度差一个数量级。

5.2 去重键怎么定

去重键的选择直接决定了「漏抓」和「重抓」哪个先出现。三种常见方案的对比:

方案优点缺陷适用
平台条目 id精确、稳定依赖平台提供首选
标题 + 发布时间不依赖 id标题可能改、时间可能只到天备选
正文内容哈希能识别转载必须先抓详情页,成本高二次去重

正确做法是分层去重:列表层用平台 id 做第一道(快、免费),写库时用「标题 + 发布时间」做第二道(防 id 变化),正文抓回来的再用内容指纹做第三道(识别同一篇文章被多个号转载)。

第三道去重的实用价值常被低估。同一篇内容经常被多个号转载,如果你只按 id 去重,本地会存五份一模一样的文章。用 SimHash 或者简单的「前 500 字 + 长度」组合指纹,能在几乎零成本的前提下干掉大部分重复。

5.3 状态机与断点续采

status字段看着平淡,但它是断点续采的全部。关键点在于失败要单独计数,不能简单回到待抓状态:

def next_batch(conn, limit=50): return conn.execute( """ SELECT id FROM item WHERE status = 0 OR (status = 2 AND retry_count < 3) ORDER BY published_at DESC LIMIT ? """, (limit,), ).fetchall()

retry_count < 3这个条件很重要。没有它,一篇永久性失败的条目(比如已被删除)会在每一轮采集里被反复重试,每次失败都要等超时,几十篇这样的条目能把一轮任务拖慢半小时。有了它,三次失败之后条目自动沉底,你可以事后单独捞出来人工看一眼。

实践里我还会额外加一条:记录最后一次成功的游标。这样即使进程被强杀,下次启动也能从断点继续,而不是从头翻。对于几百个账号的场景,这一条能省掉大量无效请求。

6. 阅读器与导出:把「网页」变成「资料」

采集和解析解决了「拿到」,阅读和导出解决的是「用得上」。很多同类项目在最后这一步崩掉——数据是有了,但打开一看排版全乱,或者导出之后图片全裂。

6.1 本地渲染的两个必踩的坑

相对路径。图片本地化之后,Markdown 里写的是../images/xxx/abc123.jpg这种相对路径。渲染时如果基准目录算错,就是一片裂图,而且不报错。正确做法是把「数据根目录」作为唯一基准,在渲染层统一拼绝对路径,绝不在 Markdown 里写死绝对路径——否则整个数据目录一搬家就全废了。

懒加载属性。页面里的图片真实地址往往不在src上,而是在>IMG_ATTRS = ("data-src", "data-original", "data-lazy-src", "data-actualsrc", "src") def pick_img_url(img): for attr in IMG_ATTRS: val = (img.get(attr) or "").strip() if val and not val.startswith("data:"): # 过滤 base64 占位图 return val return None

这个函数短,但它决定了你归档的图片是不是真的能看。我第一版就是只读src,导出了三百多张灰图,直到某天翻旧文章才发现。

6.2 全文检索:数据量小的时候别上重型方案

个人归档的规模通常在几千到几万篇,这个量级用嵌入式数据库自带的全文索引完全够,不需要引入独立的搜索服务。加一个虚拟表或者一张(item_id, term, tf)的倒排表,配合简单的 BM25 打分,几千篇的查询响应在几十毫秒。

真正需要花心思的不是检索引擎,而是分词。中文没有空格,简单按字符切会产生大量噪声。实践里比较省事的方案是「二元组切分」:把「源码剖析」切成「源码」「码剖」「剖析」,查询时同样切分再求交集。这个方案不完美,但对归档检索这种「我记得标题里有某个词」的场景,召回率足够。

6.3 导出时的排版约定

导出 Markdown 我踩过的坑集中在一个地方:元信息的位置。把标题、作者、发布时间、原文地址放在正文前面,会让文章在编辑器里打开时第一屏全是元信息,很碍事。我最后改成用 YAML front matter:

--- title: "某篇文章的标题" account: "某个账号名" published: "2024-11-03 08:12" source_id: "abc123def" archived: "2024-11-05 21:40" --- 正文从这里开始……

这样做的好处是元信息结构化、可被静态站生成器直接读取,同时在不支持 front matter 的编辑器里也不会太难看。文件名我建议用YYYYMMDD-标题前30字.md,日期在前保证按名称排序就是时间序,标题截断避免超长文件名在某些文件系统上出错。

7. 实跑之后踩到的坑与二次开发切入点

代码读得再细,不跑起来还是会漏掉一半问题。下面这些是我在实际使用和改造过程中真实遇到过的,按「排查链路」的方式写,方便你复现同样的思路。

7.1 五个高频故障的排查路径

现象第一层判断根因处理
列表返回空看响应体长度会话过期重新走登录流程,更新会话文件
列表返回旧数据比对条目 id 是否重复游标到底仍返回末页加游标去重 + max_pages
正文抽取为空打印打分 Top3 节点页面结构改了 / 纯图文章补站点特化选择器 + 兜底
图片全部裂图看本地目录有无文件懒加载属性 / 防盗链改地址取值 + 带 Referer 下载
第二轮仍然全量抓取查 status 分布去重键与 id 不一致统一 id 来源,补一层标题去重

排查顺序有个通用原则:先确认「数据有没有回来」,再确认「数据对不对」。很多人一看到空内容就去改解析器,实际上是采集端根本没拿到东西。在采集出口打一行「响应长度 + 条目数」的日志,能把这半小时的弯路压缩到三十秒。

还有一个隐蔽的坑是时区。列表返回的时间戳常是 UTC 或者带偏移的字符串,如果你直接字符串比较排序,跨月的时候顺序会乱,进而导致「增量采集从错误的游标开始」,表现为「新文章漏抓了几天」。统一在入库时转成同一时区的标准格式,这一步不能省。

7.2 想二次开发,先动这三处

如果你打算基于它改造成自己的工具,我的建议是从这三个地方入手,投入产出比最高:

第一处,采集器的接口抽象。把「列表接口」「详情接口」抽成两个可替换的适配器,用配置文件指定用哪个实现。这样后续接新的内容源只需要新增一个文件,不用动主流程。这一步做完了,项目就从「一个平台的小工具」变成「一个通用的归档框架」。

第二处,解析器的规则外置。把站点特化选择器、噪声黑名单、字段映射这些从代码挪到「每个源一份 YAML」。注意区分:通用算法留在代码里,站点规则外置。这个边界把握住,配置就不会失控。

第三处,导出器的插件化。Markdown 只是最基础的一种,静态站、电子书、纯文本、JSON 都是可能的输出目标。把 exporter 定义成「输入文章对象列表,输出文件树」的统一接口,后面加格式就是几十行的事。

这三处改造我按顺序做过一遍,最能带来长期收益的是第一处。因为「源」是会变的、会增加的,而解析算法基本稳定。

7.3 我自己的使用边界

最后分享几条纯粹个人层面的经验。采集频率宁低勿高,一天一次足够,追更这件事本身就不需要实时。别做分布式/多机并发,这类工具的数据量单机完全撑得住,上多机的唯一效果是让问题变复杂。原始 HTML 一定要留,磁盘便宜,但当你发现三个月前某篇文章导出有问题、而页面结构早改了两版的时候,那份原始 HTML 就是唯一的救星。

还有一个小技巧:给它加一个「导出最近 7 天」的命令,配合每天早上的定时任务,你会在自己的笔记目录里得到一份自动更新的周报文件夹。这个用法比打开阅读界面更符合我的习惯,也是我最终把它留下来的原因——工具的价值不在于功能多少,而在于它能不能无声地嵌进你已有的工作流里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询