案例 #1743:MHTML 图片解析与 Markdown 结构
对齐 写作标准 · 管线标杆 case-1725
PR:#1743 | 8 文件 +1112/-16 | 2026-06-22 合入
结论先行
MHTML 经 Python docreader 转 Markdown 再进 Go docparser 时,有两类独立 bug:(1)Markdown 归一化 删掉空行与行首缩进,破坏段落/列表/代码块/表格边界;(2)图片 resolver 用正则把  的 整段 target(含 title) 当路径,内嵌图 无法解析。
修复:Python 侧保留块结构与 fence 内换行;Go 侧用 状态机 markdown_image_scanner 替代正则,正确拆分 URL 与 title;+600 行级单测与 testdata/mhtml 契约 fixture。
适合谁读:改 Py+Go 跨语言管线、MHTML/多模态 ingest 的贡献者。
与 #1248:间接 — snippet 依赖 chunk 内 完整图片引用;与 case-1725、case-1808 合读 = Parse 层 checklist。
链 Ch13 多模态 · Ch28 docreader · Ch29 docparser。
1. 问题现象(What)
1.1 用户可见
- 上传
.mhtml(浏览器「另存为完整网页」)→ ingest 成功 - 问答时:内嵌 图表/截图 相关答案缺失;或 chunk 里图片语法 被拦腰切断
- 带 image title 的语法失效,例如:

1.2 修复前 / 修复后
| 现象 | 修复前 | 修复后 |
|---|---|---|
| 段落/列表/引用块 | 空行、缩进被删 → 结构塌缩 | 保留有意义块结构;fence 外才归一化多余空行 |
 |
lookup path "title" 整串 → 404 |
scanner 只取 path,title 单独字段 |
| 代码块内换行 | 可能被当普通 MD 处理 | fence 内容原样保留 |
| 回归 | 无 titled-image 契约测 | testdata/mhtml/titled-image.mhtml + JSON 契约 |
2. 定位步骤
# Python:MHTML → MD
rg "mhtml|MHTML" docreader/parser/
rg "blank line|indent" docreader/parser/mhtml_parser.py
# Go:图片路径解析
rg "image_resolver|ImageResolver" internal/infrastructure/docparser/
rg "markdown.*image|!\[" internal/infrastructure/docparser/
阅读顺序:
docreader/parser/mhtml_parser.py— MD 结构保留docreader/tests/test_mhtml_parser.pydocparser/markdown_image_scanner.go— NEW 状态机docparser/image_resolver.go— 调用 scannertestdata/mhtml/titled-image-contract.json— 端到端期望
3. 根因分析(Why)
3.1 类比
MHTML 像 密封箱里的网页 + 相册。Python 工人把网页 复印成 Markdown,却用橡皮擦把 段落空行和缩进 擦掉(章节粘在一起);Go 工人按 标签上的整串字 找相册格(把 "标题" 也当文件名),自然找不到照片。
3.2 跨语言数据流
flowchart TD
A[.mhtml multipart] --> B[Python mhtml_parser.py]
B --> C[Markdown 文本]
C --> D[Go docparser]
D --> E[markdown_image_scanner]
E --> F[image_resolver 查内嵌 part]
F --> G[chunker / embed]
B -.->|修复前| B1[删空行/缩进 ❌]
E -.->|修复前| E1[正则整段 target ❌]
3.3 为何正则不够?
Markdown image 语法变体多:() 内可有空格、嵌套引号、<url> 形式。正则 !\[.*\]\(.*\) 无法可靠区分 path 与 "title" — PR 用 state-based scanner(+262 行 + 203 行测)。
4. 解决方案(How)
4.1 Python:保留块结构
- 不再 ** blanket 删除** 空行与 leading indent
- fence 内 hard line breaks 保留
- fence 外 过多 空行可归一化(可读性)
4.2 Go:状态机扫描图片
核心思路(概念):
// markdown_image_scanner.go — 扫描 
// destination 解析为 URL/path,可选 "title" 与 <angle> URL 形式分离
// image_resolver.go 仅拿 path 查 MHTML multipart
4.3 为何不在 Python 侧删掉 title?
title 是 合法 MD;删掉会丢语义。应在 resolver 解析 destination 时剥离。
4.4 逐文件改动表(8 文件)
| 文件 | ± | 说明 |
|---|---|---|
docreader/parser/mhtml_parser.py |
+107/-6 | MD 结构 |
docreader/tests/test_mhtml_parser.py |
+147 | Python 单测 |
docparser/markdown_image_scanner.go |
+262 | NEW 状态机 |
docparser/markdown_image_scanner_test.go |
+203 | scanner 测 |
docparser/image_resolver.go |
+10/-10 | 接 scanner |
docparser/image_resolver_test.go |
+324 | resolver 测 |
testdata/mhtml/titled-image.mhtml |
+48 | fixture |
testdata/mhtml/titled-image-contract.json |
+11 | 契约 |
读 PR 技巧:+1112 行 ≈80% 是测试;逻辑增量看 parser + scanner + resolver 三处。
4.5 验证命令
cd docreader && python -m pytest tests/test_mhtml_parser.py -v
go test ./internal/infrastructure/docparser/ -run 'MarkdownImage|ImageResolver' -v
5. Review 与合入
PR 四段式
| 段 | 内容 |
|---|---|
| What | MHTML 图 unresolved;MD 结构塌缩 |
| Why | 过度归一化 + 正则 image target |
| How | 结构保留 + state scanner + fixtures |
| Test | pytest + go test + testdata 契约 |
维护者 checklist
- titled image
path "title"fixture 绿? - fence 内代码不被 strip?
- 只改 Python 或只改 Go 能否单独合入?(应 成对 验证)
6. 与 RAG 管线
| 层 | 影响 |
|---|---|
| Parse | 图片能否变成可 OCR/可 cite 的资源 |
| Chunk | 结构塌缩 → 边界错 → 检索噪声 |
| Generate | 多模态问答缺图上下文 |
与 case-1725:1725 是 HTML 表格 vs chunker;1743 是 MD 结构 + 图片语法 vs resolver — 同属 上游格式满足下游假设。
7. 可复用模式
- 跨语言 PR 必须 E2E fixture —
testdata/契约 JSON - 应用正则前问:语法是否有 nested/quotes?有则 scanner
- 改 docreader 必画 Go 边界 — 谁消费 MD 字符串
8. 常见误区
错误认知:MHTML 是小众格式,修 Python 就够。
正确:Go image_resolver 才是 lookup 失败点。
错误认知:删掉 image title 最简单。
正确:破坏 MD 合规;应 解析 destination。
错误认知:+1112 行太大不能学。
正确:测试占绝大多数 — 学 scanner + fixture 模式。
错误认知:与 #1248 无关可跳过。
正确:snippet 展示依赖 完整 chunk 文本。
9. 思考点
- 只修 Go scanner、Python 仍删空行 — 还会坏什么?
- 设计 3 条
变体单测(角括号 URL、无 title、嵌套括号)。 - case-1808 PDF 与 1743 MHTML — 跨语言 checklist 还应含哪条?
章末自检
- 说清「结构塌缩」与「title 当 path」两个根因
- 写出 pytest 与 go test 命令
- 解释为何用状态机而非正则
延伸阅读
- case-1725 · case-1808 · Ch29 docparser · 写作标准