↓ 跳过正文

Python Rust PyPI

一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包:给 Python 用的快速 HTML 转 Markdown

为了给 AI agent 把 HTML 转成 Markdown,我需要把相对链接解析成绝对链接。上游的 Rust crate 没法接受我的 pull request,因为它是用 LLM 写的,于是我 fork 了它,发布了 pyhtml2text:带 base_url 的 Python 绑定、新的 html2markdown、用 profile-guided 优化构建的 wheel,以及一个打过补丁、能在线性时间内解析深层嵌套 HTML 的解析器。本文讲它为什么存在、里面有什么,以及我怎么检查输出是否正确。

10 月 15 日,我在 PyPI 上发布了 pyhtml2text。它把 HTML 转成纯文本或 Markdown,底层是 Chris Emerson 写的 Rust crate rust-html2text。在我测过的页面上,转 Markdown 的速度是常见 Python 转换器的 3 到 21 倍。它还能根据页面的 URL 解析相对链接,这样输出里的链接离开原页面后依然能用。

pip install pyhtml2text
from pyhtml2text import html2markdown

html = """<h2>Before you start</h2>
<p>Read the <a href="setup.html">setup guide</a> and <b>back up</b> your data.</p>
<img src="/img/diagram.png" alt="How it fits together">
<table><tr><th>Plan</th><th>Price</th></tr><tr><td>Basic</td><td>Free</td></tr></table>"""

print(html2markdown(html, base_url="https://example.com/docs/intro.html"))
## Before you start

