ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

面向AI投喂的网页转Markdown:解决非标表格、行号噪点与Token浪费的工程实践

面向AI投喂的网页转Markdown:解决非标表格、行号噪点与Token浪费的工程实践

我做过不少网页转 Markdown 的插件和小工具,以前的标准特别简单:复制出来以后我自己看着舒服。直到我开始把转出来的 Markdown 直接丢给 AI 做总结、建知识库、生成代码,才意识到这个标准完全不够用。一个技术文档页面转出来,导航菜单一长串、页脚备案信息一条不少、代码块前面挂着一排行号、表格里的合并单元格被拆成对不上的竖线。Token 烧掉一大半,模型还在跟我说“参数表结构不完整”。那段时间我一度怀疑是自己 prompt 写得不到位,后来盯着转出来的文件看了半天才发现,问题出在源头:市面上的转换工具,几乎没有一款是专门为 AI 设计输入的。

所以我后面花了几周,专门做了一款以“投喂 AI”为第一目标的网页转 Markdown 浏览器扩展,内部代号 FeedMark。要解决的三个问题就写在标题里:复杂非标表格、行号噪点、Token 浪费。如果你也习惯把网页文档喂给大模型做 RAG、代码辅助、知识库整理,这篇文章里的设计思路和踩坑记录,应该对你有用。

1. 为什么“投喂 AI”的转换逻辑,和“给人看”完全不是一回事

1.1 传统工具的设计目标,从来就是“人眼可读”

先说清楚为什么传统工具在这里会失效。现成的网页转 Markdown 方案,设计目标是给人看的:把网页变成干净的笔记、把长文存成方便阅读的纯文本。人眼的纠错能力非常强,表格里有个单元格跨了两行,你会自动脑补它属于哪一列;代码前面带着行号,你能自动跳过这排数字;导航和页脚混在正文里,你扫一眼就知道往下读的重点在哪。

LLM 没有这套默认的脑补机制。你喂给它的是一串字符流,它看到什么就算什么。表格的列对不上,它就认为表格结构本身是乱的;行号混在代码里,它就可能把“1 ”当成代码片段的一部分;噪声混在正文里,它得花额外的注意力去分辨哪些信息真正有用。这意味着,一个对你来说“勉强能看”的转换结果,对模型来说可能就是一团需要强行解读的乱码。

1.2 非标表格、行号噪点、Token 冗余:三个真实投喂毒点

再拆开看三个最典型的“投喂毒点”。

第一个是非标表格。Markdown 原生表格要求矩形结构,每一行列数一致。但 HTML 里存在大量 colspan、rowspan,还有多级表头、单元格里塞列表的情况。这些东西在渲染出来的页面上表达清晰,一旦被简单粗暴地转成竖线表格,行列关系就全乱了。更要命的是,最值得喂给 AI 的页面——API 参考、配置手册、SDK 文档——恰恰是表格使用最密集的地方。

第二个是行号。代码高亮库为了展示方便,普遍会在代码左侧渲染一排行号,有些用独立的 td,有些用 span,还有一些直接把“1 ”写进文本节点。人眼能自动忽略,转成 Markdown 之后它们就成了实打实的字符。AI 读代码时会把这些行号当成正文的一部分,轻则浪费 Token,重则让它学到“代码前面要编号”这种错误模式。

第三个是 Token 冗余。网页正文之外的东西太多了:导航、面包屑、页脚备案、Cookie 弹窗、评论区、相关推荐、分享按钮……如果全被塞进 Markdown,假设一篇文章实际内容 2000 token,最后转出来 6000 token,等于模型花三倍的上下文去看同一个信息。上下文窗口是有限的,噪声越多,模型能专注的有效信息就越少,回答质量自然会下降。

1.3 我给这次重做定下的三条质量标准

所以我在项目启动时给自己定了几条质量标准,后面所有的设计和调优都围绕这几句话展开。

第一条,结构可解析。标题层级、列表、表格在转换结果里必须语义成立。人眼看到错乱的标题还能猜个大概,模型是真的会按层级理解文档,层级错了后面全错。

第二条,噪声最小化。行号、导航、页脚这类与正文无关的字符,能不进正文就不进正文。

第三条,信息密度优先。同一个链接重复出现三次的,保留第一次;出现一次就够的信息,绝不保留第二遍。宁可损失一点视觉上的“完整性”,也要保证每一分 Token 都花在刀刃上。

2. 转换管线重写:先理解页面结构,再决定转什么

