<?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/ja/tags/rust/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sun, 18 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/rust/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>閉じられたプルリクエスト、フォーク、そしてPyPIパッケージ：Python向けの高速なHTML→Markdown変換</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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エージェント向けにHTMLをMarkdownに変換するとき、相対リンクを解決する必要がありました。上流のRustクレートは、LLMで書かれたという理由で私のプルリクエストを受け入れられなかったので、フォークしてpyhtml2textとして公開しました。base_url付きのPythonバインディング、新しいhtml2markdown、プロファイルガイド最適化をかけたwheel、そして深くネストしたHTMLを線形時間で処理できるようにパッチを当てたパーサーです。なぜ作ったのか、中に何が入っているのか、そして出力をどう検証したかを紹介します。</description>
      <content:encoded><![CDATA[<p>10月15日、<a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>をPyPIで公開しました。HTMLをプレーンテキストやMarkdownに変換するライブラリで、Chris EmersonさんのRustクレート<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へのプルリクエストでした。メンテナーはLLMで書かれたという理由でそれをクローズしたので、pyhtml2textはフォークの上で動いています。この記事では、その経緯、フォークとパッケージの中身、そして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="#%e3%81%aa%e3%81%9c%e5%bf%85%e8%a6%81%e3%81%a0%e3%81%a3%e3%81%9f%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>maruは <code>Accept: text/markdown</code> のリクエストに、HTMLの代わりにMarkdownを返します。ナビゲーションバーやフッターをかき分けて読みたくないAIエージェントやLLMクライアントのためです。エッジ側の話は<a href="https://jared.lynskey.co.nz/ja/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を読むエージェントにはその文脈がないので、<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="#%e3%81%be%e3%81%9a%e3%81%af%e4%b8%8a%e6%b5%81%e3%81%ab%e7%9b%b8%e8%ab%87" 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">プルリクエスト #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ライセンスのクレートにそれをマージするリスクを負うのはメンテナーです。メンテナーのプロジェクトですし、返信も丁寧でした。機能リクエストは今もオープンのままです。</p>
<p>それでも、私にはこの機能が必要でした。かといって、メンテナー自身の貢献ポリシーについて言い争うのは、機能を手に入れる良い方法ではありません。そこで変更はフォークに残し、その上にPythonパッケージを作りました。どちらにも、上流のものではないとはっきり書いてあります。</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="#%e3%83%95%e3%82%a9%e3%83%bc%e3%82%af%e3%81%ae%e4%b8%ad%e8%ba%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>フォークは私のrust-html2textフォークの<a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> ブランチ</a>で、上流にきれいにリベースできるよう小さく保っています。追加したコミットは3つです。</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>に従って解決します。必要なのは参照の解決だけなので、<code>url</code> クレートは使わず、クレート内の約200行で実装しました。ベースURLを指定しなければ出力は変わりません。</li>
<li><strong>スタイルルールの共有。</strong> クレートはレンダリングするドキュメントごとにスタイルシートのデータを複製し、<code>config::plain()</code> は呼び出しのたびに10個の装飾ルールを作り直していました。ルールのリストを <code>Arc</code>（コピーオンライト）の裏に置き、一度だけ作るようにしたところ、短いスニペットの処理が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%e3%83%91%e3%83%83%e3%82%b1%e3%83%bc%e3%82%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a>はPyO3とmaturinで作っていて、関数は2つです。</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を必要としないことも宣言しているので、フリースレッド版のPython 3.14tでも、インポート時にGILが再び有効になることはありません。</li>
<li><strong>パニックは <code>ValueError</code> として返ります。</strong> PyO3はデフォルトで <code>PanicException</code> を送出しますが、これは <code>except Exception</code> では捕まえられません。変換エラーは処理済みのつもりのWebワーカーがそれで落ちてしまうので、バインディング側で捕まえて <code>ValueError</code> として送出し直しています。</li>
<li><strong>wheelは30個あります。</strong> CPython 3.10以降向けのabi3 wheelと、フリースレッド版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="#%e9%ab%98%e9%80%9f%e5%8c%96" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バージョン0.2.0は、実際のページで0.1.0より1.5〜1.7倍、短いスニペットでは2倍高速になりました。出力はバイト単位で同一のままです。効いたのは主に次の3つです。</p>
<ul>
<li><strong>プロファイルガイド最適化（PGO）。</strong> CIで計測用のwheelをビルドし、Pythonドキュメントのページ群とRFCからなる学習用データで実行して、そのプロファイルを使って本番の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>フォークで入れた<strong>スタイルルールの共有</strong>。</li>
</ul>
<p>高速化で何も変わっていないことを確かめるため、ベンチマークスクリプトが手元のコーパスの全ページについて、11通りのオプションの組み合わせで出力のハッシュを取ります。空の入力、300階層のネスト、<code>rowspan=0</code> といったエッジケースも含みます。どの変更でも、すべてのハッシュが変わらないことを条件にしました。</p>
<p>以下はApple SiliconのMacで計測した中央値です。まずMarkdownへの変換です。</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Wikipediaの記事（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>Wikipediaの記事（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%e3%81%8f%e3%83%8d%e3%82%b9%e3%83%88%e3%81%97%e3%81%9fhtml%e3%81%a8html5ever%e3%81%b8%e3%81%ae%e3%83%91%e3%83%83%e3%83%81" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>40,000階層のリストを手で書く人はいません。でも、自分が書いていないページを変換していれば、いずれ悪意のあるものや壊れたものが送られてきます。標準のパーサーでは、40,000階層にネストした <code>&lt;ul&gt;&lt;li&gt;</code> のパースに<strong>6秒</strong>かかりました。たった1つのリクエストでワーカーが止まってしまうということです。</p>
<p>原因は、rust-html2textが使っているHTML5パーサーの<a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>にありました。ほとんどのブロックレベルの開始タグは、開いている <code>&lt;p&gt;</code> 要素が「button scope内」にあるかを確認し、それを閉じるべきかを判断する必要があります。html5everはこれを、開いている要素のスタック全体をたどって判定していました。そのため、ネストが深くなるほど1つのタグにかかる時間が長くなり、全体の時間は深さの2乗で増えていきました。</p>
<p>パッケージにはhtml5ever 0.39.0を同梱（vendor）し、パッチを1つ当てています。開いている要素のスタックが、一番上の <code>&lt;p&gt;</code> と一番上のbutton scopeの境界の位置を記録し、push、pop、truncateのたびに更新するので、判定はO(1)で済みます。同じドキュメントのパースは<strong>0.14秒</strong>になりました。デバッグビルドでは、すべての判定結果を以前のスタック走査の結果とも照合しています。パッチがほかに何も変えていないことを確かめるため、html5libのツリー構築テストすべて、約20の実際のページ、300,000件のランダムなタグスープについて、標準のパーサーとパッチ版のパーサーが作るDOMを比較し、すべて同一でした。</p>
<p>Markdownコンバーターにも、同じ理由で独自の上限があります。ツリーは再帰ではなく明示的なスタックでたどるので、どんな入力でもスレッドのスタックがあふれることはありません。引用ブロックとリストは32階層を超えるとそれ以上インデントしません。各行が周囲すべての接頭辞を繰り返すので、そうしないと出力が2乗で大きくなるからです。また、行や列の結合を展開したグリッドが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>~~strikethrough~~</code> を加えた形で書き出します。</p>
<ul>
<li><code>#</code> の見出し、<code>-</code> の箇条書き、<code>_emphasis_</code> と <code>**strong**</code>。</li>
<li>インラインのリンクと画像。<code>base_url</code> を指定すれば絶対URLになります。中身がアイコンだけのリンクは、<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%bf%85%e8%a6%81%e3%81%aa%e3%81%a8%e3%81%93%e3%82%8d%e3%81%a0%e3%81%91%e3%82%a8%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%97%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>HTMLからMarkdownへの変換ライブラリの中には、Markdownの構文になりうる文字をすべてエスケープするものがあります。たとえばmarkdownifyは、デフォルトで <code>_</code> と <code>*</code> をすべてエスケープします。出力は正しいのですが、<code>snake\_case</code> や <code>5 \* 3</code> だらけになり、人間には読みにくく、モデルにとってはトークンの無駄です。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%93%e5%9b%bd%e8%aa%9e%e3%81%ae%e5%8a%a9%e8%a9%9e%e3%81%ae%e5%89%8d%e3%81%ae%e5%a4%aa%e5%ad%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>閉じ括弧は太字ではなくなりますが、強調は正しくパースされ、元の文字はすべて残ります。</p>
<p>日本語でも同じことが起きます。日本語の助詞（の、を、は）も、韓国語と同じく前の語にスペースなしで付きます。全角の括弧もCommonMarkでは句読点として扱われるので、<code>**タイタニック（Titanic）**の</code> のような書き方では太字になりません。中国語をはじめ、後ろに続く語の前にスペースを置かない言語ならどれも同じです。</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%aa%ad%e3%81%bf%e6%88%bb%e3%81%97%e3%81%a6%e5%87%ba%e5%8a%9b%e3%82%92%e7%a2%ba%e3%81%8b%e3%82%81%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ユニットテストが通るからといって、変換ライブラリを信用はしません。Markdownの出力のバグは、誰もテストを書こうと思わなかった組み合わせに潜んでいるからです。そこでコンバーターは、2つの方法で自分の出力を確かめます。</p>
<p>変換中は、段落ごとに強調のマーカーをフランキングルールと照らし合わせます。そのうえで段落を<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="#maru%e3%81%ae%e6%9c%ac%e7%95%aa%e7%92%b0%e5%a2%83%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>maruのMarkdownネゴシエーションは、0.3.0が出たその日に、純粋なPythonのhtml2textからpyhtml2textに切り替えました。maruのガイドページのひとつでは、変換が4.2 msから0.36 msになり、約12倍高速になりました。リンクは絶対URLになり、表は本物のパイプテーブルになり、韓国語の太字は助詞がくっついたままです。abi3 wheelがx86_64とarm64のPython 3.14をすでにカバーしているので、Dockerイメージにはインストールのために何も追加する必要がありませんでした。</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="#%e3%82%af%e3%83%ac%e3%82%b8%e3%83%83%e3%83%88%e3%81%a8%e3%83%90%e3%82%b0%e5%a0%b1%e5%91%8a%e5%85%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>プレーンテキストのレンダリングは、Chris EmersonさんによるMITライセンスのrust-html2textです。pyhtml2textは、そのフォーク、パッチを当てたhtml5ever（MIT/Apache-2.0）、そしてMarkdownコンバーターを加えたものです。このパッケージはどちらのプロジェクトとも提携しておらず、承認も受けていません。なので、<strong>問題は上流ではなく<a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2textのissueトラッカー</a>に報告してください</strong>。このコードのバグを直すのは私の役目ですし、上流のメンテナーが受け入れを断ったコードについての報告を受けるべきではありません。</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>にあり、0.1.0から0.3.0までの変更はすべてchangelogにまとめてあります。</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>
