10月15日、pyhtml2textをPyPIで公開しました。HTMLをプレーンテキストやMarkdownに変換するライブラリで、Chris EmersonさんのRustクレート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へのプルリクエストでした。メンテナーはLLMで書かれたという理由でそれをクローズしたので、pyhtml2textはフォークの上で動いています。この記事では、その経緯、フォークとパッケージの中身、そしてMarkdownが正しいことをどう確かめたかを書きます。
なぜ必要だったのか#
maruは Accept: text/markdown のリクエストに、HTMLの代わりにMarkdownを返します。ナビゲーションバーやフッターをかき分けて読みたくないAIエージェントやLLMクライアントのためです。エッジ側の話はCloudflare、クローラー、キャッシュについての記事に書きました。出品、求人、コミュニティのページは、データベースからMarkdownを組み立てます。これらのページのHTMLは中身のないExpoのシェルだからです。それ以外(マーケティング、規約、ブログのページ)はHugoが出力する本物のHTMLなので、それを変換します。
変換するうえで問題になるのはリンクです。Hugoのページには /en/posts/... や ../setup/ のようなリンクがたくさんあります。ブラウザなら元のページが分かっているので問題ありません。でもMarkdownを読むエージェントにはその文脈がないので、/login をたどれません。必要なのは https://example.com/login です。
純粋なPythonで書かれたhtml2textにはこのための baseurl オプションがあり、最初はそれを使っていました。ただ遅いうえに、Markdownの出力にも問題があります(後で説明します)。rust-html2textは高速でテストもしっかりしていましたが、リンクを解決する手段がありませんでした。
まずは上流に相談#
8月29日にissue #287を立て、ユースケースと求める挙動を書きました。RFC 3986に従って解決し、絶対URLや mailto:、data:、#fragment のリンクはそのままにする、というものです。その数分後にプルリクエスト #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ライセンスのクレートにそれをマージするリスクを負うのはメンテナーです。メンテナーのプロジェクトですし、返信も丁寧でした。機能リクエストは今もオープンのままです。
それでも、私にはこの機能が必要でした。かといって、メンテナー自身の貢献ポリシーについて言い争うのは、機能を手に入れる良い方法ではありません。そこで変更はフォークに残し、その上にPythonパッケージを作りました。どちらにも、上流のものではないとはっきり書いてあります。
フォークの中身#
フォークは私のrust-html2textフォークのpyhtml2text-perf ブランチで、上流にきれいにリベースできるよう小さく保っています。追加したコミットは3つです。
Config::base_url。 PRで出した変更です。相対リンクはRFC 3986 §5.3に従って解決します。必要なのは参照の解決だけなので、urlクレートは使わず、クレート内の約200行で実装しました。ベースURLを指定しなければ出力は変わりません。- スタイルルールの共有。 クレートはレンダリングするドキュメントごとにスタイルシートのデータを複製し、
config::plain()は呼び出しのたびに10個の装飾ルールを作り直していました。ルールのリストをArc(コピーオンライト)の裏に置き、一度だけ作るようにしたところ、短いスニペットの処理が7.3 µsから5.6 µsになりました。大きなページなら誤差ですが、何千回も呼び出すと効いてきます。 - DOMのノード型とURLリゾルバーの公開。 後で説明するMarkdownコンバーターはパース済みのDOMを自分でたどるので、これらの型が必要で、リンクもテキストレンダラーと同じ方法で解決しなければなりません。
Pythonパッケージ#
pyhtml2textはPyO3とmaturinで作っていて、関数は2つです。
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を必要としないことも宣言しているので、フリースレッド版のPython 3.14tでも、インポート時にGILが再び有効になることはありません。
- パニックは
ValueErrorとして返ります。 PyO3はデフォルトでPanicExceptionを送出しますが、これはexcept Exceptionでは捕まえられません。変換エラーは処理済みのつもりのWebワーカーがそれで落ちてしまうので、バインディング側で捕まえてValueErrorとして送出し直しています。 - wheelは30個あります。 CPython 3.10以降向けのabi3 wheelと、フリースレッド版3.14t向けのwheelを、Linux(glibcとmusl)、macOS、Windowsについて、maturinのCIがビルドできるすべてのアーキテクチャ向けに用意しています。なので、インストールにRustのツールチェーンは必要ありません。
高速化#
バージョン0.2.0は、実際のページで0.1.0より1.5〜1.7倍、短いスニペットでは2倍高速になりました。出力はバイト単位で同一のままです。効いたのは主に次の3つです。
- プロファイルガイド最適化(PGO)。 CIで計測用のwheelをビルドし、Pythonドキュメントのページ群とRFCからなる学習用データで実行して、そのプロファイルを使って本番のwheelをビルドします。学習用データに含まれないページで約25%高速になります。計測用ビルドをGitHubがネイティブに実行できる環境(Linuxのx86_64とaarch64、macOS、Windows x64)でだけ行い、ほかのプラットフォームには同じコードをPGOなしでビルドしたものを配っています。
- mimalloc。 html2textは小さなメモリ確保を大量に行いますが、mimallocはシステムのアロケーターよりずっとうまくそれをさばきます。glibcのarmv7、ppc64le、s390xでは、manylinux2014のクロスコンパイラーが古すぎてビルドできないので外しています。
- フォークで入れたスタイルルールの共有。
高速化で何も変わっていないことを確かめるため、ベンチマークスクリプトが手元のコーパスの全ページについて、11通りのオプションの組み合わせで出力のハッシュを取ります。空の入力、300階層のネスト、rowspan=0 といったエッジケースも含みます。どの変更でも、すべてのハッシュが変わらないことを条件にしました。
以下はApple SiliconのMacで計測した中央値です。まずMarkdownへの変換です。
| Wikipediaの記事(1 MB) | BBC Newsのトップページ(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 |
次にプレーンテキストへの変換です。
| Wikipediaの記事(780 KB) | BBC Newsのトップページ(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秒かかりました。たった1つのリクエストでワーカーが止まってしまうということです。
原因は、rust-html2textが使っているHTML5パーサーのhtml5everにありました。ほとんどのブロックレベルの開始タグは、開いている <p> 要素が「button scope内」にあるかを確認し、それを閉じるべきかを判断する必要があります。html5everはこれを、開いている要素のスタック全体をたどって判定していました。そのため、ネストが深くなるほど1つのタグにかかる時間が長くなり、全体の時間は深さの2乗で増えていきました。
パッケージにはhtml5ever 0.39.0を同梱(vendor)し、パッチを1つ当てています。開いている要素のスタックが、一番上の <p> と一番上のbutton scopeの境界の位置を記録し、push、pop、truncateのたびに更新するので、判定はO(1)で済みます。同じドキュメントのパースは0.14秒になりました。デバッグビルドでは、すべての判定結果を以前のスタック走査の結果とも照合しています。パッチがほかに何も変えていないことを確かめるため、html5libのツリー構築テストすべて、約20の実際のページ、300,000件のランダムなタグスープについて、標準のパーサーとパッチ版のパーサーが作るDOMを比較し、すべて同一でした。
Markdownコンバーターにも、同じ理由で独自の上限があります。ツリーは再帰ではなく明示的なスタックでたどるので、どんな入力でもスレッドのスタックがあふれることはありません。引用ブロックとリストは32階層を超えるとそれ以上インデントしません。各行が周囲すべての接頭辞を繰り返すので、そうしないと出力が2乗で大きくなるからです。また、行や列の結合を展開したグリッドが100,000セルを超える表は、セルの中身だけを書き出します。
html2markdown#
バージョン0.3.0で html2markdown を追加しました。パッケージの作業の大半はこれに費やしています。rust-html2textのレンダラーはMarkdownに見えるテキストを作りますが、ターミナル向けに折り返されていて、パースされることは想定していません。そこで html2markdown は、rust-html2textとパッチ版のhtml5everをページのパースにだけ使い、DOMを自分でたどってCommonMarkに、GitHubのパイプテーブルと ~~strikethrough~~ を加えた形で書き出します。
#の見出し、-の箇条書き、_emphasis_と**strong**。- インラインのリンクと画像。
base_urlを指定すれば絶対URLになります。中身がアイコンだけのリンクは、aria-labelかtitleをリンクテキストとして使います。 - フェンス付きのコードブロック。ページが言語を指定していれば(
class="language-python")、その言語を付けます。 - パイプテーブル。行や列をまたぐセルは空のセルになり、ヘッダー行のない表には空のヘッダー行が付き、ページのレイアウトに使われている表は段落として書き出します。
- 行の折り返しはしません。隠しコンテンツ(
hidden、aria-hidden、display: none)、スクリプト、スタイル、フォームのコントロールは除外します。
必要なところだけエスケープする#
HTMLからMarkdownへの変換ライブラリの中には、Markdownの構文になりうる文字をすべてエスケープするものがあります。たとえばmarkdownifyは、デフォルトで _ と * をすべてエスケープします。出力は正しいのですが、snake\_case や 5 \* 3 だらけになり、人間には読みにくく、モデルにとってはトークンの無駄です。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**)의 감독閉じ括弧は太字ではなくなりますが、強調は正しくパースされ、元の文字はすべて残ります。
日本語でも同じことが起きます。日本語の助詞(の、を、は)も、韓国語と同じく前の語にスペースなしで付きます。全角の括弧もCommonMarkでは句読点として扱われるので、**タイタニック(Titanic)**の のような書き方では太字になりません。中国語をはじめ、後ろに続く語の前にスペースを置かない言語ならどれも同じです。
読み戻して出力を確かめる#
ユニットテストが通るからといって、変換ライブラリを信用はしません。Markdownの出力のバグは、誰もテストを書こうと思わなかった組み合わせに潜んでいるからです。そこでコンバーターは、2つの方法で自分の出力を確かめます。
変換中は、段落ごとに強調のマーカーをフランキングルールと照らし合わせます。そのうえで段落をpulldown-cmarkでもう一度パースし、意図どおりに読み戻せるかを確認します。読み戻せなければ、もう一方の強調スタイル(_ の代わりに *、またはその逆)で書き直します。どちらも通らなければ、テキスト、リンク、コードは残して書式だけを落とすので、単語が失われることはありません。
テストスイートでは、tests/markdown_check.pyが別のパーサーを使って往復チェックをします。HTMLをMarkdownに変換し、それをmarkdown-itでHTMLに戻し、表示される単語、リンク先、画像のソースをすべて元のページと比較します。エスケープされなかった構文に飲み込まれたテキスト、パースされなかったリンク、紛れ込んだ * などは、すべて差分として現れます。実際のページと何万件ものランダムなドキュメントで実行していて、失敗したドキュメントを最小のケースまで縮めることもできます。
maruの本番環境で#
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イメージにはインストールのために何も追加する必要がありませんでした。
クレジットとバグ報告先#
プレーンテキストのレンダリングは、Chris EmersonさんによるMITライセンスのrust-html2textです。pyhtml2textは、そのフォーク、パッチを当てたhtml5ever(MIT/Apache-2.0)、そしてMarkdownコンバーターを加えたものです。このパッケージはどちらのプロジェクトとも提携しておらず、承認も受けていません。なので、問題は上流ではなくpyhtml2textのissueトラッカーに報告してください。このコードのバグを直すのは私の役目ですし、上流のメンテナーが受け入れを断ったコードについての報告を受けるべきではありません。
PythonでHTMLを変換していて、とくにモデルに読ませる用途なら、ぜひ試してみてください。
pip install pyhtml2textソースはGitHubにあり、0.1.0から0.3.0までの変更はすべてchangelogにまとめてあります。