2.1 核心思路:从“字符串级转换”升级为“DOM 语义重构”

确定目标之后,我把转换管线整个重写了。传统工具的做法是拿到 HTML 字符串,做一次正则或者简单 DOM 遍历,然后把标签替换成 Markdown 符号。这种“字符串级转换”在 AI 场景下远远不够。我的做法是把它升级成“DOM 语义重构”:先不看文本,先理解页面里哪些节点是导航、哪些是正文、哪些是表格、哪些是代码,再决定每个节点应该进 Markdown 还是被丢弃。

完整流程大致分六步:

  1. 结构定位:在 document 里找到真正的主内容区,通常优先找 main、article,找不到就用文本密度和链接密度评分选出一个候选容器。
  2. 噪声过滤:在这个容器里继续剔除 Cookie 弹窗、面包屑、页脚、评论区、推荐位等模块。
  3. 语义标注:把剩余节点分类,标记为标题、段落、列表、表格、代码块、引用等。
  4. 专项解析:对表格做行列网格解析,对代码块做行号剥离,对图片做 alt 提取。这是决定输出质量的关键一环。
  5. 序列化:根据语义标签输出 Markdown。
  6. 后处理压缩:折叠空行、清理 URL 追踪参数、做链接去重。

2.2 为什么不直接依赖 turndown 和 readability

为什么不直接依赖 turndown、readability 这些现成库?我一开始也想省事,后来发现它们各自有个绕不过去的坎。

turndown 本质上是一个 DOM 序列化器,你把什么 DOM 给它,它就忠实地把什么变成 Markdown。它不负责判断某个块是不是导航,也不懂行号和代码的区别。用它处理一个已经洗干净的 DOM 片段没问题,但让它直接处理整个页面,罪魁祸首反而全被保留下来。

readability 能提取正文,思路确实接近,但它对表格的处理太粗。它会把复杂表格直接降级成一段接一段的纯文本序列,合并单元格的结构信息全部丢失。这个方向跟我的目标刚好相反——投喂 AI 时,表格的结构语义恰恰是最需要保留的东西。

所以我的最终方案是:自己实现 DOM 遍历和语义分类,只在最后的片段序列化阶段借用 turndown 这类库,并且在序列化之后再加一层自定义的压缩器。输出质量的核心,在中间那层“语义理解”,而不在最后那步标签替换。

2.3 扩展的模块划分与最小权限设计

扩展本身的结构不复杂,MV3 架构:content script 负责读取 DOM、构建中间对象;background service worker 负责右键菜单、权限管理和批量导出;popup 提供开关和模式选择。有一个细节我想单独说一下,就是权限设计。我用了 optional_host_permissions,用户打开一个页面后,点击扩展图标才请求当前站点的读取权限。很多扩展一上来就申请“读取所有网站数据”,懂行的人看到直接卸载了。做投喂工具,隐私本身就是卖点,没必要在权限上吓唬用户。

3. 复杂非标表格的攻克:从“撞运气式转换”到“行列网格解析”

3.1 非标表格的四类典型形态

表格是这次项目里最头疼的部分,我从头到尾过一遍。

非标表格我大致分成四类。第一类是合并单元格,colspan 和 rowspan 在参数表里特别常见,一行数据跨两行表示分组,或者一个说明跨两列表示覆盖多个字段。第二类是多行表头,thead 里有两层甚至三层 tr,表头本身就有层级。第三类是单元格里嵌套块级内容,一格里面带着 ul、ol、pre、img,标准 Markdown 表格只接受行内内容,这种根本塞不进去。第四类最坑,页面里根本没有 table 标签,是拿 div 配 display:table 或者 CSS grid 模拟出来的表格,看起来是表格,DOM 里没有任何表格语义。

3.2 行列网格解析:处理 colspan 与 rowspan 的正确姿势

处理前两类,我用的方法是把表格当成一个二维网格来建。

具体步骤是:遍历表格下的所有 tr,维护一个当前列指针 col;先处理 rowspan 的占位,如果上一行某个单元格声明跨了两行,那么在下一行的对应列需要先跳过去;然后把当前 th 或 td 写入 grid[row][col],记录它的 colspan、rowspan、是否表头、内部 HTML;写入后 col 向后移动;如果 colspan 大于 1,当前位置到后面几个位置都属于这个单元格,后续单元格不能占用。这样遍历完一遍,整个表格就被还原成一个带坐标的矩阵。之后再单独做表头识别:有 thead 标签最好,没有的话我会把第一行 or 前几行全是 th 的行作为表头候选,最后再做一次一致性检查。

