☰
rea:网页正文提取与播放状态机驱动的稍后读朗读工具
2026/10/20 3:59:17 网站建设 项目流程

真正让我动手写 rea 的,是一个再普通不过的周日晚上。当时我打开那个号称“稍后读”的书签列表,发现里面躺着两百多篇文章,最早的一篇已经是半年前收藏的。点进去,文章还活着,浏览器还在,但我已经完全不记得当时为什么要把它存下来了。那一刻我意识到,我需要的不是又一个收藏夹,而是一个能在我洗碗、通勤、眼睛不想盯屏幕时,把这些内容“听完”的东西。

于是有了 rea。名字很简单,就是 read 的前三个字母。它不是阅读器,不是 RSS 工具,也不是知识管理平台。它只做一件事:把网页正文干干净净地抽出来,一段一段读给你听。我前后重写了三个版本,才终于把它的边界想清楚。这篇文章是把整个折腾过程里最有价值的部分做个记录,包括正文提取、朗读队列、状态机设计,以及三个让我最头疼的线上问题。

如果你也想做一个“本地优先、私密优先”的稍后读/语音朗读工具,或者只是好奇网页正文提取到底能有多少坑,这篇应该能给你一些参考。哪怕你完全不做这类工具,光是看我怎么把一个很小的需求一点点拆清楚,可能也会有点用处。

1. Rea 到底想解决什么问题——第三版才想明白的需求边界

1.1 我的“稍后读”其实是个“再也不读”

我最早写的 rea 第一版,其实是个浏览器脚本,功能很简单:把当前页面的正文复制出来,丢给系统语音朗读。用起来毛病不少,但最让我不舒服的是概念层面的事——它本质上还是一个“阅读器”,像是把所有文章重新排版了一遍给你看。可问题是,既然我还盯着屏幕,为什么不用原本的网页呢?更好的排版、图片、表格、代码块,原页面都处理得好好的,我非要多此一举抓一份纯文本,有什么意义?

想明白之后,我做了个很重要的决定:rea 的定位不是“阅读器”,而是“播放器”。它的目标不是让你看得更舒服,而是让你在没法看的时候也能听。这个定位直接决定了后面所有功能取舍。比如我不会做划线、高亮、批注,因为你在听的时候根本不会用到它们;我也不会做复杂的目录树和搜索,因为那不是这个场景的核心诉求。

第二个版本我却依然做复杂了。我给它加了标签、归档、全文搜索、多设备同步,甚至做了一个专门的数据看板。做完我就后悔了。这些东西每一个都牵扯到账号体系、云存储、数据格式设计,做起来很爽,但没有一个让“听完一篇文章”这个动作变容易。用户打开 rea 的真实路径应该是:看到一篇文章不错,复制链接进来,点一下播放,剩下的是听。越短越好。

第三个版本才真正收敛。功能少到一只手数得过来:添加链接、提取正文、转语音、记住进度、离线播放。没有账号,没有云同步,没有统计分析。多设备同步后来我用纯文件导入导出解决,反而比账号体系更省心。这个版本的代码量比第二版少了一半多,但我在日常生活中使用它的频率翻了不止一倍。少即是多,这句话真不是口号。

1.2 适合谁,不适合谁

如果你符合下面这几条,rea 这类工具会很对味:

  • 通勤路上不方便看屏幕,但耳朵是闲的。
  • 收藏了大量长文,却总觉得没有整段时间坐下来读。
  • 对阅读隐私比较敏感,不想把浏览记录、阅读行为交给第三方。
  • 喜欢工具长在自己手里,数据能导出能备份,而不是锁在一个平台里。

反过来,如果你想边听边做笔记,需要划重点、同步到云端、和团队协作共享批注,那 rea 完全不适合你。与其硬塞这些功能,不如一开始就承认它不擅长。

1.3 一次典型的完整使用流程

