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

| Plan | Price |
| --- | --- |
| Basic | Free |它最初是给 rust-html2text 提的一个 pull request。维护者因为它是用 LLM 写的而关闭了它,所以 pyhtml2text 改为基于一个 fork 运行。这篇文章讲事情的经过、fork 和这个包里有什么,以及我怎么确认生成的 Markdown 是对的。
为什么需要它#
我做的一个集市兼社区应用,收到 Accept: text/markdown 的请求时会返回 Markdown 而不是 HTML,给那些不想在导航栏和页脚里翻找内容的 AI agent 和 LLM 客户端用。边缘那一侧的做法我在关于爬虫和缓存的文章里写过。由应用数据生成的页面,HTML 只是一个空的 JavaScript 外壳,所以直接生成 Markdown。其余都是静态 HTML(营销页、法律条款和博客),这部分要经过转换。
转换的麻烦在于链接。静态站点的页面里到处都是 /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 产出的代码贡献。)
维护者的判断没错:那段代码确实是我用 AI 编程助手写的。我也不觉得这条政策不合理。LLM 输出的版权归属还没有定论,而把它合进一个许多项目都依赖的 MIT crate,风险是由维护者来承担的。这是对方的项目,而且回复也很客气。那个功能请求现在仍然开着。
不过我还是需要这个功能,而跟维护者争论对方自己定的贡献政策,并不是拿到它的好办法。所以我把改动留在一个 fork 上,在它之上构建了 Python 包,两者都清楚写明了自己不是上游。
fork 里有什么#
fork 是我那个 rust-html2text fork 里的 pyhtml2text-perf 分支。为了能干净地 rebase 到上游,我只保留了三个小提交。最主要的是 PR 里那个 Config::base_url 选项:按 RFC 3986 §5.3 解析相对链接,不传 base URL 时输出完全不变。另一个提交让 crate 不再每次调用都重建样式规则,短片段的耗时从 7.3 µs 降到 5.6 µs。大页面上这点差别可以忽略,但调用几千次就积少成多了。第三个只是把下文 Markdown 转换器需要的部分从 crate 里导出来。
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 倍,短片段上快一倍,输出逐字节一致。大部分提升来自配置文件引导优化(PGO):CI 先构建一个插桩版 wheel,在一组由文档页面和 RFC 组成的训练集上跑一遍,再用得到的 profile 构建正式的 wheel。在训练集以外的页面上大约快 25%,只在 CI 能原生运行插桩构建的平台上启用。剩下的来自换用 mimalloc 分配器(大量小块分配时它比系统分配器强得多),以及 fork 里那个样式规则的改动。
为了确认提速没有改变任何输出,我把一批真实页面用每一组选项都转换一遍,记录输出的哈希,还加上了空输入、极深嵌套之类的边界情况。每个改动都必须让所有哈希保持不变。
下面是在 Apple Silicon Mac 上转 Markdown 的中位耗时:
| 在线百科条目(1 MB) | 新闻网站首页(370 KB) | Python 文档页(245 KB) | |
|---|---|---|---|
| markdownify | 184 ms | 28 ms | 97 ms |
| html2text(纯 Python) | 73 ms | 13 ms | 37 ms |
| html-to-markdown | 44 ms | 5.6 ms | 15 ms |
| pyhtml2text | 14 ms | 1.7 ms | 4.5 ms |
转纯文本:
| 在线百科条目(780 KB) | 新闻网站首页(370 KB) | |
|---|---|---|
| html2text(纯 Python) | 71.5 ms | 14.9 ms |
| html2text-rs | 15.2 ms | 2.5 ms |
| pyhtml2text | 10.9 ms | 1.6 ms |
深层嵌套的 HTML,以及给 html5ever 打的补丁#
没人会手写 40,000 层嵌套的列表。但如果你要转换不是自己写的页面,迟早会碰到某个恶意的或者坏掉的页面给你来这么一下。用原版解析器,解析 40,000 层嵌套的 <ul><li> 要 6 秒,也就是一个 worker 被一个请求卡住 6 秒。
原因在 rust-html2text 使用的 HTML5 解析器 html5ever。大多数块级开始标签都要检查是否有一个打开的 <p> 元素处于 “button scope” 之内,而 html5ever 每次都要把打开元素的栈从头到尾走一遍。嵌套越深,每个标签花的时间越长,总时间随深度的平方增长。
这个包附带了打过一个补丁的 html5ever 0.39.0。补丁让这个答案在元素打开和关闭时随时更新,所以检查的耗时不再取决于嵌套深度。同一个文档现在 0.14 秒就解析完了。为了确保补丁没有改变别的行为,我在 html5lib 的全部树构建测试、约 20 个真实页面和 30 万个随机标签汤文档上,对比了原版和补丁版解析器生成的 DOM,结果完全一致。
出于同样的原因,Markdown 转换器也设了上限:任何输入都不会撑爆线程栈;引用块和列表超过 32 层就不再缩进,免得输出按平方膨胀;展开后网格超过 100,000 个单元格的表格,只输出单元格内容。
html2markdown#
0.3.0 版加入了 html2markdown,这个包的大部分工作都花在它上面。rust-html2text 的渲染器输出的文本看起来像 Markdown,但它是按终端宽度折行的,并不打算被解析。所以 html2markdown 只借用它的解析器,输出则自己来写: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)韩语助词前的加粗#
这个问题来自我那个应用自己的页面。韩语的助词紧跟在名词后面,中间没有空格,所以一个带括号的加粗词语是这样的:
<p><b>타이타닉(Titanic)</b>의 감독</p>最直接的 Markdown 写法是 **타이타닉(Titanic)**의,markdownify 输出的就是这个,但它不起作用。CommonMark 的 flanking 规则规定,** 跟在标点后面时,只有后面紧接空格或另一个标点才能闭合,而 의 是一个字母,所以这些星号会原样留在文本里。纯 Python 的 html2text 的办法是加一个空格:
**타이타닉(Titanic)** 의 감독这样能渲染出加粗,但改动了韩语原文。助词变成了一个单独的词,在韩语里这是拼写错误。pyhtml2text 的做法是把标点移到标记外面:
**타이타닉(Titanic**)의 감독括号不再是粗体,但强调依然能正确解析,原文的每个字符也都保留了下来。日语和中文也是同样的道理:中文词与词之间没有空格,一段以括号结尾的加粗文字后面如果直接跟着"的"之类的字,也会碰到同样的问题。任何不在后续文字前加空格的语言都一样。
读回来检查输出#
我不会因为一个转换器的单元测试都通过了就相信它,因为 Markdown 输出里的 bug 往往藏在没人想到要写测试的组合里。所以输出要读回来检查。
转换时,每个段落都会按 CommonMark 重新解析一次,确认强调读回来符合预期。如果不符合,就换另一种强调写法重写(用 * 代替 _,或者反过来);两种都不行,就保留文字、链接和代码,去掉格式,保证一个字都不丢。测试套件则用另一个 Markdown 解析器做往返检查,把每一个可见的词、链接地址和图片地址同原始 HTML 逐一比较。因为没转义而被语法吞掉的文字、没解析出来的链接、多出来的 *,都会作为差异显示出来。它在真实页面和成千上万个随机文档上运行,出错时还会把文档缩减成最小的复现用例。
上线#
0.3.0 发布当天,我那个应用的 Markdown 内容协商就从纯 Python 的 html2text 换成了 pyhtml2text。在其中一个指南页面上,转换时间从 4.2 ms 降到了 0.36 ms,快了大约 12 倍。链接都是绝对地址,表格是真正的管道表格,韩语的加粗也不会再把助词分开。abi3 wheel 本来就覆盖了 x86_64 和 arm64 上的 Python 3.14,所以容器镜像不需要加任何新东西就能装上它。
致谢,以及在哪里报告 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 的所有改动。