这里有个容易翻车的细节:很多页面没有正经的 thead,只是第一行用了 th 标签,甚至 class 叫 header 但标签是 td。我一开始只认 thead,结果一票文档站点的表头全部识别失败。后来改成“行内 th 占比 + 该行位置”的启发式判断,情况才稳定下来。

3.3 输出决策:能转标准表就转标准表,不能转就转结构化列表

网格解析出来之后,接下来是输出决策。我的原则是:能转成标准矩形表格的,就转原生 Markdown 表格;不能转的,坚决不硬转,改用结构化字段列表。

举个例子,一段典型的合并单元格表格,原始 HTML 大致是这样的:

<table> <tr><th rowspan="2">协议</th><th colspan="2">传输参数</th></tr> <tr><th>端口</th><th>加密</th></tr> <tr><td>HTTP</td><td>80</td><td>无</td></tr> </table>

普通转换器会输出一个列数对不上的竖线表格,模型读起来非常痛苦。我的输出是变成这样:

### 表格:传输参数 - 协议:HTTP - 端口:80 - 加密:无

每个字段名对应字段值,通过缩进把层级关系表示清楚。对 LLM 来说,这种“键值对 + 缩进”的结构比强行对齐的竖线表格友好得多,因为它不依赖视觉对齐,语义关系直接由缩进和换行表达。

3.4 用 div 模拟的表格怎么识别

div 模拟表格的识别,目前做的是比较保守的启发式。我会在候选容器里扫描相邻元素,看它们的 computed style 是不是 display:table-row、table-cell,或者是不是网格列数一致的 grid 容器。如果发现一组元素满足“行内 cell 数量固定、纵向排列对齐”的特征,就按表格语义处理;否则保持普通的块级文本输出。为什么保守?因为 grid 布局经常用于卡片式界面,强行当成表格解析,很容易把完全不是表格的内容误判成表格,造成更大的结构污染。能做到的,是把明显是表格语义的 div 布局捞回来,同时容忍一部分漏网之鱼。

4. 行号噪点:一个容易被忽略的 Token 与语义黑洞

4.1 行号是怎么混进 Markdown 的

行号噪点这个坑,我单独拿出来讲,是因为它看起来很小,实际影响比想象中大得多。

行号混进 Markdown 的方式主要有三种。一种是最常见的代码高亮插件渲染,比如 highlight.js 的 line-numbers,会在代码左侧生成一排独立的数字节点;老式一点的还会用 table 布局,左侧 td 放行号,右侧 td 放代码。第二种是服务端直接把行号写进文本,像有些教学网站为了省事,输出“1 function foo()”这样的原样文本。第三种是在线代码编辑器里复制代码时,编辑器自动把行号带进了剪贴板。这三种情况在普通转换流程里,行号都会被当成正文文本原样保留。

4.2 识别规则:靠结构信号而不是靠正则瞎猜

处理行号,我一开始想的很简单:每行行首匹配数字,删掉。第一次实测就翻车了,翻得很彻底。

因为代码本身经常有数字开头的内容:CSS 的“1rem”、Python 里的“1e-5”、有序列表的“1. 步骤”,还有某些日志格式。如果盲删行首数字,删掉的可能是代码的真正内容。所以我改成了“结构信号”判断法,只有下面至少两条信号同时成立,并且上下文确定是代码块时,才判定为行号:

  1. 行号节点和代码节点是分离的:行号在独立的 span、td,或者独立文本节点里,代码在另一个元素里。
  2. 数字具有连续性:连续三行以上,行首数字严格递增。
  3. 行号后有固定分隔符:空格、竖线或者制表符,而且格式统一。

这三条凑在一起,基本就能锁定行号。反过来,如果数字和代码内容混在同一个文本节点里,比如“1 function foo()”整个是一段文本,我不会动它。宁可多留一点噪声,也不能拿代码的正确性去赌。

4.3 误删代码的代价:一个印象深刻的反面案例

还要补充一个教训。最初我加了一个“代码块内行首数字清理”的规则,想着只要在 pre 里就可以更激进一点。结果有个文档页面,正文里用 pre 展示了一段安装步骤,内容是“1. 下载 SDK\n2. 配置环境变量\n3. 运行验证”。我的规则全给删成了“下载 SDK\n配置环境变量\n运行验证”,列表编号全部消失。