我日常的使用路径大概是这样:

  1. 在浏览器里看到一篇长文,复制链接。
  2. 打开 rea,粘贴,点“添加”。
  3. 后台开始抓取页面、分析正文、拆分成段落和句子。
  4. 我点“播放”,可以选从开头播,也可以从上次进度继续。
  5. 在播放页可以看到当前读到了哪一段,前后切换也能跳转。
  6. 到办公室了,暂停,锁屏走人。中午回来进度还在。

这里面没有一步是多余的。你也会发现,整个流程里没有一个“导入书签”“批量抓取”“全文搜索”之类的入口——不是因为我懒,而是刻意砍掉的。砍完之后,rea 才真正像一个工具,而不是一个需要用学习成本的 App。

2. 正文提取:给网页做“噪声过滤”的那套朴素算法

2.1 为什么直接用抓取结果会一团糟

说到正文提取,很多人第一反应是“正则抽<p>标签不就行了”。如果你试过就知道,现实完全不是这样。一个现代页面的 HTML 字符串里,可能有几十个<p>标签,真正属于正文的不到一半。导航栏、侧边栏推荐文章、评论区、页脚链接、Cookie 弹窗,全是<p>和<a>的排列组合。你要是按“取所有段落文字”来写,你最后会得到一篇包含二十篇推荐摘要和五段广告词的缝合怪。

更麻烦的是,很多页面还会用<div>包着真正的正文,而把文章标题、作者名、发布时间、相关阅读全都平铺在同一个结构层级里。你无法靠“取第几个<p>”来定位。我放弃正则以后,转向了一个更本质的做法:给页面上的候选区块打分,谁的分最高,谁就是正文。

这个方法不算新,很多阅读工具都在用,但它对我的核心价值是:不需要为每个网站单独写规则,又能处理大多数常规页面。我要做的就是设计好“分”怎么算。

2.2 一个可运行的内容打分器

我把整个页面按照 DOM 树遍历,找到一批候选容器,对每个容器算四个维度的分:

  • 可见文本长度
  • 段落标签数量
  • 标题相似度
  • 链接密集度

核心思路是:正文区域的文本通常比较长、段落多、链接占比低;而导航、标签云、广告位链接密度极高,即使字也多,也是垃圾字。下面是我用 JavaScript 写的一个简化版打分逻辑:

