<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Rust · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/rust/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Sun, 18 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/rust/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包：给 Python 用的快速 HTML 转 Markdown</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>开源</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>性能</category>
      <description>为了给 AI agent 把 HTML 转成 Markdown，我需要把相对链接解析成绝对链接。上游的 Rust crate 没法接受我的 pull request，因为它是用 LLM 写的，于是我 fork 了它，发布了 pyhtml2text：带 base_url 的 Python 绑定、新的 html2markdown、用 profile-guided 优化构建的 wheel，以及一个打过补丁、能在线性时间内解析深层嵌套 HTML 的解析器。本文讲它为什么存在、里面有什么，以及我怎么检查输出是否正确。</description>
      <content:encoded><![CDATA[<p>10 月 15 日，我在 PyPI 上发布了 <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>。它把 HTML 转成纯文本或 Markdown，底层是 Chris Emerson 写的 Rust crate <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>。在我测过的页面上，转 Markdown 的速度是常见 Python 转换器的 3 到 21 倍。它还能根据页面的 URL 解析相对链接，这样输出里的链接离开原页面后依然能用。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>它最初是给 rust-html2text 提的一个 pull request。维护者因为它是用 LLM 写的而关闭了它，所以 pyhtml2text 改为基于一个 fork 运行。这篇文章讲事情的经过、fork 和这个包里有什么，以及我怎么确认生成的 Markdown 是对的。</p>

<h2 class="relative group">为什么需要它
    <div id="为什么需要它" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e9%9c%80%e8%a6%81%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>maru 收到 <code>Accept: text/markdown</code> 的请求时会返回 Markdown 而不是 HTML，给那些不想在导航栏和页脚里翻找内容的 AI agent 和 LLM 客户端用。边缘那一侧的做法我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >关于 Cloudflare、爬虫和缓存的文章</a>里写过。商品、招聘和社区页面的 Markdown 是从数据库生成的，因为它们的 HTML 只是一个空的 Expo 外壳。其余的都是 Hugo 生成的真实 HTML（营销页、法律页和博客文章），这些需要转换。</p>
<p>转换的麻烦在于链接。Hugo 页面里到处都是 <code>/en/posts/...</code> 和 <code>../setup/</code> 这样的链接。在浏览器里没问题，因为浏览器知道它们来自哪个页面。读 Markdown 的 agent 没有这个上下文，所以它没法打开 <code>/login</code>，它需要的是 <code>https://example.com/login</code>。</p>
<p>纯 Python 的 <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> 有一个 <code>baseurl</code> 选项专门做这件事，我一开始用的就是它。但它很慢，生成的 Markdown 也有一些问题，后面会讲到。rust-html2text 速度快、测试充分，但它没有办法解析链接。</p>

<h2 class="relative group">先问上游
    <div id="先问上游" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%88%e9%97%ae%e4%b8%8a%e6%b8%b8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>8 月 29 日，我提了 <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a>，写明了使用场景和我想要的行为：按 RFC 3986 解析，绝对 URL 以及 <code>mailto:</code>、<code>data:</code> 和 <code>#fragment</code> 链接保持不变。几分钟后，我提了 <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>。它加了一个 <code>Config::base_url(...)</code> 选项，我在 issue 里说明这只是一个起点，如果维护者想换一种做法，我完全理解。</p>
<p>10 月 11 日，维护者关闭了这个 PR：</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;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&rsquo;t previously stated)</p>
</blockquote><p>（大意：感谢贡献，但我无法接受，因为它看起来是 LLM 生成的。我已经在 README.md 里加了一条简短的政策，之前没有写明。）</p>
<p>并在 issue 里回复：</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>（大意：这是个合理的功能请求，谢谢！PR 看起来是 LLM 写的，我无法接受，所以关闭了，但感谢你的尝试。如果能提供一小段 HTML 示例和期望的输出，肯定有助于实现这个功能！）</p>
<p>现在 README 里有这样一条政策：</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>（大意：由于版权状态尚不明确，本项目目前无法接受由生成式 AI/LLM 产出的代码贡献。）</p>
<p>维护者的判断没错：那段代码确实是我用 Claude Code 写的。我也不觉得这条政策不合理。LLM 输出的版权归属还没有定论，而把它合进一个许多项目都依赖的 MIT crate，风险是由维护者来承担的。这是对方的项目，而且回复也很客气。那个功能请求现在仍然开着。</p>
<p>不过我还是需要这个功能，而跟维护者争论对方自己定的贡献政策，并不是拿到它的好办法。所以我把改动留在一个 fork 上，在它之上构建了 Python 包，两者都清楚写明了自己不是上游。</p>

<h2 class="relative group">fork 里有什么
    <div id="fork-里有什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#fork-%e9%87%8c%e6%9c%89%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个 fork 是我那份 rust-html2text fork 上的 <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> 分支</a>。我刻意让它保持很小，这样能干净地 rebase 到上游。它只多了三个 commit：</p>
<ul>
<li><strong><code>Config::base_url</code>。</strong> 就是 PR 里的那个改动。相对链接按 <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a> 解析，用 crate 内部大约 200 行代码实现，而没有引入 <code>url</code> crate，因为它只需要做引用解析。不设置 base URL 时，输出不变。</li>
<li><strong>共享样式规则。</strong> 这个 crate 每渲染一个文档都会把样式表数据复制一份，<code>config::plain()</code> 每次调用还会重建它的十条装饰规则。把规则列表放到 <code>Arc</code> 后面（写时复制），并且只构建一次之后，一小段 HTML 的处理时间从 7.3 µs 降到了 5.6 µs。对大页面来说这不算什么，但调用几千次就积少成多了。</li>
<li><strong>导出 DOM 节点类型和 URL 解析函数。</strong> 下文要讲的 Markdown 转换器会自己遍历解析好的 DOM，所以它需要这些类型，而且解析链接的方式必须和文本渲染器一致。</li>
</ul>

<h2 class="relative group">Python 包
    <div id="python-包" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#python-%e5%8c%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> 用 PyO3 和 maturin 构建，提供两个函数：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code> 就是 rust-html2text 的纯文本渲染器，它是为终端和电子邮件的 <code>text/plain</code> 部分设计的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>关于这些绑定，有几点可能不那么显而易见：</p>
<ul>
<li><strong>两个函数都接受 <code>str</code> 或 UTF-8 的 <code>bytes</code></strong>，所以可以直接传 <code>response.content</code>，不用先解码。输入是借用的，不会复制。</li>
<li><strong>转换期间会释放 GIL。</strong> 从头到尾都是纯 Rust，所以多线程能用满多个核。模块还声明了自己不需要 GIL，因此在 free-threaded 的 Python 3.14t 上，导入它时不会把 GIL 重新打开。</li>
<li><strong>panic 会变成 <code>ValueError</code>。</strong> PyO3 默认抛出的是 <code>PanicException</code>，而 <code>except Exception</code> 捕获不到它。一个以为自己已经处理好转换错误的 web worker 会因此崩掉，所以绑定层把它捕获下来，重新抛成 <code>ValueError</code>。</li>
<li><strong>一共有 30 个 wheel：</strong> CPython 3.10 及以上的 abi3 wheel，加上 free-threaded 3.14t 的 wheel，覆盖 Linux（glibc 和 musl）、macOS 和 Windows 上 maturin 的 CI 能构建的所有架构。所以安装时谁都不需要 Rust 工具链。</li>
</ul>

<h2 class="relative group">让它变快
    <div id="让它变快" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%a9%e5%ae%83%e5%8f%98%e5%bf%ab" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>0.2.0 版在真实页面上比 0.1.0 快 1.5 到 1.7 倍，在短片段上快一倍，而且输出逐字节一致。主要靠三项改动：</p>
<ul>
<li><strong>Profile-guided 优化（PGO）。</strong> CI 先构建一个插桩版的 wheel，用一组 Python 文档页面和 RFC 作为训练集跑一遍，再用得到的 profile 构建正式的 wheel。在训练集之外的页面上大约快 25%。只有 GitHub 能原生运行插桩构建的平台才这样做（Linux x86_64 和 aarch64、macOS、Windows x64），其他平台拿到的是同样的代码，只是没有 PGO。</li>
<li><strong>mimalloc。</strong> html2text 会做大量的小内存分配，mimalloc 处理起来比系统分配器好得多。glibc 上的 armv7、ppc64le 和 s390x 没有用它，因为那里的 manylinux2014 交叉编译器太旧，编译不了它。</li>
<li>fork 里的<strong>共享样式规则</strong>。</li>
</ul>
<p>为了确认这些提速没有改变任何输出，有一个基准测试脚本会在十一组选项下，对本地语料库里每个页面的输出计算哈希，另外还有一些边界情况，比如空输入、300 层嵌套和 <code>rowspan=0</code>。每一项改动都必须让所有哈希保持不变。</p>
<p>下面是在 Apple Silicon Mac 上转 Markdown 的中位耗时：</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>维基百科条目（1 MB）</th>
					<th>BBC News 首页（370 KB）</th>
					<th>Python 文档页（245 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（纯 Python）</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>转纯文本：</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>维基百科条目（780 KB）</th>
					<th>BBC News 首页（370 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（纯 Python）</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">深层嵌套的 HTML，以及给 html5ever 打的补丁
    <div id="深层嵌套的-html以及给-html5ever-打的补丁" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b7%b1%e5%b1%82%e5%b5%8c%e5%a5%97%e7%9a%84-html%e4%bb%a5%e5%8f%8a%e7%bb%99-html5ever-%e6%89%93%e7%9a%84%e8%a1%a5%e4%b8%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>没人会手写 40,000 层嵌套的列表。但如果你要转换不是自己写的页面，迟早会碰到某个恶意的或者坏掉的页面给你来这么一下。用原版解析器，解析 40,000 层嵌套的 <code>&lt;ul&gt;&lt;li&gt;</code> 要 <strong>6 秒</strong>，也就是一个 worker 被一个请求卡住 6 秒。</p>
<p>原因在 <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>，也就是 rust-html2text 用的 HTML5 解析器。大多数块级开始标签都要检查是否有一个打开着的 <code>&lt;p&gt;</code> 元素处于 &ldquo;button scope&rdquo; 中，以便决定要不要关闭它。html5ever 的做法是把整个打开元素栈走一遍，所以嵌套越深，每个标签花的时间就越长，总耗时随深度的平方增长。</p>
<p>这个包 vendor 了 html5ever 0.39.0，并打了一个补丁。现在打开元素栈会记录最上面的 <code>&lt;p&gt;</code> 和最上面的 button scope 边界各在哪里，并在每次 push、pop 和 truncate 时更新，于是这个检查变成了 O(1)。同一个文档现在 <strong>0.14 秒</strong> 就能解析完。在 debug 构建里，每次的结果还会和原来的遍历栈方式做比对。为了确保补丁没有改变别的东西，我在 html5lib 的全部树构建测试、大约 20 个真实页面和 300,000 个随机的标签乱炖文档上，比较了原版和打补丁后的解析器生成的 DOM，结果全部一致。</p>
<p>出于同样的原因，Markdown 转换器也有自己的上限。它用显式的栈而不是递归来遍历树，所以任何输入都不会让线程栈溢出。blockquote 和列表超过 32 层后就不再继续缩进，因为每一行都要重复外层所有元素的前缀，不加限制的话输出会按平方增长。另外，如果一个表格展开后的网格会超过 100,000 个单元格，就只按单元格逐个输出。</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>0.3.0 版加入了 <code>html2markdown</code>，这个包的大部分工作都花在它上面。rust-html2text 的渲染器输出的文本看起来像 Markdown，但它是按终端宽度折行的，并不打算被解析。所以 <code>html2markdown</code> 只用 rust-html2text 和打过补丁的 html5ever 来解析页面，然后自己遍历 DOM，输出 <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>，外加 GitHub 的管道表格和 <code>~~删除线~~</code>：</p>
<ul>
<li><code>#</code> 标题、<code>-</code> 列表、<code>_强调_</code> 和 <code>**加粗**</code>。</li>
<li>行内链接和图片，给了 <code>base_url</code> 时转成绝对地址。如果一个链接里只有图标，就用它的 <code>aria-label</code> 或 <code>title</code> 作为链接文字。</li>
<li>围栏代码块，页面标明了语言时（<code>class=&quot;language-python&quot;</code>）会带上语言标记。</li>
<li>管道表格。跨行或跨列的单元格变成空单元格，没有表头行的表格会补一个空表头，用来做页面布局的表格则写成普通段落。</li>
<li>不折行。隐藏内容（<code>hidden</code>、<code>aria-hidden</code>、<code>display: none</code>）、脚本、样式和表单控件都会被去掉。</li>
</ul>

<h3 class="relative group">只转义需要转义的
    <div id="只转义需要转义的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8f%aa%e8%bd%ac%e4%b9%89%e9%9c%80%e8%a6%81%e8%bd%ac%e4%b9%89%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>有些 HTML 转 Markdown 的工具会把所有可能是 Markdown 语法的字符都转义掉。比如 markdownify 默认会转义每一个 <code>_</code> 和 <code>*</code>。这样的输出是正确的，但满篇都是 <code>snake\_case</code> 和 <code>5 \* 3</code>，人读起来费劲，对模型来说也是浪费 token。pyhtml2text 只在 CommonMark 解析器会把某个字符当成语法的地方才转义它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">韩语助词前的加粗
    <div id="韩语助词前的加粗" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9f%a9%e8%af%ad%e5%8a%a9%e8%af%8d%e5%89%8d%e7%9a%84%e5%8a%a0%e7%b2%97" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这个问题来自 maru 自己的页面。韩语的助词紧跟在名词后面，中间没有空格，所以一个带括号的加粗词语是这样的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>最直接的 Markdown 写法是 <code>**타이타닉(Titanic)**의</code>，markdownify 输出的就是这个，但它不起作用。CommonMark 的 flanking 规则规定，<code>**</code> 跟在标点后面时，只有后面紧接空格或另一个标点才能闭合，而 <code>의</code> 是一个字母，所以这些星号会原样留在文本里。纯 Python 的 html2text 的办法是加一个空格：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>这样能渲染出加粗，但改动了韩语原文。助词变成了一个单独的词，在韩语里这是拼写错误。pyhtml2text 的做法是把标点移到标记外面：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>括号不再是粗体，但强调依然能正确解析，原文的每个字符也都保留了下来。日语和中文也是同样的道理：中文词与词之间没有空格，一段以括号结尾的加粗文字后面如果直接跟着&quot;的&quot;之类的字，也会碰到同样的问题。任何不在后续文字前加空格的语言都一样。</p>

<h3 class="relative group">读回来检查输出
    <div id="读回来检查输出" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%af%bb%e5%9b%9e%e6%9d%a5%e6%a3%80%e6%9f%a5%e8%be%93%e5%87%ba" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我不会因为一个转换器的单元测试都通过了就相信它，因为 Markdown 输出里的 bug 往往藏在没人想到要写测试的组合里。所以这个转换器用两种方式检查自己的输出。</p>
<p>转换时，每个段落里的强调标记都会按 flanking 规则检查一遍。然后用 <a href="https://github.com/raphlinus/pulldown-cmark"  target="_blank" rel="noreferrer">pulldown-cmark</a> 把这个段落重新解析一次，确认读回来的结果符合预期。如果不符合，转换器就换另一种强调写法重写一遍（用 <code>*</code> 代替 <code>_</code>，或者反过来）。如果两种写法都通不过，就保留文字、链接和代码，去掉格式，保证一个字都不丢。</p>
<p>在测试套件里，<a href="https://github.com/jaredlynskey/pyhtml2text/blob/main/tests/markdown_check.py"  target="_blank" rel="noreferrer"><code>tests/markdown_check.py</code></a> 用另一个解析器做往返检查。它把 HTML 转成 Markdown，再用 markdown-it 渲染回 HTML，然后把每一个可见的词、链接地址和图片地址同原始页面逐一比较。因为没转义而被语法吞掉的文字、没解析出来的链接、多出来的 <code>*</code>，都会作为差异显示出来。它会在真实页面和成千上万个随机文档上运行，还能把一个出错的文档缩减成最小的复现用例。</p>

<h2 class="relative group">在 maru 上线
    <div id="在-maru-上线" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8-maru-%e4%b8%8a%e7%ba%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>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 镜像不需要加任何新东西就能装上它。</p>

<h2 class="relative group">致谢，以及在哪里报告 bug
    <div id="致谢以及在哪里报告-bug" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%87%b4%e8%b0%a2%e4%bb%a5%e5%8f%8a%e5%9c%a8%e5%93%aa%e9%87%8c%e6%8a%a5%e5%91%8a-bug" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>纯文本渲染来自 Chris Emerson 的 rust-html2text，采用 MIT 许可证。pyhtml2text 在此之上加了它的一个 fork、一份打过补丁的 html5ever（MIT/Apache-2.0）以及 Markdown 转换器。这个包与这两个项目都没有关联，也没有得到它们的认可，所以<strong>有问题请报到 <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text 的 issue 页面</a></strong>，不要报到上游。这些代码里的 bug 由我来修，上游维护者不应该收到关于他们拒绝接受的代码的报告。</p>
<p>如果你在用 Python 转换 HTML，特别是要给模型读的，不妨试试：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<p>源码在 <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a> 上，changelog 里列出了 0.1.0 到 0.3.0 的所有改动。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