后来我加了保护:如果父元素是 ol、li,或者整段内容没有典型的代码特征(比如分号、括号、缩进块),就不走行号剥离。这个案例每次想起来都提醒我,规则再激进,也得留一层兜底。

4.4 行号清理的收益量化

收益方面,单页看行号占的 Token 不算吓人,一个 250 行的代码示例,行号前缀大概 1000 到 1500 字符,折算下来 300 到 400 token。但一个长篇技术文档往往有十几个代码块,累积起来就相当可观。

更关键的是,行号不是单纯的浪费,它还会诱导模型把“行号+代码”当作一个整体来学习。我在一次代码生成测试里,模型真的照着文档里的行号格式输出了带编号的代码。这个教训让我觉得,行号剥离这件事投入再多都不为过。

5. Token 浪费的源头排查与压缩策略

5.1 最烧 Token 的隐形内容清单

我先列一份我实际遇到的“隐形内容清单”,每一个都是真金白银烧掉的 token:

噪声类型典型来源处理方式
导航与菜单站点头部、侧栏结构评分直接剔除
面包屑文章顶部路径保留一级路径,其余删除
页脚信息备案、copyright删除
Cookie 弹窗各家合规组件删除
评论区Disqus、自建评论默认删除
相关推荐文章底部卡片删除
隐藏预渲染SPA 的 template 占位按 display:none、aria-hidden 过滤
base64 图片内联小图替换为 alt 文本
URL 追踪参数utm_source 等正则清理

5.2 噪声评分与删除策略:保守模式和激进模式

实现上,我不是靠写死规则去删节点,而是给每个候选节点算一个噪声分。总方向上:文本密度越高分越高,链接密度越高分越低,位于主要素内部会加分,位于主要素外部会减分。分数超过阈值的,直接标记为“删除”;低于但接近阈值的,标记为“存疑”。

存疑节点在保守模式下默认留在文档末尾的参考区,在激进模式下会被直接删除。这个分数的权重我调了大概三版,原则是宁可漏删,不要误删。比如一个页面只有 main 标签而没有内容丰富的正文,这时如果把 main 外的东西全删了,可能把标题和关键元信息一起删掉,那才是真正的灾难。

5.3 序列化层的小手脚:空行、URL、base64 图片

序列化层的压缩主要做了几件小事。连续空行统一折叠成一个,行尾空格全部去掉,这两个改动在长文档里能省掉不少零碎 token。URL 清洗是必须的,很多站点的文章链接带着 utm_source、fbclid、gclid 这类追踪参数,对 AI 理解内容毫无帮助,我统一用正则去掉参数部分,只保留路径。

链接去重也很有用,一篇文章里同一个 href 出现三四次很常见,第一次保留完整链接,后面几次只保留锚文本。base64 图片是 Token 杀手,一张内联小图可能几千字符,我会把 data URI 整个删掉,只保留 alt 文本;如果连 alt 都没有,就保留一个图片占位标记。这些手段没有一个是高深的,但叠加起来效果非常明显。

5.4 一个页面的实测对比:从 6800 token 降到 2300 token

给个我测试集里的代表案例。一个常见 SaaS 文档首页,我先把渲染后的可见文本全部提取出来,用 cl100k_base tokenizer 粗算,原始页面约 11000 token;用 readability 加 turndown 转一遍,约 6800 token;走 FeedMark 默认模式,约 2300 token;激进模式约 1500 token。

也就是说,相对常见工具链,默认模式大概能省 60% 到 70% 的 token,激进模式省得更多,但偶尔会丢掉一些警告性文案。所以我把默认档位放在标准模式,激进模式做成用户可选的开关。得说明一下,这是个人测试集里的一个代表页面,不是全站统计,但趋势在几十个页面上都一致。

6. 从 30 个回归页面里逼出来的调优细节

6.1 建一个“投喂质量”回归测试集

开发进入稳定期后,我建了一个 30 页的回归测试集,覆盖技术文档、博客、GitHub README、新闻稿、论坛帖子。每次改完转换逻辑,先跑一遍这三件事:内容召回率,我在每个页面上预设 10 个信息点,检查输出里是否还在;结构正确率,标题层级、列表层级、表格结构是否可解析;token 压缩率,对比 readability 加 turndown 的基线,算下降百分比。

不搞复杂的自动化评分,就是人工过一遍,但流程固定。为什么一定要固定?因为“投喂质量”看起来很主观,其实可以拆成具体动作:转出来的文档能不能回答我预设的问题。这个验货流程救了我很多次,好几次改完规则以为优化了,一跑测试发现某个站点的关键信息被误删了。