function cleanText(node) { let text = node.innerText || node.textContent || ''; return text.replace(/\s+/g, ' ').trim(); } function scoreBlock(node) { const text = cleanText(node); if (text.length < 80) return 0; let linkTextLength = 0; const links = node.querySelectorAll('a'); links.forEach((a) => { linkTextLength += cleanText(a).length; }); const linkDensity = linkTextLength / (text.length + 1); const pCount = node.querySelectorAll('p').length; const imgCount = node.querySelectorAll('img').length; const headingCount = node.querySelectorAll('h1, h2, h3').length; let score = 0; score += text.length * 0.4; score += pCount * 20; score += headingCount * 5; score += Math.min(imgCount, 5) * -6; score -= linkDensity * 100; // 语义化标签加权:在 <main> 或 <article> 内的容器,可信度天然高 if (node.closest('main, article')) { score += 120; } // 标题匹配加成:如果容器里出现了和页面标题高度相似的文字,很可能是正文标题 const pageTitle = cleanText(document.title).toLowerCase(); const containerText = text.toLowerCase(); if (containerText.startsWith(pageTitle.slice(0, 20))) { score += 30; } return Math.max(0, score); }

然后从根节点往下找得分最高的容器作为“正文候选”:

function findBestContainer(root) { let bestNode = null; let bestScore = -1; const nodes = root.querySelectorAll('div, article, main, section'); nodes.forEach((node) => { const s = scoreBlock(node); if (s > bestScore) { bestScore = s; bestNode = node; } }); return { node: bestNode, score: bestScore }; }

你如果细看这个打分函数,会发现它其实很像人类判断一篇文章的直觉:文字多、成段落、链接少、藏在语义标签里,就是正文。而链接密度惩罚是其中最有效的一招。很多网站的导航区域文字也不少,但大部分文字都包在<a>标签里,密度一算,分数立刻掉下去。

这套算法不是万能的。对于极简风格的博客、图片为主的网站、内容全部由视频构成的页面,它经常失手。所以我留了一个兜底:人工手动指定正文区间。rea 里有一个“重新选择正文”的入口,用户可以在页面上高亮一段文字,直接覆盖算法结果。这个兜底我强烈建议你做,因为再好的算法都不可能覆盖所有人类创意排版。

2.3 动态加载和懒加载:三次抓取结果为什么不一样

正文提取更大的坑在后面。现在很多页面不是一次性把内容渲染出来的,而是先用 JS 骨架屏,再异步请求接口,最后才把正文塞进 DOM。如果你在 HTML 加载完的一瞬间就去取正文,你可能会拿到空内容,或者只拿到标题和一个“加载中”。

我的方案是做一个“重试轮询”机制。不盲目等固定秒数,而是每次抓取完都算一次正文质量分,连续两次分数一致才认为稳定。这样既不会在慢页面没加载完就出手,也不会在快速页面上白白等三秒。

简化后的伪代码大致是这样:

async function extractWithRetry(url, maxAttempts = 6) { let lastResult = null; let stableCount = 0; for (let i = 0; i < maxAttempts; i++) { const html = await fetchPage(url); const result = extractArticle(html); if (result.score <= 0) { await sleep(600 * (i + 1)); continue; } if (lastResult && result.score === lastResult.score) { stableCount++; } else { stableCount = 0; } lastResult = result; if (stableCount >= 1) return result; await sleep(800 * (i + 1)); } return lastResult; }

这里有个细节:连续两次分数相同,才算是“稳定”。第一次拿到 30 分,第二次 30 分,可以认为内容加载完了。如果第一次 0 分、第二次 80 分、第三次 92 分,说明内容还在变,那就继续等。在实际测试中,绝大多数页面三次以内就能稳定,只有极少数接口慢的页面会浪费到第五次第六次。

另一个很关键的点:在等待期间不要把中间结果直接塞给用户。用户看到第一版 30 分的正文,然后又看到 92 分的内容刷一下变了,观感会很差。rea 的做法是先把状态显示成“提取中”,等稳定了再一次性渲染。宁可慢一点,也不要闪跳。

3. 朗读队列:从“整页朗读”到“状态机驱动”的改造

3.1 为什么需要状态机

第一版的 rea 非常简单:点击播放,读取全部段落,逐段朗读。这听起来没问题,但实际使用中很快就会发现,浏览器的语音引擎并不听你使唤。你点一下播放,它开始读;但后台播放一会儿会被中断,切标签页会暂停,长文本读到一半声音可能直接消失。如果没有一个清晰的状态来记录“现在到底在干什么”,你会发现界面上按钮状态和真实播放情况完全对不上。

我从第三版开始把整个播放流程改成了显式状态机。状态一共六种:

idle→loading→playing→paused→error

以及一个临时的interrupted状态,用于处理系统打断。

每个状态之间的关系是单向且明确的:

  • 添加链接后进入loading,正文提取完成后进入idle。
  • 点播放,从idle或paused进入playing。
  • 中途点击暂停,进入paused。
  • 播完最后一句,回到idle。
  • 任何异常,进入error,用户可以手动恢复。

这个状态机最重要的价值是:任何时候用户刷新页面、切走又切回、甚至浏览器杀掉了页面,rea 都能根据保存的progress恢复到正确位置。我不需要去猜播到哪了。

我保存的进度数据结构大致长这样:

{ "version": 1, "itemId": "a1b2c3", "title": "为什么你需要的不是收藏夹", "url": "https://example.com/article", "addedAt": "2024-12-01T08:30:00.000Z", "progress": { "paragraphIndex": 3, "charOffset": 128 } }

paragraphIndex指读到第几段,charOffset指在段内的第几个字。音频恢复时,直接定位到段内偏移继续读,而不是从头把这一段再读一遍。这个体验差别很大,尤其是长文章,恢复速度的准确度直接决定用户愿不愿意继续用。

3.2 长文本拆分的两个粒度

把一篇文章喂给语音引擎之前,必须先拆分。一次喂太多,有些引擎会读得含糊、卡顿,甚至在长文本中间直接罢工。一次喂太少,又会导致句子之间停顿太碎。

rea 做了两层拆分。第一层按段落拆,服务于“跳段”和“进度显示”。第二层把每个段落再按句子拆,服务实际朗读单元。句子拆分不是简单按句号切,我用的分隔符优先级是:句号、问号、感叹号、分号,然后是冒号。同时要忽略“图1:”“注:”这类非完整句子的短片段。

function splitSentences(paragraph) { const parts = paragraph.split(/(?<=[。?!;:])/); return parts .map((s) => s.trim()) .filter((s) => s.length > 1 && s.length < 180); }

句子上限我设成 180 个字符。超过这个长度的句子会在下一个标点处二次切断,避免一次朗读太长。这个数字不用太精确,但你一定要有上限。我在测试中发现,超过二百字的长句朗读明显会有吞字、跳字的风险。

3.3 音源选择:系统语音、本地模型、云端接口

rea 默认使用系统自带语音引擎,也就是浏览器暴露出的语音合成能力,好处是零成本、离线可用、没有额外权限。但系统语音的问题也明显:音色机械、断句有时生硬,长句连读会有点怪。

后来我加了一个音源切换选项,用户可以在系统语音和本地模型语音之间切换。本地模型语音的音质要好不少,但它需要更长的初始化时间,也会占用一些系统资源,不是每个人都愿意接受。至于云端接口,我研究过,最后放弃了。不是因为效果不好,而是因为它和 rea “本地优先、隐私优先”的定位背道而驰。把用户正在听的文章内容发给第三方服务,这个设计我怎么也说服不了自己。

我的建议是:把音源做成插件式的。核心播放逻辑只依赖一个统一的“朗读接口”,后面接什么音源都行。这样既保留了最轻量的默认方案,又给追求音质的用户留了一条升级路径。

4. 实际使用中遇到的三类最难缠的问题

4.1 正文里混进了页脚,朗读把“版权所有”也读了

正文提取算法上线后,第一个让我崩溃的问题是:有的页面选出来的正文容器,最下方混着一大片页脚。页脚里通常是版权声明、备案号、友情链接,“版权所有”四个字被读出来的时候,整个文章的氛围就全毁了。

排查了很久,我发现问题出在评分器上。页脚区域虽然链接密度高,但如果它前面正文足够长,整体容器分数依然很高。而我最终选出的容器可能刚好是包含正文和页脚的一个大<div>,于是垃圾信息跟着正文一起进来了。

解决方式是在评分器里增加一个“尾部净化”步骤。当选出正文容器后,再从容器底部逐级向上扫描,删除那些文本以“版权”“备案”“关于我们”“联系方式”等特征开头的节点。重点不是正则匹配所有情况,而是把明显的尾部噪音去掉。

function stripFooter(node) { const children = Array.from(node.children); for (let i = children.length - 1; i >= 0; i--) { const text = cleanText(children[i]); if (text.length > 0 && text.length < 300) { if (/版权|备案|关于我们|联系|关注|Copyright|All Rights Reserved/i.test(text)) { children[i].remove(); } } } return node; }

这个方案不完美,但已经能挡住绝大多数尾部的垃圾信息。极端情况下,用户手动“重新选择正文”仍然是最可靠的兜底。

4.2 动态渲染页面:三次请求三次结果不一样

第二个让我血压升高的坑,是同一个链接加进 rea,第一次抓取出来只提取到标题,第二次抓取多了第一段,第三次才完整。我要是不处理,用户会看到三版不同的正文在界面上闪,根本没法用。

前面说到了重试轮询,但实际还有一个隐藏问题:有些页面会根据 URL 参数返回不同内容,还有的会依据地理位置、登录态返回不同的首屏。更麻烦的是,某些页面的正文不是前几次请求里那些“稳定加载出来的内容”,而是用户滚动到一定位置才触发接口加载的。这种页面你用任何轮询策略都很难完美拿到全文。

我最后采用的折中方案是:

  • 不追求拿到 100% 的正文,只追求拿到可见区域内最完整的正文。
  • 如果连续三次提取结果都不一致,就采用最后两次中分数更高的那一次。
  • 同时把“提取不完整”这个状态暴露给用户,而不是默默给出一段残文。

rea 里加了一个“重新提取”按钮,专门应对这种情况。哪怕算法拿不到完整正文,至少用户不会被困住。

4.3 浏览器打断播放的机制与恢复策略

第三个问题最麻烦,因为它发生在浏览器和系统层面,不是纯前端能完全控制的。我在实际使用中遇到三种打断情况:

  1. 切到后台标签页后,浏览器为了省电降低了页面优先级,朗读变卡。
  2. Chrome 风格的自动播放策略,在我没有响应用户操作的情况下,直接暂停了声音。
  3. 某些系统语音引擎在长文本读太长时间后,会自动静音或停止。

前两种情况,我靠监听页面的可见性变化来处理。当用户重新回到页面时,rea 检查当前进度和实际播放状态,如果发现不一致,就恢复到最近的进度。

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { const progress = loadProgress(); if (player.state === 'playing' || player.state === 'interrupted') { player.resume(progress); } } });

