1. rea 的定位:从网页 URL 到本地 Markdown 的一站式工具
前两个月整理个人知识库的时候,我经常干一件事:把网上看到的好文章存到本地。试过浏览器收藏夹,时间一长全是死链;试过截图,检索和引用都不方便;也试过直接复制网页,结果正文里混着导航、广告、相关推荐,排版乱得没法看。后来我花两个晚上写了个小工具,代号叫 rea,全称写得很随意——Read External Article。功能听起来很简单:输入一个 URL,它帮我把网页标题、正文、链接抓取出来,清洗成干净的 Markdown,再存进指定目录。
这个工具的定位不是爬虫,也不是内容农场,它更像一个“网页文章本地化助手”。适合谁用?适合写技术博客需要留存素材的人,适合做知识管理、想把自己的阅读资料沉淀成纯文本的人,也适合经常在终端里处理文件、不希望为了存篇文章还得打开图形界面的朋友。如果你只是偶尔收藏几篇网页,浏览器收藏夹就够了;但如果你有“每周归档几十篇、后续还要做检索”的需求,rea 这种命令行工具会顺手非常多。这篇文章我会把 rea 背后的设计思路、核心流程、实操命令和踩坑记录完整拆开,尽量做到你也能照着复刻一个属于自己的版本。
1.1 我为什么写 rea
先说痛点。我做内容归档时,真正需要的不是“把网页存下来”,而是“把网页里值得留存的信息变成我可以自由使用的文本”。浏览器自带的“保存网页”会把整个 HTML 连同图片、脚本一起存下来,文件体积大,打开还慢;一键复制虽然快,但复制出来的内容带上一堆无关文字,后续放进自己的文档里还得手动清理。
试过现成的在线“阅读模式”服务,倒是能把正文提取出来,但要一篇篇复制粘贴,而且很多服务要求注册、上传 URL,隐私上也总让人觉得不踏实。浏览器扩展我也没有放弃过,问题是扩展的数据存在某个私有格式里,想导出成 Markdown、想放进自己的笔记系统,总要经过一步奇怪的转换。rea 的出发点很朴素:输入一个 URL,输出一个我能完全控制的 Markdown 文件,不要任何中间步骤,也不要任何云服务参与。
这个需求听起来小,做起来却不简单。网页里最麻烦的部分从来不是“取代码”,而是“判断哪部分算正文”。有的文章正文被拆成几十个段落,中间插入一堆推荐卡片;有的技术文章正文里全是代码块、表格、图片,普通的提取逻辑很容易把代码块截断;还有的页面正文在侧边栏,主区域反而是视频播放器。rea 最初的版本只能处理博客类文章,后来的核心工作几乎都花在“正文识别”和“格式保留”上。
1.2 为什么最终选择了命令行形态
我工作上经常要跟终端打交道,写脚本、跑批处理、接定时任务,都依赖命令行。rea 做成命令行工具,最大的好处是“可组合”。它不需要和某个编辑器绑定,也不需要依赖某个图形界面,输出的是标准 Markdown 文件,任何工具都能读。今天我想把文章存到某个目录,就用rea <url>;明天我想批量处理一批链接,就写个循环调用它;后天我想在服务器上定时抓取几篇文章,依然可以用同一套命令。
另一个原因是对隐私的控制。命令行工具是一个跑在本地的进程,除了需要访问目标网址之外,不产生任何额外流量。网页内容只会落到你自己的硬盘上,不存在“上传到某个服务器解析后再返回”这一步。对于做技术研究、整理敏感资料或者单纯不想让第三方知道你读了什么的场景,这一点挺重要。
还有一个比较现实的考量:维护成本低。命令行工具没有复杂的 UI,界面可以烂一点,只要参数清晰、日志可读就行。rea 的核心逻辑可以从一个 Python 脚本里慢慢长成一个有配置文件的小项目,不会因为界面逻辑拖累正文提取逻辑。对个人工具来说,这种“反工程化”的选择反而能让它活得够久。
2. 核心流程拆解:网页正文提取的四个关键环节
一个 URL 到一份干净的 Markdown,中间要经过四步:获取页面、识别正文、清洗噪声、格式转换。四步环环相扣,前一步做不好,后面全白搭。刚开始我总觉得重点是最后一步“HTML 转 Markdown”,后来才发现真正难的是第二步“怎么找到正文”。
2.1 页面获取:先拿到完整 HTML
rea 的第一步是请求目标网页。这一步最容易被忽略,却最容易踩坑。首先是请求头里的 User-Agent,很多站点会拦截没有 UA 的脚本请求,被拦住的页面返回的不是正文,而是一段安全验证的提示。rea 默认会带一个比较常见的浏览器 UA,不是伪装,只是避免被当成异常访问。其次要处理重定向、超时和响应编码。HTTP 链接可能跳到 HTTPS,短链接会跳几次才能到最终页面,rea 默认会跟着跳转,但最多跳五次,防止出现循环跳转。
还有一个细节是页面的字符编码。有些站点返回的响应头里没有charset,但页面内容其实是 UTF-8;设置一个errors="ignore"来避免解码中断很重要。实际操作里,rea 会先从响应头拿编码,拿不到再去看 HTML 里的<meta charset>,再拿不到就默认尝试 UTF-8。编码错了,后面提取出来的就是满屏乱码,这件事我在后面排查部分还会专门讲。
这一环节的代码思路大概是:
def fetch_html(url): req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=10) as resp: # 先尝试响应头声明的编码 charset = resp.headers.get_content_charset() raw = resp.read() return raw.decode(charset, errors="ignore")请求超时设置成 10 秒,是因为个人工具没必要为了一个慢站点无限等待。如果你经常访问很慢的站点,把默认超时调大一点就好,这个值不该写死在代码里,应该放进配置文件,后面我会讲到。
2.2 正文识别:从 HTML 里找出真正的内容
拿到完整 HTML 之后,不能直接把所有文本拼起来。那段 HTML 里往往包含导航栏、页脚、评论区、推荐链接、广告脚本等等,如果不做筛选,转出来的 Markdown 会让人根本不想看。rea 用的办法是“文本密度打分”:先去掉明显不是正文的节点,比如script、style、nav、footer、aside,然后在剩余节点里找“文本量大、链接少、段落多”的区域。
这个思路很像浏览器的阅读模式:正文区域通常有连续的段落,每个段落有些长度,而侧边栏和评论区虽然也会有很多文本,但里面夹杂着大量超链接,链接的文字密度相对高。rea 会给每个候选节点打两个分:一是纯文本长度,二是段落数量。纯文本长、链接占比低、段落多的节点,多半就是正文容器。选出正文容器之后,还要递归清理容器内部的垃圾,比如“分享到微博”这种按钮文字、藏在段落中间的广告链接、以及重复出现的“上一篇/下一篇”导航。
识别逻辑写成简化代码大概是这个味道:
def find_main_content(dom): for tag in ["script", "style", "nav", "footer", "aside"]: for node in dom.find_all(tag): node.decompose() best_score = -1 best_node = None for candidate in dom.find_all("article") + dom.find_all("div"): # 用文本密集度做一个朴素打分 score = len(candidate.get_text()) links = candidate.find_all("a") link_text_len = sum(len(a.get_text()) for a in links) if len(candidate.get_text()) == 0: continue ratio = link_text_len / len(candidate.get_text()) score = score - ratio * 1000 if score > best_score: best_score = score best_node = candidate return best_node实际项目里不会用这么粗的方式,但核心思路就是这个方向。rea 还会保留<article>、<main>这种语义化标签的权重,它们命中的概率通常比普通<div>高。要是页面结构太奇怪,语义标签也救不了,那就要靠后面会提到的“站点自定义规则”作为兜底。
2.3 格式转换:HTML 到 Markdown 的细节取舍
找到正文节点之后,接下来是把节点内的 HTML 转成 Markdown。这一步并不是简单把<p>换成空行、把<h2>换成##就完事了。真正麻烦的是:代码块、表格、图片、行内代码、列表嵌套、链接引用。
rea 的转换原则是“只保留最常用的标记,避免生成一堆在笔记软件里根本渲染不出来的花哨语法”。我会把<pre>和<code>组合识别成围栏代码块,并尝试从 class 里提取语言类型;把<table>转成 Markdown 表格,但只保留表头行和普通行,合并单元格这类复杂结构直接降级为普通文本;图片会自动提取src和alt,生成;链接则保留原文文字,避免整篇被 URL 刷屏。
有一个很实用的取舍:默认不下载图片。rea 输出的 Markdown 仍然引用原站点的图片地址,好处是文件轻量、转换速度快;缺点是如果原站图片删了,本地文件里的图就裂了。后续可以给 rea 加一个--download-images参数,把图片下载到本地并改写链接,这个后面在扩展部分会提到。
格式转换代码的核心思想是用递归方式遍历 HTML 节点,根据标签类型决定输出什么:
def html_to_markdown(node): if node.name == "h1": return "# " + inline_text(node) + "\n" if node.name == "pre": return "```\n" + node.get_text() + "\n```\n" if node.name == "a": return f"[{inline_text(node)}]({node.get('href')})" if node.name == "img": return f"})" return "".join(html_to_markdown(child) for child in node.children)当然真实版本还要处理块级元素换行、段落间距、列表嵌套等。但上面这段逻辑已经把 rea 的“口味”表达出来了:干净、可读、不过度修饰。
3. 从零开始实操 rea:安装、命令和配置
前面把原理讲了一堆,下面进入实操。这一部分我按自己实际使用流程来写:先安装依赖,再跑通一个 URL,然后调整配置让它更顺手。
3.1 环境准备和安装步骤
rea 本身是一个用 Python 写的命令行工具,依赖很少。为了不污染系统环境,我一般先在项目目录里建一个虚拟环境。安装步骤大致是这样:
mkdir ~/rea cd ~/rea python -m venv venv source venv/bin/activate pip install -e .这里pip install -e .是把当前目录下的 rea 安装成开发模式,这样做的好处是以后每次改代码,终端里的rea命令都会立刻生效,不用反复重装。如果你只是临时试用,也可以直接运行python rea.py <url>,不安装也完全能跑。rea 的入口文件做得很薄,主要逻辑都在rea_core.py里,这样做是为了让测试和调试都更直接。
我第一次写 rea 时踩过一个典型的坑:直接在系统 Python 里装了一堆依赖,结果另一个项目需要不同版本,两边互相干扰。所以现在我的习惯是:每个命令行工具都放到自己的虚拟环境里。这不算 rea 的功能,但如果你打算照着复刻一版,这个习惯能帮你省掉后面非常多麻烦。
3.2 最常用的几组命令
rea 的使用命令设计得很简单,核心就几个:
# 从 URL 提取正文并输出到终端 rea https://example.com/some-article # 提取正文并保存到默认目录 rea https://example.com/some-article --save # 指定保存文件名和目录 rea https://example.com/some-article -o notes/readme.md # 把标题和来源也写入文件头 rea https://example.com/some-article --save --with-meta第一次跑的时候,建议先不加--save,直接看终端输出。这样可以迅速判断正文提取效果——如果输出里还有一堆导航文字,直接上保存命令只会把垃圾存下来。rea 默认会把标题提取出来作为终端第一行输出,方便你快速确认打开的网页没错。如果标题不对,通常说明 URL 本身有问题或者页面做了跳转。
保存下来的 Markdown 文件默认以“日期-标题片段”命名。这个命名规则是我从自己的归档习惯里总结出来的:日期排序非常方便,标题片段则保证文件名能看懂。如果你不喜欢,可以在配置里改成“域名-标题”或者其他模板。文件名里的特殊字符会自动去掉,Windows 和 Linux 都能正常使用。
3.3 用配置文件固定自己的偏好
命令跑通之后,我建议配一个config.yaml,把高频选项固定下来。rea 会优先读当前目录下的rea.yaml,找不到就回退到用户目录下的全局配置。我的配置大概是这样的:
save_dir: ~/Documents/archive timeout: 10 default_ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" with_meta: false keep_empty_lines: true strip_tags: - "script" - "style" - "nav" - "footer" - "aside" - "form"default_ua是一个经常要调整的选项。有些技术文章站点对 UA 很敏感,有时候换上移动端 UA 会得到完全不同的页面结构,正文提取结果也不一样。rea 的策略是:优先使用请求参数里指定的 UA,其次使用配置文件里的 UA,如果都没有才用内置默认值。这样遇到提取失败时,可以先用rea --ua "xxx" <url>快速测试是不是 UA 的问题。
with_meta控制是否在 Markdown 文件顶部生成标题、来源链接、抓取时间这几行元信息。我最初没有加这个选项,后来发现归档了一段时间后,有些文章我完全不记得原文出处了,所以就把默认值改成true。这个过程让我体会到一个小工具和大型软件的区别:完全按自己的需求长,不需要考虑兼容所有人。
4. 实践中的坑:正文丢失、乱码和动态页面
rea 写到现在,最值得分享的不是功能本身,反而是我在使用中踩过的一堆坑。下面这些问题几乎每个人做正文提取都会遇到,我把排查思路整理成了一段一段的“实操记录”。
4.1 正文提取结果为空怎么办
最常遇到的是 rea 输出提示“没找到正文”。第一次遇到的时候,我还以为是网站用了什么高级反爬,检查半天发现不是。真正原因通常有四种,我列成了一张表:
| 可能原因 | 典型表现 | 处理方式 |
|---|---|---|
| 页面是动态渲染 | HTML 里没有正文,只有一堆脚本 | 换用带渲染能力的抓取方式,或者直接放弃 |
| 编码识别错误 | 正文提取出来全是乱码 | 手动指定编码 |
| CSS 选择器误伤 | 页面结构特殊,正文被当成侧边栏删掉 | 写站点自定义规则 |
| 反爬拦截 | 拿到的是安全验证页,没提取到内容 | 修改 UA,或者降低请求频率 |
如果 rea 找不到正文,我建议先不要把锅甩给反爬,而是把抓下来的 HTML 保存到本地,人工看一眼里面到底有没有正文文本。很多时候问题出在 rea 的默认“噪声标签列表”太严格,把某些站点正文所在的<section>也当成了侧边栏。这时候可以临时关闭strip_tags里的个别项,重新跑一次。
还有一个细节:正文识别算法对“文章页”特别有效,但如果你喂给 rea 一个首页或者列表页,它大概率会提取失败。这是预期行为,不是 bug。rea 的使用场景是单篇文章,不是网站归档工具。第一次用的人看到这个报错可能会困惑,所以我后来在报错信息里加了一句“如果这是一个列表页,请直接给文章地址,而不是栏目地址”。
4.2 乱码问题与编码识别
乱码问题在我早期版本里出现频率很高。起因是有些老站点并没有在响应头里声明编码,页面 HTML 里的<meta>又写得比较怪。rea 默认按 UTF-8 解码,遇到 GBK 编码的页面就会乱成一片。解决方案并不复杂:先尝试响应头声称的编码,再尝试页面声明的编码,最后用 UTF-8 兜底。
还有一个容易忽略的地方:req返回的resp.read()拿到的是字节串,必须要在解码阶段就确定编码,不能在拿到字符串之后再猜。rea 现在会把“最终采用的编码”打印到调试日志里,方便你确认到底走了哪条分支。如果你遇到的页面是 GB2312 编码,可以在配置里给这个域名指定force_encoding: gb2312,这一个配置能解决 90% 的乱码问题。
讲到底,乱码不是 rea 的独有问题,而是所有做网页文本处理的人都会碰到的基础课。我对乱码的容忍度极低,因为一旦乱码,整篇文章等于作废,重抓又费时间。所以建议你也像 rea 这样,把编码检测流程做成三级兜底,而不是赌某个固定值。
4.3 动态渲染页面和懒加载图片的替代思路
现在很多前端框架的页面在 HTML 里根本没有正文内容,只有 JS 脚本。rea 拿到这种页面,正文提取会直接失效。这时候我不会立刻给 rea 加一个“无头浏览器”,因为那会引入巨大的依赖和性能开销。对个人工具来说,最好先判断一下:是不是真的需要抓这种页面?
如果只是偶尔遇到一个动态渲染的页面,我通常直接把阅读模式的 URL 拿出来。比如很多站点提供print版本,或者移动端页面会服务端渲染正文,换个 URL 结构就能拿到内容。手动复制也比给 rea 加无头浏览器划算。如果你确实需要成批处理动态页面,再考虑用带渲染能力的抓取方案。这个方案会慢很多,一个页面可能需要几秒到十几秒,不适合做批量任务。
懒加载图片是另一个坑。很多图片的src是一个占位图,真实图片地址在>