Read the [setup guide](https://example.com/docs/setup.html) and **back up** your data.

![How it fits together](https://example.com/img/diagram.png)

| Plan | Price |
| --- | --- |
| Basic | Free |

它最初是给 rust-html2text 提的一个 pull request。维护者因为它是用 LLM 写的而关闭了它,所以 pyhtml2text 改为基于一个 fork 运行。这篇文章讲事情的经过、fork 和这个包里有什么,以及我怎么确认生成的 Markdown 是对的。

为什么需要它
#

maru 收到 Accept: text/markdown 的请求时会返回 Markdown 而不是 HTML,给那些不想在导航栏和页脚里翻找内容的 AI agent 和 LLM 客户端用。边缘那一侧的做法我在关于 Cloudflare、爬虫和缓存的文章里写过。商品、招聘和社区页面的 Markdown 是从数据库生成的,因为它们的 HTML 只是一个空的 Expo 外壳。其余的都是 Hugo 生成的真实 HTML(营销页、法律页和博客文章),这些需要转换。

转换的麻烦在于链接。Hugo 页面里到处都是 /en/posts/... 和 ../setup/ 这样的链接。在浏览器里没问题,因为浏览器知道它们来自哪个页面。读 Markdown 的 agent 没有这个上下文,所以它没法打开 /login,它需要的是 https://example.com/login。

纯 Python 的 html2text 有一个 baseurl 选项专门做这件事,我一开始用的就是它。但它很慢,生成的 Markdown 也有一些问题,后面会讲到。rust-html2text 速度快、测试充分,但它没有办法解析链接。

先问上游
#

8 月 29 日,我提了 issue #287,写明了使用场景和我想要的行为:按 RFC 3986 解析,绝对 URL 以及 mailto:、data: 和 #fragment 链接保持不变。几分钟后,我提了 pull request #288。它加了一个 Config::base_url(...) 选项,我在 issue 里说明这只是一个起点,如果维护者想换一种做法,我完全理解。

10 月 11 日,维护者关闭了这个 PR:

Thanks for the contribution, but I’m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn’t previously stated)

(大意:感谢贡献,但我无法接受,因为它看起来是 LLM 生成的。我已经在 README.md 里加了一条简短的政策,之前没有写明。)

并在 issue 里回复:

Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can’t accept, but thanks for the attempt.

A short HTML sample and expected output would definitely help get the feature implemented!

(大意:这是个合理的功能请求,谢谢!PR 看起来是 LLM 写的,我无法接受,所以关闭了,但感谢你的尝试。如果能提供一小段 HTML 示例和期望的输出,肯定有助于实现这个功能!)

现在 README 里有这样一条政策:

At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.

(大意:由于版权状态尚不明确,本项目目前无法接受由生成式 AI/LLM 产出的代码贡献。)

维护者的判断没错:那段代码确实是我用 Claude Code 写的。我也不觉得这条政策不合理。LLM 输出的版权归属还没有定论,而把它合进一个许多项目都依赖的 MIT crate,风险是由维护者来承担的。这是对方的项目,而且回复也很客气。那个功能请求现在仍然开着。

不过我还是需要这个功能,而跟维护者争论对方自己定的贡献政策,并不是拿到它的好办法。所以我把改动留在一个 fork 上,在它之上构建了 Python 包,两者都清楚写明了自己不是上游。

fork 里有什么
#

这个 fork 是我那份 rust-html2text fork 上的 pyhtml2text-perf 分支。我刻意让它保持很小,这样能干净地 rebase 到上游。它只多了三个 commit:

  • Config::base_url。 就是 PR 里的那个改动。相对链接按 RFC 3986 §5.3 解析,用 crate 内部大约 200 行代码实现,而没有引入 url crate,因为它只需要做引用解析。不设置 base URL 时,输出不变。
  • 共享样式规则。 这个 crate 每渲染一个文档都会把样式表数据复制一份,config::plain() 每次调用还会重建它的十条装饰规则。把规则列表放到 Arc 后面(写时复制),并且只构建一次之后,一小段 HTML 的处理时间从 7.3 µs 降到了 5.6 µs。对大页面来说这不算什么,但调用几千次就积少成多了。
  • 导出 DOM 节点类型和 URL 解析函数。 下文要讲的 Markdown 转换器会自己遍历解析好的 DOM,所以它需要这些类型,而且解析链接的方式必须和文本渲染器一致。

Python 包
#

pyhtml2text 用 PyO3 和 maturin 构建,提供两个函数:

from pyhtml2text import html2text, html2markdown

html2text(html, width=80, *, base_url=None, decorate=True, link_footnotes=True,
          table_borders=True, wrap_links=True, max_wrap_width=None, raw=False)

html2markdown(html, *, base_url=None)

html2text 就是 rust-html2text 的纯文本渲染器,它是为终端和电子邮件的 text/plain 部分设计的:

print(html2text('<p>Please <a href="/login">sign in</a> first.</p>',
                base_url="https://example.com/docs/page.html"))
Please [sign in][1] first.

[1]: https://example.com/login

关于这些绑定,有几点可能不那么显而易见:

  • 两个函数都接受 str 或 UTF-8 的 bytes,所以可以直接传 response.content,不用先解码。输入是借用的,不会复制。
  • 转换期间会释放 GIL。 从头到尾都是纯 Rust,所以多线程能用满多个核。模块还声明了自己不需要 GIL,因此在 free-threaded 的 Python 3.14t 上,导入它时不会把 GIL 重新打开。
  • panic 会变成 ValueError。 PyO3 默认抛出的是 PanicException,而 except Exception 捕获不到它。一个以为自己已经处理好转换错误的 web worker 会因此崩掉,所以绑定层把它捕获下来,重新抛成 ValueError。
  • 一共有 30 个 wheel: CPython 3.10 及以上的 abi3 wheel,加上 free-threaded 3.14t 的 wheel,覆盖 Linux(glibc 和 musl)、macOS 和 Windows 上 maturin 的 CI 能构建的所有架构。所以安装时谁都不需要 Rust 工具链。

让它变快
#

0.2.0 版在真实页面上比 0.1.0 快 1.5 到 1.7 倍,在短片段上快一倍,而且输出逐字节一致。主要靠三项改动:

  • Profile-guided 优化(PGO)。 CI 先构建一个插桩版的 wheel,用一组 Python 文档页面和 RFC 作为训练集跑一遍,再用得到的 profile 构建正式的 wheel。在训练集之外的页面上大约快 25%。只有 GitHub 能原生运行插桩构建的平台才这样做(Linux x86_64 和 aarch64、macOS、Windows x64),其他平台拿到的是同样的代码,只是没有 PGO。
  • mimalloc。 html2text 会做大量的小内存分配,mimalloc 处理起来比系统分配器好得多。glibc 上的 armv7、ppc64le 和 s390x 没有用它,因为那里的 manylinux2014 交叉编译器太旧,编译不了它。
  • fork 里的共享样式规则。

为了确认这些提速没有改变任何输出,有一个基准测试脚本会在十一组选项下,对本地语料库里每个页面的输出计算哈希,另外还有一些边界情况,比如空输入、300 层嵌套和 rowspan=0。每一项改动都必须让所有哈希保持不变。

下面是在 Apple Silicon Mac 上转 Markdown 的中位耗时:

维基百科条目(1 MB)BBC News 首页(370 KB)Python 文档页(245 KB)
markdownify184 ms28 ms97 ms
html2text(纯 Python)73 ms13 ms37 ms
html-to-markdown44 ms5.6 ms15 ms
pyhtml2text14 ms1.7 ms4.5 ms

转纯文本:

维基百科条目(780 KB)BBC News 首页(370 KB)
html2text(纯 Python)71.5 ms14.9 ms
html2text-rs15.2 ms2.5 ms
pyhtml2text10.9 ms1.6 ms

深层嵌套的 HTML,以及给 html5ever 打的补丁
#

没人会手写 40,000 层嵌套的列表。但如果你要转换不是自己写的页面,迟早会碰到某个恶意的或者坏掉的页面给你来这么一下。用原版解析器,解析 40,000 层嵌套的 <ul><li> 要 6 秒,也就是一个 worker 被一个请求卡住 6 秒。

原因在 html5ever,也就是 rust-html2text 用的 HTML5 解析器。大多数块级开始标签都要检查是否有一个打开着的 <p> 元素处于 “button scope” 中,以便决定要不要关闭它。html5ever 的做法是把整个打开元素栈走一遍,所以嵌套越深,每个标签花的时间就越长,总耗时随深度的平方增长。

这个包 vendor 了 html5ever 0.39.0,并打了一个补丁。现在打开元素栈会记录最上面的 <p> 和最上面的 button scope 边界各在哪里,并在每次 push、pop 和 truncate 时更新,于是这个检查变成了 O(1)。同一个文档现在 0.14 秒 就能解析完。在 debug 构建里,每次的结果还会和原来的遍历栈方式做比对。为了确保补丁没有改变别的东西,我在 html5lib 的全部树构建测试、大约 20 个真实页面和 300,000 个随机的标签乱炖文档上,比较了原版和打补丁后的解析器生成的 DOM,结果全部一致。

出于同样的原因,Markdown 转换器也有自己的上限。它用显式的栈而不是递归来遍历树,所以任何输入都不会让线程栈溢出。blockquote 和列表超过 32 层后就不再继续缩进,因为每一行都要重复外层所有元素的前缀,不加限制的话输出会按平方增长。另外,如果一个表格展开后的网格会超过 100,000 个单元格,就只按单元格逐个输出。

html2markdown
#

0.3.0 版加入了 html2markdown,这个包的大部分工作都花在它上面。rust-html2text 的渲染器输出的文本看起来像 Markdown,但它是按终端宽度折行的,并不打算被解析。所以 html2markdown 只用 rust-html2text 和打过补丁的 html5ever 来解析页面,然后自己遍历 DOM,输出 CommonMark,外加 GitHub 的管道表格和 ~~删除线~~:

  • # 标题、- 列表、_强调_ 和 **加粗**。
  • 行内链接和图片,给了 base_url 时转成绝对地址。如果一个链接里只有图标,就用它的 aria-label 或 title 作为链接文字。
  • 围栏代码块,页面标明了语言时(class="language-python")会带上语言标记。
  • 管道表格。跨行或跨列的单元格变成空单元格,没有表头行的表格会补一个空表头,用来做页面布局的表格则写成普通段落。
  • 不折行。隐藏内容(hidden、aria-hidden、display: none)、脚本、样式和表单控件都会被去掉。

只转义需要转义的
#

有些 HTML 转 Markdown 的工具会把所有可能是 Markdown 语法的字符都转义掉。比如 markdownify 默认会转义每一个 _ 和 *。这样的输出是正确的,但满篇都是 snake\_case 和 5 \* 3,人读起来费劲,对模型来说也是浪费 token。pyhtml2text 只在 CommonMark 解析器会把某个字符当成语法的地方才转义它:

html2markdown('<p>Use <code>snake_case</code>, 5 * 3, $5 and '
              '<a href="../setup.html">setup</a></p>',
              base_url="https://example.com/docs/intro.html")
Use `snake_case`, 5 * 3, $5 and [setup](https://example.com/setup.html)

韩语助词前的加粗
#

这个问题来自 maru 自己的页面。韩语的助词紧跟在名词后面,中间没有空格,所以一个带括号的加粗词语是这样的:

<p><b>타이타닉(Titanic)</b>의 감독</p>

最直接的 Markdown 写法是 **타이타닉(Titanic)**의,markdownify 输出的就是这个,但它不起作用。CommonMark 的 flanking 规则规定,** 跟在标点后面时,只有后面紧接空格或另一个标点才能闭合,而 의 是一个字母,所以这些星号会原样留在文本里。纯 Python 的 html2text 的办法是加一个空格:

**타이타닉(Titanic)** 의 감독

这样能渲染出加粗,但改动了韩语原文。助词变成了一个单独的词,在韩语里这是拼写错误。pyhtml2text 的做法是把标点移到标记外面:

**타이타닉(Titanic**)의 감독

括号不再是粗体,但强调依然能正确解析,原文的每个字符也都保留了下来。日语和中文也是同样的道理:中文词与词之间没有空格,一段以括号结尾的加粗文字后面如果直接跟着"的"之类的字,也会碰到同样的问题。任何不在后续文字前加空格的语言都一样。

读回来检查输出
#

我不会因为一个转换器的单元测试都通过了就相信它,因为 Markdown 输出里的 bug 往往藏在没人想到要写测试的组合里。所以这个转换器用两种方式检查自己的输出。

转换时,每个段落里的强调标记都会按 flanking 规则检查一遍。然后用 pulldown-cmark 把这个段落重新解析一次,确认读回来的结果符合预期。如果不符合,转换器就换另一种强调写法重写一遍(用 * 代替 _,或者反过来)。如果两种写法都通不过,就保留文字、链接和代码,去掉格式,保证一个字都不丢。

在测试套件里,tests/markdown_check.py 用另一个解析器做往返检查。它把 HTML 转成 Markdown,再用 markdown-it 渲染回 HTML,然后把每一个可见的词、链接地址和图片地址同原始页面逐一比较。因为没转义而被语法吞掉的文字、没解析出来的链接、多出来的 *,都会作为差异显示出来。它会在真实页面和成千上万个随机文档上运行,还能把一个出错的文档缩减成最小的复现用例。

在 maru 上线
#

0.3.0 发布当天,maru 的 Markdown 内容协商就从纯 Python 的 html2text 换成了 pyhtml2text。在 maru 的一个指南页面上,转换时间从 4.2 ms 降到了 0.36 ms,快了大约 12 倍。链接都是绝对地址,表格是真正的管道表格,韩语的加粗也不会再把助词分开。abi3 wheel 本来就覆盖了 x86_64 和 arm64 上的 Python 3.14,所以 Docker 镜像不需要加任何新东西就能装上它。

致谢,以及在哪里报告 bug
#

纯文本渲染来自 Chris Emerson 的 rust-html2text,采用 MIT 许可证。pyhtml2text 在此之上加了它的一个 fork、一份打过补丁的 html5ever(MIT/Apache-2.0)以及 Markdown 转换器。这个包与这两个项目都没有关联,也没有得到它们的认可,所以有问题请报到 pyhtml2text 的 issue 页面,不要报到上游。这些代码里的 bug 由我来修,上游维护者不应该收到关于他们拒绝接受的代码的报告。

如果你在用 Python 转换 HTML,特别是要给模型读的,不妨试试:

pip install pyhtml2text

源码在 GitHub 上,changelog 里列出了 0.1.0 到 0.3.0 的所有改动。