30 秒判断:HTML 不是“只有标签的漂亮树”。注释、CDATA、脚本、错误嵌套和 tail 文本都是合法输入。解析器只要在某个遍历分支里假设“每个节点都是 element”,一条正常网页就可能让整篇文档入库失败。更麻烦的是,修完解析逻辑还不算结束:解析行为变了,内容 hash、索引语义和旧快照兼容性也随之变化。
适合谁读:正在做 HTML 抽取、正文清洗、文档切块和 RAG 入库管线的开发者;遇到过“网页在浏览器正常,但后端解析奇怪报错”的人;正在处理 parser/chunker 升级与旧索引兼容的人。
系列位置:本文属于《学习问答助手Agent实践》的实现复盘部分。系列前 9 篇完成了需求定义、架构设计与评测方法;进入实现后,第一篇《AI 写代码越快,为什么我反而越没有掌控感?》讨论控制模型,上一篇增量发布故障复盘讨论活动 collection 如何被错误覆盖。本篇不重复HTML 到 collection 的总体设计,而是拆开一个真实解析故障,观察“单行判断错误”如何一路影响到快照版本。
结构速览:§0 故障现场;§1 复现与根因;§2 为什么只跳过注释还不够;§3 正确修复;§4 错误应该怎样分层;§5 为什么必须提升解析器版本;§6 测试策略;§7 一份 HTML 解析防御清单;§8 协作复盘;§9 一页速查。
证据标记:✅ 实测=真实文件复现;🧪 回归=已有自动化测试固定行为;🤔 推断=由当前解析与快照机制推导出的结论。
0. 故障现场
一次批量 HTML 入库中,5 个来源里有 4 个成功,最后一个失败。
界面给出的错误是:
Input object is not an XML element: HtmlComment
批次状态仍然是“部分入库”:
- 成功的文章已经在活动知识库里;
- 失败文章没有进入索引;
- 界面能指出第 5 个来源失败;
- 但错误信息看起来像 Python 内部异常,普通用户无法据此采取行动。
这里同时出现了三个问题:
- 一个合法 HTML 文档没有被解析;
- 内部实现异常直接泄漏给了用户;
- “来源本身有问题”和“解析器有缺陷”没有被区分。
第一层是解析 bug,第二层是错误语义问题。
1. 复现与根因
用原文件单独执行预览,异常能稳定复现。
调用栈最终落在一个列表项处理函数里,简化后相当于:
for item in list_element.xpath("./li"):
own_text_parts = []
if item.text:
own_text_parts.append(item.text)
for child in item:
own_text_parts.append(child.text_content())
HTML 中出现了:
<li>
第一段文字
<!-- 编辑器注释 -->
注释后的文字
</li>
在 lxml 中:
- 普通标签是 element;
- HTML 注释是
HtmlComment; HtmlComment的tag不是普通字符串;- 对它调用
text_content()会抛出:
ValueError: Input object is not an XML element: HtmlComment
更隐蔽的是,主文档遍历函数已经写过元素过滤:
if not isinstance(child.tag, str):
continue
但列表项内部还有一套自己的子节点遍历,它漏掉了同样的防御。
✅ 实测:真实文件稳定复现,移除注释后可正常通过,确认根因就是遍历分支没有过滤非元素节点。
为什么容易漏
这是典型的“多个局部遍历各自实现一遍”问题:
- 主遍历知道要过滤脚本、样式和非元素节点;
- 列表遍历只记得处理嵌套
<ul>/<ol>; - 两个地方语义相似,却没有共享同一套元素判断。
它不是因为注释少见。真实网页里,编辑器信息、组件边界、A/B 标记、服务端模板和埋点都可能产生 HTML 注释。
从人机分工看,AI 很适合这一步:读取堆栈、定位到 HtmlComment、提出最小防御判断。但“直接跳过注释”只是局部修复。
人的关键价值是继续追问系统边界:
- 注释之后的可见文字还在不在?
- inline 元素是否也漏了 tail?
- 这个异常应该算来源问题还是解析器问题?
- 行为变化后 extractor 版本是否还能保持不变?
这也是后文所有检查存在的原因:AI 收集证据并实现修复,人负责把单点 bug 上升为必须满足的内容和版本契约。
2. 为什么“直接跳过注释”还不够
最直观的修复是:
if not isinstance(child.tag, str):
continue
这能避免崩溃,但可能悄悄丢正文。
因为在 HTML DOM 中,一个节点后面的文字常放在它的 tail 上:
<li>前文<!-- 注释 -->后文</li>
结构关系近似于:
li.text = "前文"
comment.tail = "后文"
如果直接跳过 comment,后面的“后文”也会一起消失。
同样的问题也存在于普通 inline 元素:
<li>前文<strong>重点</strong>后文</li>
只读取 strong.text_content() 得到“重点”,却不读取 strong.tail,就会丢失“后文”。
因此在列表项中,正确的文本归属要同时考虑:
- 当前元素的
text; - 子元素内部的
text_content(); - 子元素的
tail; - 嵌套列表与父列表的边界。
3. 正确修复
最终实现按下面的逻辑收集列表项直属文本:
for child in item:
if not isinstance(child.tag, str):
if child.tail:
own_text_parts.append(child.tail)
continue
if child.tag in {"ul", "ol"}:
if child.tail:
own_text_parts.append(child.tail)
continue
own_text_parts.append(child.text_content())
if child.tail:
own_text_parts.append(child.tail)
这段修复包含两个判断:
- 非 element 节点不能调用 element API;
- 节点被跳过不等于它的 tail 也应该消失。
🧪 回归:新增测试同时覆盖注释前文字、注释后文字、inline 元素和 inline tail,并断言注释内容不进入正文。
为什么不直接在解析前删除所有注释
也可以先把注释从 DOM 中移除,但需要保证删除节点时正确合并 tail。否则只是把同样的文本丢失问题换了一个位置。
更稳妥的原则是:
抽取层显式定义“哪些节点贡献文本”,而不是依赖 DOM API 对异常节点做隐式处理。
4. 错误应该怎样分层
相同现象背后至少有三类错误:
| 错误类型 | 例子 | 面向用户的语义 |
|---|---|---|
| 输入错误 | 空内容、登录页、JS 空壳 | 这个来源不适合当前能力 |
| 上游错误 | 网络超时、429、embedding 服务不可用 | 暂时性失败,可重试 |
| 内部错误 | parser 未处理合法节点、状态不一致 | 系统缺陷,需保留现场排查 |
本次故障一开始只是被记成通用的:
source_ingestion_failed
这会让界面错误地暗示“这个来源有问题”,而真正的责任在解析器。
修复时把未知解析异常映射为稳定的内部错误码,例如:
extractor_internal_error
同时记录完整服务端日志,但不把 Python 原始异常直接当成用户说明。
原则:未知异常必须保留技术细节,但对产品暴露稳定、可分类、可操作的错误语义。
不能为了“界面好看”而吞掉异常。内部错误码的意义是明确告诉系统:这不是用户文档不够好,而是解析实现需要修。
5. 为什么必须提升解析器版本
HTML 注释问题最初看起来只是一个防御性判断,但它改变了抽取行为:
- 以前遇到注释会失败;
- 修复后能生成完整章节和 blocks;
- inline tail 也开始进入正文;
- 同一原始 HTML 可能得到不同的
extraction_hash。
因此不能继续宣称“解析器版本 1 的行为没有变化”。
本次把 extractor 从 v1 提升到 v2。
版本提升不是形式主义
它至少影响三件事:
- 可复现性:同一来源、同一版本必须产生同一结构;
- 问题定位:用户报告解析差异时,必须知道当时用的是哪个版本;
- 快照兼容性:同一 collection 中的文档通常要共享一组解析、切块和 embedding profile。
如果旧知识库由 extractor v1 构建,新导入文档由 v2 构建,直接把两批混进同一个 collection,看似只是多加了几篇,实际上 manifest 已经无法用单一版本准确描述全部内容。
这时有两种诚实选择:
- 重新构建整个 collection;
- 明确引入分文档版本并重新设计快照语义。
不能悄悄把两个版本混在一起。
🤔 推断:任何会改变正文边界、节点顺序、文本内容或 hash 的解析修复,都应触发版本升级评估。
6. 测试应该怎么补
6.1 最小合成用例
<ul>
<li>
第一段
<!-- editor note -->
注释后的文字
<strong>重点</strong>
收尾文字
<!-- nested note -->
</li>
</ul>
断言:
- 不抛异常;
- 注释内容不进入正文;
- 注释前后的可见文字都保留;
- inline 元素内部及 tail 都保留。
6.2 真实语料验证
合成用例证明分支逻辑,真实文件证明它在页面整体结构中成立。
修复后,原先失败的文件成功得到:
31 sections
34 blocks
67 semantic nodes
✅ 实测:真实文件完成完整单篇入库,不只是 preview 返回成功。
6.3 错误语义测试
注入一个模拟的 extractor 内部异常,断言接口返回稳定错误码,而不是 500 堆栈或通用来源失败。
6.4 全量回归
解析器版本变化后,还要回归:
- section tree;
- block 边界;
- 双索引;
- collection 发布;
- 详情页正文;
- 旧快照版本判定。
这份回归清单本身就是控制契约:只要解析行为发生变化,负责人就应该能快速判断哪些内容、错误和版本边界也被一起改变了。
7. 一份 HTML 解析防御清单
面对不可信 HTML,至少检查:
- 注释、CDATA、script、style 和 noscript;
- 非字符串
tag的节点; - 元素
tail是否被遗漏; - inline 元素前后的可见文字;
- 表格单元格中的嵌套结构;
- 重复标题和非法标题跳级;
- 未闭合标签被解析器自动修复后的结构;
- 不可见字符和错误编码;
- extractor 内部异常与输入错误的分类;
- parser/chunker 版本变化后的旧快照兼容。
其中最容易忽略的往往不是“复杂网页”,而是:
一个本来合法、但代码没想到的普通节点
8. 协作复盘
这个案例最后形成的协作结论不是“AI 修得快”,而是两种能力缺一不可:
- AI 负责快速复现、追踪节点类型、实现遍历修复和补测试;
- 人负责追问 tail 文本、错误分类、版本语义和旧快照兼容;
- 合成用例证明分支逻辑,真实文件证明整页语义;
- 修复完成后继续做完整入库,而不是停在 preview 成功。
如果只检查“异常是否消失”,这个 bug 很容易被当成一行 if 修复。最终能够形成可复用经验,是因为人没有把运行成功当作系统正确。
9. 一页速查
故障:HTML 注释让列表遍历调用 element API,整篇文档入库失败。
根因:主遍历过滤了非 element,列表遍历漏了过滤。
修复不能只做一半:
- 跳过
HtmlComment; - 保留注释后的
tail文本; - 同样处理 inline 元素 tail;
- 未知解析异常映射成稳定内部错误码。
版本影响:
- 抽取行为变化;
- extraction hash 可能变化;
- extractor 必须升级;
- 旧 collection 不能默默混入新版本。
测试闭环:
合成注释列表
+ 真实网页
+ 错误语义
+ 版本兼容
+ 完整发布回归
最重要的一句话:
合法输入触发崩溃时,要修的不是一个
if,而是解析器的节点边界、文本归属、错误分类和版本契约。