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 页面获取先拿到完整 HTMLrea 的第一步是请求目标网页。这一步最容易被忽略却最容易踩坑。首先是请求头里的 User-Agent很多站点会拦截没有 UA 的脚本请求被拦住的页面返回的不是正文而是一段安全验证的提示。rea 默认会带一个比较常见的浏览器 UA不是伪装只是避免被当成异常访问。其次要处理重定向、超时和响应编码。HTTP 链接可能跳到 HTTPS短链接会跳几次才能到最终页面rea 默认会跟着跳转但最多跳五次防止出现循环跳转。还有一个细节是页面的字符编码。有些站点返回的响应头里没有charset但页面内容其实是 UTF-8设置一个errorsignore来避免解码中断很重要。实际操作里rea 会先从响应头拿编码拿不到再去看 HTML 里的meta charset再拿不到就默认尝试 UTF-8。编码错了后面提取出来的就是满屏乱码这件事我在后面排查部分还会专门讲。这一环节的代码思路大概是def fetch_html(url): req urllib.request.Request(url, headers{User-Agent: UA}) with urllib.request.urlopen(req, timeout10) as resp: # 先尝试响应头声明的编码 charset resp.headers.get_content_charset() raw resp.read() return raw.decode(charset, errorsignore)请求超时设置成 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 - formdefault_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是一个占位图真实图片地址在>