资讯动态

从零开发浏览器阅读模式扩展:正文提取与MV3架构实战

发布时间:2026/10/14 7:42:08 来源:尧图企业网站定制
如果你手头突然出现一个只写着“rea”三个字母的项目代号大概率会愣一下这算什么名字我第一次看到的时候翻了半天记录才弄清楚这是项目方给一个阅读模式工具起的内部代号就是“read”去掉最后那个字母意思很直白——把被广告、弹窗、侧边栏、推荐流层层包裹的网页长文重新还原成一张干净的纸。这件事本身看着简单真做起来却涉及浏览器扩展、正文提取算法、DOM隔离、性能优化一大堆东西。我把整个从零到能用的过程复盘一遍内容包括需求边界怎么定、MV3扩展怎么搭、正文打分算法怎么写、阅读体验怎么调、以及各种现场踩坑的记录。想了解浏览器扩展开发、正文提取、页面级DOM处理的朋友这篇可以直接当参考。1. 项目代号rea背后需求拆解与能力边界1.1 从内部代号到需求本质“rea”就是“read”的缩写变体核心诉求只有一个让人能舒服地读完一篇长文。很多新闻站、技术博客、小说站点正文只占屏幕中间一小条左右两侧是推荐流底部是“热评”滚动时还会弹出各种遮挡层。我数过有些页面的正文文本占整个DOM文本量的比例不到20%剩下的全是导航、广告、相关阅读、评论区占位符。浏览器自带的阅读模式解决了一部分问题但现实里有很多限制进入路径藏得深、排版不可定制、对某些动态渲染的页面识别率不稳定。更关键的是它不提供“一键还原原文”这种符合直觉的操作。所以rea要做的是在浏览器扩展体系里给用户一个“当前页面瞬间变干净想回去还能回去”的能力开关。开发时最关键的一条原则是不改变网站本身不做任何数据收集不依赖后端。这是一款纯本地运行的客户端工具所有正文提取和样式渲染都发生在当前页面内部用户关掉扩展网站跟没发生过任何事一样。这个边界一开始就要立住否则后面很容易越做越重。1.2 功能边界哪些做哪些坚决不做接需求的时候最容易翻车的地方就是什么都想加。我给自己划了一个MVP清单从当前页面提取正文、标题、作者、发布时间、正文图片一键进入阅读视图一键还原原文支持字号、行距、最大宽度、深色模式调整并持久化提供快捷键、右键菜单、弹出窗口三种触发方式支持忽略站点列表让用户指定某些站点不启用同时明确不做的事项不做账号系统不做云同步不做推荐算法不做广告拦截。广告拦截是另一个独立品类塞进阅读插件里会让权限诉求膨胀而且会引入误拦风险。推荐算法更不用提那和“让阅读安静下来”的目标完全是反方向。能力边界想清楚之后整个工程结构就简单了一个内容脚本负责核心的提取和渲染一个后台Service Worker负责菜单和快捷键状态一个弹出窗口负责快速开关一个设置页面负责用户偏好。四个模块各干各的事通过消息传递和本地存储通信没有循环依赖也没有全局状态地狱。1.3 整体架构与运行流程rea的运行流程很像一个“临时手术台”用户触发阅读模式后内容脚本先把当前页面的DOM复制一份出来通过评分算法找到最像正文的节点清洗掉无用元素然后渲染成一个干净的阅读视图。原文节点不删除只是用隐藏方式保留在原地用户点击“还原”时把隐藏去掉页面就能回到原来的状态。这个“隐藏而非删除”的设计非常重要。很多新手做阅读模式时会把原页面直接清空等用户想回去时只能刷新结果滚动位置、展开状态、播放中的视频全部丢失。rea的做法是给原始容器设置display: none等还原时再恢复并滚动到原来的位置。既有“页面被替换”的感觉又保留了完整现场。2. 基础工程搭建MV3扩展的五步脚手架2.1 为什么必须选Manifest V3浏览器扩展从Manifest V2迁移到V3已经是不可逆的趋势新发布到扩展市场的扩展基本只接受V3版本。V3最大的变化是把后台页面改成了Service Worker也就是一个事件驱动的常驻脚本。V2时代的background.html后台页常驻内存V3的Service Worker在没有事件触发时会被浏览器回收下次有事件再重新唤醒。这个变化直接影响扩展的写法。V2时代可以放心在后台脚本里维护一个内存变量保存状态V3不行进程随时可能休眠状态必须持久化到storage里。rea的开关状态、当前页面是否处于阅读模式都用chrome.storage.session或者storage.local来记录不能依赖内存。V3还禁止加载远程代码扩展里所有JS文件必须是本地打包的。同时权限模型更克制host_permissions要显式声明才能跨域或注入到所有页面。对rea这种需要操作任意页面DOM的扩展来说权限在manifest里要写清楚不然后续排查“为什么点击没反应”会让你怀疑人生。2.2 manifest.json完整配置示例整个扩展的骨架就是一份manifest.json。下面是我落地时用的配置可以直接抄{ manifest_version: 3, name: rea - 极简阅读模式, version: 1.0.0, description: 一键将当前网页转换为干净的阅读视图支持还原与排版自定义。, permissions: [storage, contextMenus, commands], host_permissions: [all_urls], action: { default_popup: popup.html, default_icon: { 16: icons/icon16.png, 48: icons/icon48.png } }, background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content.js], css: [content.css], run_at: document_idle } ], commands: { toggle-read-mode: { suggested_key: { default: AltR }, description: 切换阅读模式 } }, options_page: options.html }几个关键字段说明content_scripts声明了内容脚本会在所有URL匹配的页面注入run_at选document_idle保证DOM基本就绪commands注册了快捷键AltR用户改不了默认键时可以在扩展管理页面自行调整options_page让用户能打开设置页。权限上只申请了存储、右键菜单、快捷键三个没有申请不必要的tabs或webRequest。2.3 内容脚本的注入策略与页面隔离内容脚本默认运行在ISOLATED世界这是一个与页面主线程隔离的JavaScript环境。好处是页面里的某些脚本无法轻易干扰rea的逻辑坏处则是如果想读取页面自己挂载的全局变量比如window.__INITIAL_STATE__之类默认拿不到。好在rea只关心DOM不关心页面的JS变量所以隔离世界的默认配置就够用。另一种注入方式是MAIN世界相当于把脚本直接塞进页面主线程能拿到页面所有全局变量但也会暴露在页面的安全环境里容易被页面脚本检测或篡改。rea没有这个需要所以保持默认。还有一类页面需要特别处理正文渲染在Shadow DOM里的组件化站点。常规querySelector找不到Shadow DOM内部的节点需要用openShadowRoot递归进入。对closed模式关闭的Shadow DOM内容脚本也无能为力只能通过可见文本的近似计算来兜底。3. 核心算法实战手写一个可以上线的正文提取器3.1 正文提取的基础思路候选节点投票制阅读模式能不能用百分之七十看正文提取准不准。社区里成熟的工具通常采用“候选节点投票”的思路遍历页面上所有容器节点根据文本长度、段落数量、链接密度、类名特征等因素给每个节点打分得分最高的那个节点就是“最像正文”的容器。可以用一个生活类比来理解你要在一堆候选人里找“最像文章正文”的那位不看长相只看几个硬指标——话说得多不多文本长度、是不是分段落讲的p标签数量、有没有大量外链要跑路链接密度低、身份标签像不像作者类名包含article/content/post。每条指标按权重计分最后总分最高者当选。打个比方导航栏通常有一堆链接、文字短、标签是nav或menu得分自然很低。评论区虽然段落多但文本碎片化、链接密度高、外部引用少也容易被筛掉。正文的典型特征是连续文本占比高、段落完整、链接极少、容器语义化标签往往是article或main。3.2 节点评分公式与核心代码评分公式不能拍脑袋要能解释每一个参数的意义。我落地时用的核心逻辑如下function scoreNode(node) { const meta getMeta(node); const linkText meta.linkText; const textLen meta.textLen; // 太短的内容直接淘汰 if (textLen 80) return -1; // 自身文本量 总文本 - 链接文本 const selfTextLen textLen - linkText; if (selfTextLen 50) return -1; const pCount node.querySelectorAll(p).length; const linkDensity textLen 0 ? 1 : linkText / textLen; let score 0; // 正文的文本量是基础用对数防止长文过分碾压 score Math.log(selfTextLen 1) * 4; // 段落数量是强特征一段加5分 score pCount * 5; // 链接密度是扣分项超过0.5基本判定为导航或列表 score - linkDensity * 40; // 类名启发式加权 score classifyBonus(node); return Math.max(0, score); } function classifyBonus(node) { const cls (node.className || ).toLowerCase(); const id (node.id || ).toLowerCase(); const text ${cls} ${id}; let bonus 0; if (/article|content|post|main|body|entry/i.test(text)) bonus 5; if (/comment|ad|widget|nav|menu|footer|sidebar|related/i.test(text)) bonus - 10; return bonus; }几个参数的调整经验Math.log是为了让一千字和一万字的文章在分数上差距不要过大否则遇到超长页时容器定位容易偏向整个body。linkDensity * 40是调参时发现最好用的惩罚值普通正文链接密度一般低于0.2导航栏往往超过0.6一扣一个准。类名启发式必须先做黑名单再考虑白名单因为页面上带content类名的广告容器也不少。选出最高分节点后不能直接拿来用还要做一次“上溯检查”如果得分最高的节点本身太窄比如只覆盖了某一段正文就要往上找祖先节点直到找到文本覆盖率大于60%的容器。这一步能避免误把一个p标签当正文。3.3 清洗节点、元信息提取与图片劫持拿到的候选节点往往还带一堆杂物。清洗时按这几步走先把script、style、iframe、noscript、svg直接移除然后扫描类名包含ad-、banner、promote、recommend的子节点整块删除最后处理图片标签因为很多站点的真实图片地址放在>:root { --rea-bg: #f7f5f2; --rea-text: #2a2a2a; --rea-font-size: 18px; --rea-line-height: 1.8; --rea-max-width: 720px; }米白背景比纯白更接近纸质书的感觉深色模式则把背景和文字反转成#1a1a1a和#d5d5d5。字号默认18px是因为很多人读网页时屏幕距离比较远小于16px会明显费眼。行距1.8是让长段落不糊成一团的底线再小就会觉得密集。用户每次调整设置时设置页把值写入storage.local内容脚本通过chrome.storage.onChanged监听变化实时更新CSS变量。这样不需要重新进入阅读模式就能看到效果实测手感最接近原生应用。4.2 入口收敛快捷键、右键菜单、浮动按钮与弹出窗口一个扩展可以有多个入口但底层逻辑必须收敛成同一个切换函数。rea的构成是后台脚本注册commands快捷键和contextMenus右键菜单二者都通过chrome.runtime.sendMessage通知内容脚本执行toggleReader()弹出窗口里的开关按钮同样发消息此外内容脚本还在页面右下角注入了一个悬浮的“rea”小按钮方便用户不碰键盘时点击。多个入口并存的最大问题是状态同步。用户按了快捷键进入阅读模式再点浮动按钮应该退出而不是再叠加一个视图。rea用>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