6.2 误删回退与调试模式

误删是所有清洗类工具的天敌。我的处理办法是增加一层“调试模式”,开启后会在 popup 里输出一份 JSON 清单,列出每个被删除节点的路径、标签、删除原因(导航、页脚、Cookie、评论等)。排查问题的时候看一眼清单,就知道是那条规则太激进。

另外,我对低置信度节点不做直接删除,而是把它们挪到文档末尾的“参考信息区”。多花几十个 token,保住一条重要信息,这笔账是划算的。激进模式下才会把这部分也清掉。所以说,默认思路不是怎么删得最大胆,而是怎么在删和留之间留足缓冲。

6.3 SPA 页面、CSP 与权限问题的处理

浏览器扩展这里有几个绕不开的坑,我一个个说。

第一个是 SPA 页面。content script 在页面加载时运行一次,但很多现代文档站是单页应用,URL 变了 DOM 整个换掉,脚本却不会再执行。我在 content script 里同时监听了 history.pushState、popstate,并配合 MutationObserver 观察 body 子节点的大规模替换,防抖 500ms 后重新跑一次转换逻辑。

第二个是 CSP。我一开始担心页面有严格的内容安全策略会阻止脚本,后来确认只要不注入远程脚本,只做 DOM 读取和本地计算,CSP 基本不影响。

第三个是权限。前面说了用 optional_host_permissions,用户第一次在当前站点点击扩展图标时才弹授权。很多用户只是临时投喂一篇文章,没必要让扩展在所有站点常驻权限。

6.4 性能与内存的注意点

性能方面,大页面 DOM 遍历容易踩内存的坑。我的做法是就地处理,不在 content script 和后台之间传递整份 HTML 字符串;中间对象只用 Map 和普通数组,避免同时持有多个大体积字符串副本。实测一个 1.5MB 的 HTML 页面,转换大概在 100 到 200 毫秒,用户感知不明显。

最怕的是在后台脚本里复制 document,然后还序列化来序列化去,那样内存直接爆炸。这个经验对做类似扩展的人应该有用。

7. 目前边界、适用场景与下一步想法

7.1 在哪些页面上表现稳定,哪些场景还不怎么行

目前表现最稳的页面类型是技术文档站,比如 MDN、Read the Docs、GitBook、GitHub 上的 README,还有各种博客。这些站点 HTML 语义相对干净,有明确的 main、article,表格和代码块也都是标准标签,清洗起来很顺手。

表现不太好的,一类是登录后才可以访问的后台控制台,很多数据面板用 Canvas 或复杂 grid 渲染,DOM 里根本拿不到完整的文本语义;另一类是强交互的 SPA 看板,信息不断异步加载,抓取时机不对就会缺内容。这些场景我暂时不做强支持,因为用户投喂 AI 的主要诉求还是文档和资讯页面。

7.2 公式、图片和其他还没啃完的硬骨头

还有几块硬骨头没啃完。公式是最明显的,MathML 要转成 LaTeX,完整方案基本要引入 MathJax,体积大、依赖重,对我这个扩展来说性价比不高。目前我的处理是保留 MathML 原始片段,不强行转换。

图片这里暂时只做 alt 提取,不做 OCR 和图片内容理解,因为那属于另一个 AI 项目的范畴了。代码语言推断也有不准的时候,有些站点不给代码块标注语言,我默认原样保留,不猜。猜错了反而误导模型。

7.3 后续计划:评分报告、开源与社区适配

下一步计划里,我最想做的是一份“投喂报告”:每次转换完成后,在页面角落显示预计 token 数、相对原始页面的下降百分比、被删除节点数量。让用户一眼看到这次投喂省了多少,哪些内容被清理了。另外打算把表格网格解析和行号剥离这两块逻辑拆出来,做成一个独立的开源库,因为这两块思路其实是可以复用的。再往后就是适配 Firefox 和 Safari,目前主战场在 Chrome/Edge 内核。

如果你也想做类似工具,我最想分享的一条经验是:先建验货流程,再写转换代码。我当时是先花半天收集了二十来个真实页面,然后强迫自己每次改完都回答三个固定问题:这篇文档的关键结论还在吗?表格结构还能不能解析?token 比上次多了还是少了?没有这个流程,很容易陷入“改规则一时爽、上线翻车火葬场”的循环。把投喂质量定义成可验证的具体动作之后,开发速度快了不是一点半点。

返回列表