最近终于把代号rea的个人项目收尾了。rea的全称是 Reading Experience Assistant也就是“阅读体验助手”。做它的原因其实很朴素我平时有大量网页长文要读但浏览器默认的排版、字体、广告和跳转弹窗让我很难沉浸下来更别说把文章保存、整理、换个时间继续读。rea要解决的就是这三个问题把网页正文干净地提取出来用适合阅读的排版重新呈现再把阅读进度和收藏内容沉淀下来。它可以做成一个自托管的轻量 Web 服务也可以拆成命令行工具单独用。这篇文章我会直接讲清楚rea是怎么从需求变成可运行项目的包括为什么选这套技术、正文提取的核心原理、完整实操步骤、我在联调时踩过的坑以及后续怎么部署和扩展。无论你是想做一个类似的阅读工具还是想了解网页正文提取与前端阅读页面的实现思路都可以直接参考这套方案。1. 项目定位与需求拆解先把要解决的问题想明白1.1 长文阅读的真实痛点不能只用“广告多”来概括很多人说网页阅读体验差第一反应是广告太多。但实际操作中你会发现广告只是最表层的问题。真正让人不想在网页里读长文的是下面几个更琐碎的点一是正文经常被“肢解”。文章开头可能很正常读到一半突然插一段相关推荐底部又是评论区和一堆脚注。你想复制一段核心内容结果把导航链接、推荐标题甚至差点把弹窗文案一起复制进去。二是排版不稳定。有的页面字号太小有的行高太窄有的背景图和文字颜色几乎一样。每次换一个网站阅读体验就完全换一种风格。三是不容易“接上”。你在通勤路上读了前两段想在地铁上继续但页面滚动位置、浏览器历史、文章链接都散落在不同地方想找回原文还要靠记忆翻历史记录。rea最初的需求就是从这些细节里长出来的输入一个 URL返回一段干净正文正文里的图片能正常显示链接还能点代码块不能被拆掉阅读位置可以记住最后一键生成适合夜间阅读的排版。它不是要取代浏览器而是做浏览器和笔记软件之间的一个中间层。1.2rea的核心功能边界提取、清洗、朗读、收藏在动手写代码之前我把功能收敛成四个模块避免一开始就做成大杂烩。功能模块输入输出解决的问题正文提取任意网页 URL 或 HTML标题、作者、正文内容去掉导航、广告、评论等噪音内容清洗原始正文节点标准化 HTML/Markdown保留图片、链接、代码块修复相对路径阅读模式清洗后的内容沉浸式网页 / 朗读音频统一排版、夜间模式、支持语音播放收藏与进度文章 ID 与阅读位置SQLite / JSON 数据跨设备继续阅读建立个人文章库这四个模块相互独立又可以串联成一条完整链路。比如你在某个技术博客看到一篇长文点“存入rea”服务端先去抓取页面再调用正文提取模块清洗后存成一份干净的 HTML下次打开rea它直接从本地数据库里把文章读出来即使原网页已经删除你依然能看到完整内容。1.3 命名与目标用户为什么叫 rea适合谁rea这个名字没有太多复杂的含义一开始只是随手取的缩写。后来我把全称补成了 Reading Experience Assistant反而更贴合它的定位不只是一个爬虫工具而是“阅读体验的辅助者”。它适合三类人一是经常阅读技术文档、行业分析、深度报道想建立个人素材库的内容消费者二是做信息聚合类应用需要在后端集成正文提取能力的开发者三是想用低成本方式做“自托管阅读服务”的用户比如部署在家庭服务器或者自己的云主机上。当然如果你完全不懂代码直接用现成的浏览器“阅读模式”也能解决一部分问题。但rea的价值在于数据是自己的提取结果、排版样式、阅读进度都掌握在自己手里不会因为某个平台调整规则就消失。这对我来说是选择自建而不是只用插件的最重要原因。2. 技术选型解析为什么用 Node.js 文本解析而不是浏览器插件2.1 产品形态对比本地脚本、浏览器扩展、自托管 Web 服务在开始写代码前我比较了三种产品形态本地命令行脚本、浏览器扩展、自托管 Web 服务。三种方案都能实现“提取正文”这个核心动作但后续能力差别很大。本地脚本最轻量适合一次性处理少量页面。写一个 Python 或者 Node 脚本输入 URL 输出 Markdown整个过程只要几十行代码。缺点是没法保存阅读进度也不能在多设备间共享。浏览器扩展体验最好可以在当前页面直接切换阅读模式但需要针对不同浏览器的 API 做适配还要处理扩展审核流程开发成本略高。自托管 Web 服务则介于两者之间它既可以通过浏览器访问也能被命令行调用、被其他应用通过 API 调用还可以配合 Docker 部署到任何机器上。我最后选择的是自托管 Web 服务形态但把核心提取逻辑拆成独立模块。这样rea既能作为服务运行也能被脚本引用。如果你只需要一个提取函数完全可以把这一层单独拿出来用。2.2 后端方案为什么选 Node.js 而不是 Python很多人做网页抓取第一反应是 Python确实 Python 在网络请求和文本处理上有非常成熟的生态。但rea的核心是“解析浏览器 DOM”而 Node.js 生态里有一个天然的优势jsdom可以模拟浏览器环境配合 Mozilla 开源的Readability.js可以直接复用 Firefox 阅读模式的提取逻辑。这是一套已经被大规模验证过的方案比我自己从零写正则要可靠得多。选 Node.js 还有一个实际原因前端和后端都用同一种语言分享数据结构和解析函数时不需要做语言切换。比如前端想处理“相对路径转绝对路径”可以直接和后端用同一段逻辑校验如果后端、前端分开用 Python 和 JavaScript这部分就要维护两份代码。当然Node.js 的缺点也很明显处理超大 HTML 时内存占用偏高遇到动态渲染页面还需要引入无头浏览器整体资源消耗比纯抓取大。所以我做了一条规定优先用jsdom Readability.js处理静态 HTML只有检测到页面内容确实由 JavaScript 动态生成时才考虑用无头浏览器补充渲染。大多数博客、新闻站、文档站的内容都是服务端直出的静态方案足够覆盖大部分场景。2.3 核心库与依赖readability、jsdom、cheerio 的分工rea的依赖非常克制核心只有三个jsdom把 HTML 字符串变成可操作的 DOM 对象提供浏览器一样的选择器和事件能力。我们用它的原因是它不需要启动真实浏览器进程比无头浏览器快得多。mozilla/readabilityMozilla 开源的正文提取算法Project 内叫 Readability.js。它负责从 DOM 中找到“最像正文”的节点返回标题、作者、正文 HTML、纯文本等结构化信息。cheerio一个轻量级的 HTML 操作库主要用来做二次清洗。比如把img的src从相对路径改成绝对路径删除正文里残留的脚本节点统一标题层级。如果你只需要快速验证效果这三个依赖的组合在几分钟内就能搭出一个可运行的提取接口。下面的实操部分我会详细展示具体怎么组织代码。3. 核心原理拆解正文提取、清洗与摘要生成3.1 正文提取不是“去掉标签”基于密度的评分机制很多人以为正文提取就是把script、style、广告标签删掉剩下的就是正文。实际上网页结构千奇百怪有的内容藏在很深的嵌套里有的正文被分散成多个div单纯删标签很容易把正文漏掉。Mozilla 的 Readability.js 采用的是一套“评分机制”遍历 DOM 中的所有节点给每个候选节点打分分数最高的节点最可能是正文。评分时会考虑几个关键指标文本长度越长加分越多段落和文本节点的密度越高说明这里内容越集中链接密度越高越可能是导航或广告区所以需要减分p、article、pre这类语义化标签会给加分。整个过程有点像面试官看简历文本量相当于经历丰富程度段落结构相当于表达是否清晰链接太多则有点像是到处跳转的“拼凑简历”可信度反而降低。最终得分最高的节点会被选为正文容器其余内容都被丢弃。这套机制的优点是不需要针对每个网站写规则。不管页面结构怎么换只要正文本身是“一大段有结构的文本”算法就能找出来。实际使用中它也有失效的情况如果页面内容全是由 JavaScript 动态渲染初始 HTML 里根本没有正文Readability.js 自然无米下锅。这种情况我会在后面的排障部分单独说。3.2 正文清洗与排版还原保留图片、代码块和引用提取到正文节点只是第一步接下来要解决的是“呈现”。“呈现”包括两层内容的完整性以及链接的可使用性。很多网页中的图片地址是相对路径比如/images/1.png。原页面能正常显示是因为它知道当前域名是https://example.com但如果我们把 HTML 字符串单独拿出去图片就会裂开。因此清洗阶段必须做路径补全通过new URL(relativePath, pageUrl).toString()把相对路径转成绝对路径。链接同理正文里的超链接如果也是相对路径必须一并转换。除了路径问题还要注意代码块。技术类文章里最影响阅读体验的就是代码片段一旦 CSS 失效或者转成纯文本缩进和换行都会丢失。Readability.js 虽然会保留pre和code但我们在二次清洗时仍然需要检查不能把pre标签误删。我的做法是先把候选节点里的pre、table、blockquote标记为“受保护节点”再做通用清理这样就不会伤到代码和引用。清洗列表可以沉淀成一套默认规则删除所有script、style、ins、iframe移除多余的内联样式合并连续的空白字符将图片的loading属性设为lazy给视频链接加上显式的跳转提示。这些规则组合起来输出的 HTML 比原页面干净很多同时仍然保留原始语义。3.3 摘要与朗读不依赖大模型的轻量方案rea最初的版本没有摘要功能因为我一直觉得摘要必须“理解语义”那应该交给大模型做。但后来发现对一个个人阅读工具来说提取“开头部分 高权重段落”也能达到不错的效果。具体做法是取正文纯文本的前 300 字作为基础摘要再用简单的关键词权重算法统计出现频率较高的领域词挑选包含这些词的段落拼成摘要如果原文自带 description meta 标签就直接作为摘要来源。这个方案不追求生成全新的句子也不追求文学性只追求“让读者在列表页快速回忆文章讲了什么”实测下来完全够用。如果你的场景是批量生成推荐摘要可以考虑接入大模型 API但那就需要额外关注数据隐私和调用成本不是所有场景都值得。朗读功能我选择了浏览器原生的 Web Speech API。它不需要上传音频到任何服务器文字内容全部在本地完成语音合成隐私压力最小。前端读取到正文纯文本后按段落拆分朗读队列高亮当前正在朗读的段落并支持语速和声音切换。虽然效果比不上商业 TTS 引擎但胜在零成本、无依赖。4. 从 0 到 1 的完整实操过程把rea跑起来4.1 项目初始化与目录结构在正式写代码前我建议先初始化一个干净的 Node.js 项目目录。下面这个结构是我反复调整后的结果它把“抓取”“提取”“清洗”“前端展示”分层隔开后面加功能时不需要推倒重来。rea/ ├── package.json ├── src/ │ ├── server.js # Express 服务入口 │ ├── fetch.js # 抓取页面并处理编码 │ ├── extract.js # 调用 Readability.js │ ├── clean.js # 二次清洗与路径补全 │ └── store.js # SQLite 存取 ├── public/ │ ├── index.html # 阅读页 │ └── reader.css # 沉浸式排版样式 ├── data/ # SQLite 数据文件目录 └── Dockerfile初始化依赖时只需要执行npm init -y npm install express jsdom mozilla/readability cheerio sqlite3这样依赖就齐了。如果你不想用 SQLite前期也可以直接写 JSON 文件不过我在保存阅读进度和文章列表时用了 SQLite查询和更新都更方便。4.2 服务端接口实现URL 提交到正文返回核心接口就一个POST /api/extract接收{ url: https://example.com/article }返回 JSON包含标题、作者、正文 HTML、纯文本和摘要。抓取页面的时候要特别处理编码问题。很多老站点的页面是 GBK 编码如果直接按 UTF-8 解码中文会变成乱码。我先从响应头Content-Type里读charset如果读不到再用html标签里的meta charset做二次判断。下面的代码是一个比较稳的抓取方式// fetch.js const axios require(axios); async function fetchHtml(url) { const resp await axios.get(url, { headers: { User-Agent: Mozilla/5.0 (rea-reader/1.0), }, responseType: arraybuffer, timeout: 15000, }); const contentType resp.headers[content-type] || ; const metaMatch resp.data.toString(utf8, 0, 1024).match(/meta[^]charset[]?([a-zA-Z0-9-])/i); const charset contentType.includes(charset) ? contentType.match(/charset([a-zA-Z0-9-])/i)[1] : (metaMatch metaMatch[1]) || utf-8; return resp.data.toString(charset); }抓回来后提取和清洗的代码相对直接// extract.js const { JSDOM } require(jsdom); const { Readability } require(mozilla/readability); const cleanArticle require(./clean); function extractFromHtml(html, url) { const dom new JSDOM(html, { url }); const reader new Readability(dom.window.document); const article reader.parse(); if (!article) return null; return { title: article.title, author: article.byline, content: cleanArticle(article.content, url), textContent: article.textContent, excerpt: article.excerpt, }; }最后在server.js里把这个函数暴露成接口。4.3 前端阅读页实现沉浸式排版与阅读进度前端页面我尽量做得简单目标是让正文成为唯一的视觉焦点。页面上方是一个 URL 输入框输入链接后点击“读取”前端调用/api/extract把返回的 HTML 插入到正文容器中同时应用一套全局排版样式最大宽度 720px字号 18px行高 1.8颜色用深灰色而不是纯黑背景用低饱和度的米白色。夜间模式只需要切换一个darkclass然后调整背景和字体颜色。阅读进度记忆用localStorage就能实现。核心逻辑是监听滚动事件计算当前滚动位置占全文高度的百分比每 2 秒写入一次本地存储。再次打开同一文章时通过window.scrollTo自动定位到之前的进度。存储结构可以简单设计成const progressKey rea:progress: articleId; localStorage.setItem(progressKey, scrollRatio);注意要加一个防抖否则滚动时不断写localStorage会有性能问题。我用的是函数节流只在滚动结束后或固定间隔内写一次实测下来很稳。4.4 本地跑通与效果验证完成接口和页面后在项目目录执行node src/server.js启动服务后访问http://localhost:3000。在输入框粘贴一篇文章链接点击读取正常情况下几秒钟后就能看到干净的正文内容。我用几类不同结构的网站做验证纯文本博客、代码文档站、新闻门户、问答社区。前三种提取效果很理想问答社区偶尔会把问题描述和最佳答案混在一起这是因为页面本身把多个内容块放在同一个容器里提取算法很难精确切分。遇到这种情况我会在清洗阶段做额外处理如果检测到内容里出现“最佳回答”“编辑于”这类标志词就把后续内容截断。5. 常见问题与排查实录接口联调时最容易踩的坑5.1 接口返回空正文大多数不是算法问题我最早遇到的一个问题是明显有内容的页面reader.parse()却返回null。排查后发现不是算法不行而是抓取阶段拿到的 HTML 不完整。有些网站会对请求头做校验发现缺少Accept-Language或者Accept头时会返回一个简易版页面或者验证页面。解决方式是补全请求头模拟真实浏览器的请求特征并且把超时时间拉长一点。还有一种情况是页面在返回后立刻通过 JavaScript 跳转比如把 HTTP 请求重定向到带验证参数的页面。此时 Readability.js 拿到的是跳转页的 HTML自然没有正文。解决办法是先做一次重定向跟踪看看最终 URL 和原始 URL 是否一致如果不一致就用最终 URL 重新提取。表现可能原因解决方案返回 null请求头不全被服务器识别为爬虫补充 User-Agent、Accept、Accept-Language内容全是验证页网站有反爬跳转跟踪重定向取最终 URL中文乱码编码识别失败优先解析响应头 charset再回退到 meta 标签图片裂开相对路径未补全清洗阶段用 new URL() 统一转绝对路径5.2 动态渲染页面拿不到内容要不要引入无头浏览器单页应用和部分客户端渲染网站初始响应里只有一个空壳div正文要等 JavaScript 执行后才出现。面对这类页面jsdom Readability.js是无能为力的。最直接的方案是引入无头浏览器让它像真实浏览器一样渲染完成后再抓取 DOM。但这里要做一个权衡无头浏览器内存占用高、启动慢而且容易被更多网站识别。我的折中方案是先用普通抓取如果发现提取到的文本长度远低于页面可见文本量再触发无头渲染。具体判断逻辑是检查页面html里有没有的核心内容或者用字符数阈值判断。另外可以维护一个“已知动态站点”名单名单内的 URL 直接走无头浏览器其余仍走轻量方案。这个名单自己存成一个 JSON后续遇到同类站点往里加就行。5.3 跨域、编码、超时的典型坑前端直接调用/api/extract不存在跨域问题因为这个接口和前端同源。但如果你想把rea的提取接口暴露给其他前端项目使用就需要设置Access-Control-Allow-Origin。我建议在服务端加一个中间件只允许自己配置的域名列表跨域访问而不是直接开放*避免接口被滥用。编码问题除了前面说的乱码还有一个容易忽略的点有些页面会在 HTML 开头声明 UTF-8但实际内容是 Latin-1 编码。这时候按声明解析依然会乱。我的兜底策略是如果正文文本里出现大量替换符就用iconv-lite尝试其他常见编码重新解码。超时方面抓取请求设置 15 秒超时对无头浏览器额外设置 30 秒超时避免某个慢网站卡住整个服务。5.4 独家避坑技巧合理规划抓取频率与版权意识用rea处理文章时我给自己定了三条规则。第一条是尊重目标网站的服务器压力。提取接口在收到新 URL 时先查数据库是否存在 5 分钟内的缓存如果存在就直接返回缓存避免重复抓取。批量处理文章列表时每两个请求之间至少间隔 1 秒而不是一次性并发轰炸。第二条是区分“个人阅读缓存”和“公开转载”。rea默认生成的阅读页只在本地或自部署环境中可见不会自动发布到公网。如果你想把它做成一个公开展示文章库需要额外确认原文的授权要求。技术上的提取并不等于可以随意分发这一点必须分清。第三条是正确处理跳转和短链。我在清洗链接时会把http://统一归一化但不会强行展开所有短链因为短链跳转目标有时是登录页展开后反而拿不到有效内容。遇到短链我选择保留原始地址由前端跳转时自行处理。6. 部署发布与后续扩展从本地服务变成常驻工具6.1 用 Docker 把rea固化成可迁移的服务本地跑通后我开始想怎么让它常驻运行。最简单的方式是用 Docker 把整个服务打包成镜像这样不管是部署到云服务器还是放到家里的小主机上都能一键启动。Dockerfile 的核心思路是先安装依赖再拷贝源码最终暴露一个端口。FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --production COPY src ./src COPY public ./public RUN mkdir -p /app/data EXPOSE 3000 CMD [node, src/server.js]构建和启动只需要两条命令docker build -t rea-reader . docker run -p 3000:3000 -v rea-data:/app/data rea-reader把数据目录挂载成宿主机卷这样容器重建后文章数据和阅读进度都不会丢失。如果你希望服务对公网开放再套一层反向代理加 HTTPS 即可如果只在局域网使用直接访问宿主机 IP 加端口就行。6.2 数据持久化与离线缓存rea的数据存储我用了 SQLite因为它是单文件数据库备份非常方便。表结构只有三张articles存文章内容progress存阅读进度sync_log记录抓取状态。每次提取文章时先按 URL 的哈希值判断是否已经存在存在则更新不存在则插入。这样文章库天然具备去重能力。离线缓存是我觉得最实际的功能既然正文已经存在本地网络断开时也能继续阅读。实现上只要把正文 HTML、图片的本地缓存地址、样式文件都打包进articles表前端打开文章时优先读取本地的副本即可。图片离线缓存稍微麻烦一点我会在清洗阶段增加一个选项把图片下载到本地目录并把src替换成本地路径。代价是磁盘占用变大但换来了完全离线的阅读体验。6.3 进阶方向RSS 订阅、自动打标签与多端同步rea目前已经可以正常使用但我还在做几个扩展方向。第一个方向是接 RSS。RSS 本质上就是一批文章 URL 的列表我可以每天定时抓取订阅源把新文章自动存入文章库让rea变成一个自动更新的“私人刊物”。这样我不需要一个个手动输入链接打开rea看到的永远是最近收藏的内容。第二个方向是自动打标签。当前依赖于对正文文本做关键词统计按出现频率提取三到五个标签。虽然不如语义理解精准但对个人分类足够用。如果后续要更精准可以把摘要和标签生成统一接到大模型接口同时加一个手动修正入口。第三个方向是多端同步。SQLite 是单文件存储在单机上很顺手但多设备同步需要同步文件。我计划把存储层抽象成接口后面可以换成一个支持同步的轻量数据库服务这样手机、平板、电脑都能共享同一份阅读进度。如果你也想做一个类似的工具我的建议是千万不要一上来就追求宏大。先把“提取正文 阅读页面 本地存储”这条主链路跑通再根据实际使用感受加功能。我就是先做了最小版本用了一段时间确认这确实能提高阅读效率才开始扩展 RSS 和标签能力的。这样每一步都有实际需求驱动代码也不会越写越失控。
阅读完成 · 觉得有帮助?