第三种情况很难根除,因为语音引擎停止后不一定会抛出标准错误。我已经尽量缩短每次朗读的句子长度,减少长时间连续朗读触发的概率,但偶尔还是会遇到。我的心态是:这不是 rea 能完全解决的问题,能做的是把错误暴露清楚,让用户在“重新播放”后还能回到原进度。能做到这一点,体验就已经相当可靠了。

5. 隐私、可靠性和下一步计划

5.1 为什么坚持本地优先

做第三版时,我反复问自己一个问题:这个工具要不要账号体系?要不要把数据存到云端方便多设备读取?功能上当然想,但隐私上我非常不舒服。

The 理由很现实:rea 里的数据就是我的阅读历史。它包含我看了哪些文章、读到了哪一段、在哪个标题上停留最久。这些数据如果交给专门做内容推荐的平台,也许能换来更聪明的推荐,但我不想要那个交换。我更希望 rea 是一个放在我口袋里、所有数据都归我管的工具。

所以本地优先是一个刻意选择。所有解析结果、播放记录、进度信息都存在本地。没有分析统计,没有埋点,没有“为你推荐”。页面上的“添加链接”按钮,只是往本地队列里加一条数据而已,整个流程都在用户自己的设备上完成。

5.2 用文件导入导出代替云同步

