网页转AI友好Markdown:清洗表格、行号与Token浪费的浏览器扩展实战
发布时间:2026/10/2 5:04:26来源:尧图网络
做这个扩展的直接原因是我最近实在被“复制网页喂 AI”这件事恶心到了。每次想从一个带表格的网页、代码文档或者某篇技术文章里取内容粘贴到对话里全是 div、span、class 满天飞偶尔还带上一整条导航栏和推荐位。模型倒是能读但 token 烧得肉疼上下文窗口被一堆html、body、滚动容器占据真正有用的信息反而被稀释得厉害。更烦人的是那些带行号的代码块复制出来一排排 “1、2、3”喂给 AI 之后它甚至可能理解成代码内容的一部分干扰特别大。做这个扩展项目的初衷就是想把“网页 → 大模型”这条链路里最脏的那几个环节一次清理干净复杂非标表格、代码行号噪点、以及无意义的 token 浪费。这篇文章会把整个工具的思路、关键实现细节、踩过的坑、以及几个真实页面的前后对比数据完整记录下来。如果你也经常用网页内容喂 GPT、Claude 或者本地模型这篇文章值得完整看一遍如果你正准备做一个类似的浏览器扩展可以直接抄作业。1. 为什么网页直接复制根本不能喂给 AI1.1 复制粘贴后的“HTML 混合沙拉”大多数人的日常操作其实是这样选中网页正文CtrlC然后切到对话里 CtrlV。浏览器复制出来的并不是纯文本而是一份带着原始结构和嵌套标签的“富文本”。聊天界面通常会做兼容处理把 HTML 的可见文本提取出来塞进输入框但这个过程是“尽力而为”的结果往往千奇百怪。我拿一个带代码的掘金文章页面试过直接全选后粘贴得到的文本里不仅有正文和代码还混着“作者推荐”“目录”“相关文章”这些页面附属区块。这些区块虽然不影响人眼阅读但对大模型来说全是噪音。Token 不看“内容质量”它只看“字符串长度和拆分复杂度”。一段 HTML 里的 id、class、style 属性在 tokenizer 眼里和正文没有任何区别都会占据上下文容积。更深一层的问题在表格。浏览器复制表格时会带上很多布局残留因为现在很多网页表格根本不是真正的table而是用大量的div加上 flex 布局模拟出来的“伪表格”。这类伪表格在复制时会变成一行行切片式文本列关系完全丢失。真正用table构建的页面如果里面还套了 rowspan 或 colspan复制出来也经常错位。这些脏数据喂给 AI 之后轻则它读不懂“第几列对应第几项”重则它会按照错误的结构生成回答非常坑。我当时在做的这个扩展最核心的定位就是把页面内容转换成结构最干净、信息密度最高、且能被大模型稳定理解的 Markdown 文本而不是简单地把 HTML 压缩成字符串。这个定位决定了后面所有的技术选型。1.2 表格部分为什么总是一团乱麻复杂表格是“AI 喂食”场景里最考验转换器的一个模块。常见的问题大约有这几类第一类是结构本身不合规。比如一个单元格里嵌套了列表、代码块、甚至另一个表格。标准的 Markdown 表格语法是扁平的一个格子理论上只能放一行普通文本。如果按照 GFMGitHub Flavored Markdown规范去转遇到嵌套内容就只能丢失或者压成一行导致原始信息残缺。第二类是“伪表格”问题。现在很多站点用 div CSS 模拟表格布局比如某些后台管理系统的详情页、报表工具导出的展示页。这类页面根本没有tr、td节点传统的 table 规则完全找不到入口必须依赖视觉特征去推断列边界。这个推断过程非常容易出错。第三类是合并单元格。一个带rowspan2的表格如果转 Markdown 时不做占位处理那么后面那一行的列数就少一列整个表直接串位。这个问题在几乎所有现成转换器里都存在因为 Markdown 表格本身就不支持合并单元格。第四类是行号和序号列。很多文档网站为了让代码行更好看会给表格或者代码块加上一个“行号列”这个列里就是纯数字。转换的时候不做识别它就会变成表格里一堆无意义的数字进一步浪费 token。这个扩展的表格处理模块最开始只是想解决第四类“行号列”问题结果做着做着发现前面三类问题才更难缠最后不得不在表格规则上做了一个相对完整的递归处理方案。后面第 3 章我会详细展开实现逻辑。1.3 行号噪点是如何产生的行号噪点主要有两种来源。一种是代码高亮库生成的“真节点”。比如 highlight.js 的行号模式会在pre里生成一组span classhljs-ln-numbers或者用table.hljs-ln把代码拆成两列第一列放行号。Prism 插件也有类似的行为会渲染出span classline-numbers-rows。Shiki 的某些主题同样会生成行号节点。这些行号在页面里看着很自然但一旦进入复制链路就会变成文本里的1\n2\n3\n4...而且是混在代码中间的。另一种是代码编辑器的“伪行号”。你在语雀、飞书文档或者一些在线 IDE 里复制代码行号不一定存在于 DOM 节点中而是由容器的 CSS::before伪元素绘制出来。普通复制的时候浏览器不会把伪元素内容带进剪贴板这也是我之前的一个误判——我以为伪元素行号不会污染复制结果。实际上有些编辑器为了保证复制体验做了“复制时把行号拼进文本”的监听逻辑或者用了-webkit-user-modify之类的手段。最终结果仍然是一堆行号混进代码里。另外还有一种容易被忽略的情况某些中文博客的代码块每行前面带着“1.”、“2.”这种手动编号。这不是高亮库生成的是作者自己敲进去的。清洗的时候要按“数字加顿号或点号”这种格式做启发式识别而且要看上下文——普通正文里出现2024.这种数字开头不能乱清但在代码块内部就基本可以放心处理。1.4 Token 浪费到底有多严重Token 浪费不是一个模糊的概念它可以直接量化。我拿一个 3000 字左右的掘金技术文章页面做测试页面整体 HTML 抓下来约 45KB。直接浏览器复制、粘贴到对话的输入框客户端的 tokenizer 对这段文本的估算结果是大概 5200 token。经过扩展转换后的 Markdown 文本只有 9KB 左右估算 token 大约 1800。也就是说有接近 65% 的 token 被浪费在了结构和噪音上。如果你经常做“批量投喂”——比如把一堆文档、文章喂给 AI 做摘要——这个浪费会直接决定你要不要多开一次对话。常用模型单次上下文容量在 8K 到 128K 不等垃圾占掉一半就没有意义了。而且 token 是按照请求来计费的喂同样的内容花同样的时间真正有效的信息量能差两三倍。下面第四章我会放更加详细的三个页面实测数据。2. 整体设计与技术选型2.1 为什么不用现成的“一键转 MD”工具很多浏览器都有“Web 转 Markdown”扩展也有在线的 html2md、turndown 等服务为什么还要自己做坦白讲通用转换工具的场景是“人看的 Markdown”我的场景是“模型读的 Markdown”这两个要求有很大的差别。现有工具的毛病集中在三块第一输出结果是通用优化对“AI 友好性”没有专门考虑。比如一个链接通用工具转换后可能是[链接文字](https://.../)这个没问题但很多工具会把整个页面所有导航链接、版权链接、推荐链接全部保留导致 token 大幅度膨胀。我自己的扩展做了一项处理给链接区块设置了“阈值判断”把页面底部频繁出现的版权链接、备案链接、订阅链接这一类“非正文链接”直接降级成纯文本或者丢弃。第二现有工具普遍对复杂表格没有深度适配。Turndown 库本身支持表格但它处理不了嵌套结构。它会把td里所有内容直接当作文本 flatten 掉丢失内部格式。更麻烦的是遇到代码高亮生成的“表格型行号”时很多工具会把它当成正常表格原样转出来产生一个诡异的两列表格。这对人眼来说也许还能忍对模型来说就是灾难。第三现有工具几乎都没有“token 预算”的概念。我不知道转换出来的内容到底占多少 token无法在投喂前做成本控制。这是我那个扩展里坚持加入 token 估算模块的原因——不要等到对话里才发现喂多了。2.2 扩展的完整处理管线整个扩展的处理链路是页面 DOM → 克隆与清洗 → 正文抽取 → 自定义 Markdown 转换 → 二次净化 → token 估算输出。第一部分是 DOM 克隆与清洗。我通过document.cloneNode(true)拿到页面完整副本然后在这个副本上做操作绝不动原页面。清洗的目标很明确把script、style、link、meta、iframe、noscript等对 AI 无意义的节点直接移除把藏在导航栏、页脚、侧边栏里的文本降噪识别出广告区块或者“下一篇推荐”这类模块按特定类名优先清除。第二部分是正文抽取用的是 Mozilla 的 Readability 库。这个库原本是 Firefox Reader View 的底层实现可以按文本密度和链接密度计算“正文分数”把页面的核心内容提取出来。它不是万能灵药对某些门户网站分块比较敏感但胜在稳定而且我可以给它配置一个“自定义删除类名”列表进一步过滤噪音。第三部分是核心的 Markdown 转换层。我基于 Turndown 做了一层深度封装再加入 GFM 插件的表格支持、任务列表支持。同时我完全重写了它的表格规则改成了下面第 3 章会详细说的递归处理逻辑。这一层是整个扩展的“灵魂”也是投入开发时间最多的部分。第四部分是二次净化。转换完成后代码块里的行号残留、多余空白、特殊 HTML 实体、页面自带的水印文字在这一层做兜底清洗。为什么要有这一层因为很多噪音不是 DOM 节点而是藏在文本内容里比如某些网站的文本里强制插入了不可见字符或者代码高亮库在代码文本里混入了一些 unicode 空格。第五部分是 token 估算。输出面板上会直接显示“转换前字符数”“转换后字符数”“估算 token 数”“预计节省比例”方便投喂前心里有数。2.3 Manifest V3 的注入策略扩展基于 Manifest V3 构建但没有申请大地量的host_permissions。我只在用户点击扩展按钮的那一刻通过chrome.scripting.executeScript动态注入 content script。这么做的好处是权限最小化只在用户主动触发时对当前标签页生效不常驻后台也没有全局域名授权。manifest.json的权限声明非常精简{ manifest_version: 3, name: FeedCleaner, version: 1.0.0, permissions: [activeTab, scripting, clipboardWrite], action: { default_popup: popup.html, default_title: 转换当前页面为 AI 友好的 Markdown }, background: { service_worker: background.js } }用户点击按钮后popup 里执行这段逻辑const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) return; await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: [content.js] });单独的clipboardWrite权限保证了可以直接复制结果到剪贴板不用用户手动去选文本复制。整个注入策略围绕“按需加载”来设计扩展启动时只有 popup 这一个入口不挂全局监听器对网页性能零影响。3. 核心功能实现细节3.1 表格从“转坏”到“保真”的递归转换法这里直接讲表格部分是全部环节里最值得拿出来说的。Turndown 自带的表格规则很简单遍历每一行拿每一行里的 td、th 文本拼接成| 单元格 | 单元格 |。这个逻辑处理纯文本表格没问题但遇到复杂事件就会崩。我做了几处改造先看默认规则的问题。假如一个 td 里有列表td ul li苹果/li li香蕉/li /ul /td默认规则会渲染成苹果香蕉列表结构完全丢失。如果 td 里有代码块tdprecodeconst a 1;/code/pre/td默认规则同样会把代码压成一行。这显然不行。我的改造思路是把“td 内部”当成一个独立的“小块文档”来递归转换。每个 td 的内容不再简单取innerText而是走一遍完整的turndown()递归service.addRule(tableSafe, { filter: table, replacement(content, node) { // 先判断是否为行号伪表格 if (isLineNumberTable(node)) { return extractCodeFromLineNumberTable(node); } const rows Array.from(node.querySelectorAll(tr)); if (!rows.length) return content; const markdownRows rows.map((tr) { const cells Array.from(tr.children).filter((child) /^td|th$/i.test(child.tagName) ); const cellTexts cells.map((cell) { let inner service.turndown(cell.innerHTML); // 将单元格内换行压缩成 br 语义或直接转义 inner inner.replace(/\|/g, \\|); inner inner.replace(/\n/g, br); inner inner.replace(/\s/g, ).trim(); return inner; }); return | ${cellTexts.join( | )} |; }); return \n\n markdownRows.join(\n) \n\n; } });这段代码里有一个关键设计点单元格内如果出现了换行文本我选择压缩成br。因为 GFM 的表格语法不支持单元格内换行某个格子里的多行文本如果原样保留成\n表格的管道符结构就会被破坏。用br来表示软换行至少能保留行长逻辑且很多 Markdown 渲染器都会把br显示成显式换行。但你要问了如果单元格里是一大段代码或者一个列表压缩成br不是丢信息吗这里我做了更深的处理在进入表格规则之前先对 table 节点做一次“复杂单元格检测”如果发现单元格里有pre、blockquote、ul、ol这类块级结构就直接把整个表格降级成“段落式表格”也就是每一行渲染成一个引用块内部保留 Markdown 原生格式。这样做的代价是表格不再是视觉上的标准表格但信息的保真度最高模型读起来反而更舒服。function hasComplexCell(table) { return !!table.querySelector(td pre, td ul, td ol, td blockquote); } if (hasComplexCell(tableNode)) { // 降级逻辑每行转成一个 blockquote 段落 }这个降级开关很重要。保结构还是保形态必须根据内容决定。人眼更喜欢表格形态大模型更喜欢“结构化段落”在喂 AI 这个场景里我选择保结构。还有一个细节合并单元格。colspan2的格子转换后如果只输出一个单元格内容列数就不齐。我会在遍历时检测到colspan在后面补两个占位符让行对齐const rowspanMap new Map(); cells.forEach((cell, index) { const colspan parseInt(cell.getAttribute(colspan) || 1, 10); const rowspan parseInt(cell.getAttribute(rowspan) || 1, 10); // 把占位符补齐 });还是那句话Markdown 不支持合并单元格所以物理结构上我们做不到完全等价的还原但通过占位符至少能保证模型读到的“每一行列数”是一致的不会被串列。3.2 行号与代码块噪点清洗代码块的行号清洗要分几个层次来处理。第一层节点级清洗。在 DOM 克隆阶段如果发现pre节点内部有hljs-ln-numbers、line-numbers-rows、>codeBlock.querySelectorAll(.hljs-ln-numbers, .line-numbers-rows, [data-line-number]).forEach((el) el.remove());这个操作之所以能做是因为高亮库在生成行号时留下了标记类名。我扒过几个常见库的类名规律highlight.js 是hljs-ln-numbersPrism 插件是line-numbers-rows部分 Shiki 主题用的是>function isLineNumberTable(node) { const firstCell node.querySelector(tr td, tr th); if (!firstCell) return false; const text firstCell.textContent.trim(); return /^\d{1,4}$/.test(text); } function extractCodeFromLineNumberTable(node) { const codeCells Array.from(node.querySelectorAll(tr)).map((tr) { const cells Array.from(tr.children).filter((el) /^td$/i.test(el.tagName)); return cells.length 2 ? cells[1] : cells[0]; }); const codeText codeCells.map((cell) cell.textContent).join(\n); return \n codeText \n; }注意这里用了textContent而不是innerText这是一个很重要的细节。innerText会受页面样式影响遇到被 CSS 隐藏的行会直接丢掉导致代码残缺textContent永远返回完整文本不存在这个问题。第三层文本级清洗。前面两层的办法处理的是“真实节点”但如果行号是伪元素绘制、或已经混入代码文本内部就只能做正则兜底。在二次净化阶段我对代码块内部按“去除每一行开头的行号”逻辑处理let code content; code code.replace(/^\s*\d{1,4}[.、]\s*/gm, );这种规则有一定的误伤风险比如一段代码里恰好有123.这种常量。所以我会加一个限制只有“至少连续两行都带行号”时才启用这个正则。这是典型的“宁可少清不要误伤”的平衡策略。清洗完成后输出端会把代码块统一标注为独立段落并且从高亮主题节点里提取语言类型。比如classlanguage-javascript会被保留成js让模型明确知道这段代码是什么语言。3.3 数学公式和 HTML 实体保全喂 AI 的场景里经常遇到数学公式。网页里的公式有两种常见形态一种是 MathJax 渲染后的复杂 SVG/HTML一种是 katex 渲染的 HTML 结构。直接复制模型看到的是乱糟糟的渲染节点而不是 LaTeX 源码。想让模型理解数学表达式最好输出$...$或\(...\)形式的 LaTeX 源码。MathJax 有个特性源页面里通常存在script[typemath/tex]或其他原始公式容器。在转换层我会优先尝试从这类容器里拿 LaTeX 源码如果拿不到就尝试从渲染后的容器里反推。具体策略const mathTeX node.querySelector(script[typemath/tex], script[typemath/tex; latex-environments]); if (mathTeX) { return $ mathTeX.textContent.trim() $; }如果是 katexaria-hiddentrue的渲染结果不能直接拿但容器上有>text text.replace(/amp;/g, ); text text.replace(/lt;/g, ); text text.replace(/gt;/g, ); text text.replace(/quot;/g, ); text text.replace(/#39;/g, ); text text.replace(/#124;/g, |); text text.replace(/#36;/g, $); text text.replace(/#42;/g, *);代码块内部保持原样一来避免破坏代码真实性二来大多数模型对lt;这种形式也能理解不会引入太多语义损失。3.4 输出前的 Token 估算反馈扩展的 popup 面板上在转换结果底部会展示一行统计信息原文 DOM 字符数、转换后 Markdown 字符数、估算 token 数、预计节省比例。token 的精确计算其实很复杂不同的模型用的 tokenizer 不一样。OpenAI 系用的是cl100k_base或o200k_baseClaude 用的是自己的 BPE 词表。好在浏览器端跑完整 tokenizer 不太实际所以我实现了一个轻量估算函数用“中文字符 英文单词 标点”三类拆分function estimateTokens(text) { const chineseChars (text.match(/[\u4e00-\u9fa5]/g) || []).length; const nonChinese text.replace(/[\u4e00-\u9fa5]/g, ); const words nonChinese.split(/\s/).filter(Boolean).length; const punctuation (text.match(/[。、,.!?;:()\[\]{}]/g) || []).length; return Math.round(chineseChars * 0.8 words * 1.3 punctuation * 0.5); }这个算法和真实 tokenizer 的对齐精度在 10% 以内。我拿一段 2000 中文字符的文本用 OpenAI 的 tokenizer 页面做过对照误差基本可以接受。更重要的是它能给出一个“量级感”让你在投喂前知道这段内容到底值不值得。面板上还会给一个“token 预算”颜色标识低于 2k 显示绿色2k 到 6k 显示黄色高于 6k 显示橙色。这个反馈机制用久了之后我对“什么内容该喂、什么内容先裁再喂”会有更直观的感知。4. 实测数据三个真实页面处理前后对比4.1 测试页面与配置我选了三个典型页面来做对比测试。测试环境是 Chrome 129扩展版本 1.0.0所有页面在无缓存状态下加载完成 3 秒后执行转换。为了避免网络波动影响每个页面测三次取中间值。页面 A掘金上的一篇前端性能优化长文约 3500 字含两个简单表格和一段带行号的代码块。页面 B某在线算法题解页面包含详细的解题说明和大量带行号的代码块。页面 C一个 GitHub README 页面包含复杂嵌套表格、徽章图标链接、任务列表和代码片段。对照标准是页面加载完成后按正常的浏览器全选复制粘贴到一个标准输入框再用同一套 token 估算函数处理两边结果。4.2 实测结果表页面原始 DOM 字符数直接复制文本字符数转换后 MD 字符数直接复制估算 token转换后估算 token节省比例页面 A96,00014,3006,2002,8001,10060.7%页面 B112,00018,5007,8003,9001,50061.5%页面 C88,00012,1004,9002,50089064.4%从结果看转换后的 token 平均只有直接复制文本的 60% 左右。这里还没有算“直接复制文本”里残留导航链接造成的额外浪费如果加上那部分扩展的实际收益还会更高。页面 B 的收益最明显。它的代码块用的是 Prism 的 line-numbers 插件而且是 table 布局版本。直接复制时每一行代码前面都带着行号占掉了大量 token。转换后行号被完整剥离代码块纯代码模型理解效率提升明显。页面 C 的复杂嵌套表格直接复制时列关系错乱得一塌糊涂模型根本看不出三列里哪列是“用途”哪列是“参数”。经过扩展的“降级表格”逻辑处理后每一行变成结构清晰的引用块字段语义完全保留。4.3 对长文投喂的实际影响数据看的是 token实际体验差异更大。在投喂前我通常会先跑一遍扩展把结果稍微扫一眼确认表格结构没乱然后一次性贴进去。模型对这类“半结构化文本”的解析速度明显更快回答也更准确。以前直接把代码带行号的代码贴给模型它偶尔会把行号当成代码的一部分比如追问“第二行是什么意思”时它可能回答“你说的是 3 还是 4”这种尴尬对话。去掉行号噪点之后模型对代码块的定位准确多了提问时也不会出现行号带来的歧义。另一个影响是多文档投喂的效率。以前单次对话窗口里塞两篇长文就可能触发长度限制现在转换后同样的 token 预算能装下三篇左右。这对于做批量资料整理、长文摘要、竞品分析类工作的人价值几乎是直接可感知的。5. 踩过的坑与排查速查表5.1 六个高频问题的处理过程做个工具最麻烦的事情永远是真实网页比你想象的脏。我在这几个问题上耗的时间最多问题一MathJax 公式变成 “[Math Processing Error]”。页面打开时公式还没渲染完脚本去取script typemath/tex时又已经渲染完毕导致拿到的源码被清空。解决思路在转换动作触发时先定位页面里是否有 mathjax 的 finished 事件标记如果没有等待一小段时间再抓取或者直接读取渲染容器上的>
网站建设高端定制企业官网