多设备同步怎么办?我的方案很原始但很有效:导出 JSON 文件,换设备后导入。rea 把整个书架、进度、设置全部打包成一个文件,我可以在手机浏览器和电脑浏览器之间手工搬一次。

这个方案的好处是没有中间服务器,也不依赖任何账号体系。缺点是每次切换设备都要手动操作一次,不自动化。但对我来说,这个代价完全可接受。我甚至在思考,要不要做一个局域网内设备间快速传输功能,让两台设备在同一个 WiFi 下直接传文件,省掉“保存文件再打开文件”的步骤。这也是一个以后可以扩展的方向。

5.3 后续想做的两件事:摘要和章节跳转

rea 目前能做的是把正文完整读出来,但有些长文我其实只想知道“这篇讲了什么”。下一步我想加一个“极简摘要”模式,在播放前先用一小段话告诉用户文章大致主题,用户听了觉得需要再继续听全文。这个摘要最好也能在本地生成,避免把文章内容送出去。

另一个想做的事情是章节跳转。现在很多长文是有小标题的,我的正文提取器其实已经能拿到h2、h3的位置,但播放器还没有利用起来。如果能在播放时按标题切换章节,体验会好很多,尤其是那种两万字的深度长文。

这些功能会陆续加,但我会保持同一原则:尽量在本地完成,不引入账号体系,不牺牲播放链路的速度。Rea 从一开始就不是要做一个大而全的平台,它更像是我的私人“朗读伴侣”。如果它也能让你在不想盯着屏幕的时候,把那些存了很久的文章真正“读”完,那这个工具存在的意义就实现了。

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

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

立即咨询