AIクローラー(Gemini、GPT、Claude、Perplexityなど)は絶えずサイトを読みにきており、HTMLよりMarkdownのほうを好む。Markdownのほうが文脈がクリーンで、トークン数が少なく、推論コストも安い。エージェントがどうせあなたのページを要約するなら、DOMを歩くことに割くトークン予算を、実際のコンテンツに回したほうが合理的だ。
エージェントにMarkdownを届ける方法は二つある。コンテンツが すでにMarkdown の場合(CMS、Gitリポジトリ、データベースにある場合)、Accept: text/markdownヘッダでフォーマットを交渉するだけで済む。筆者の姉妹記事「SvelteKit + Cloudflare WorkersでAIエージェント向けMarkdownを配信する」が、そのケースを詳しく扱っている。
コンテンツが HTML の場合(サードパーティページのプロキシ、ドキュメントのミラー、リーダーモード・エンドポイント、LLMサマライザーへの供給、あるいは単に静的サイトを配信する場合など)は、Cloudflare WorkerのなかでMarkdownに変換することになる。筆者はcogley.jpを無料プランで、会社と顧客のサイトを有料プランで運用しており、このトレードオフは折に触れて向き合うものだ。有料プランならCPUとバンドルの余裕があってライブラリはほぼ何でも選べるが、無料プランでは1リクエストあたり 10 msのCPU と 1 MBの圧縮Workerバンドル しかなく、この制約がかなり特殊な選択を強いる。本記事は、その制約のなかで何が実際に収まるかを実測する。
有料プランで買っている予算
数字を示す前に、ひとつ整理しておきたい。Cloudflareで「有料」という言葉は紛らわしく、二つの異なる製品を指しうる。
- Workers Paid:月額5ドル+従量課金。Workerランタイムのアップグレードだ。CPU予算は10 msから30 sへ、圧縮バンドル上限は1 MBから10 MBに拡大する。HTMLからMarkdownへの変換の前提条件を変えるのは、このプランだ。
- Cloudflare Pro:ドメインあたり月額20ドル。画像最適化、高度なWAF、Page Rules、モバイルリダイレクトなどが追加される。こちらはドメインプランであり、Workerプランではない。Workerの制限は 変わらない。
Proだけ、あるいはWorkers Paidだけの利用も可能で、課金は別だ。本記事で「有料」と言った場合、それはWorkers Paidを指す。Cloudflare Proに月20ドル払っても、Worker CPUが追加されるわけではない。
Workers無料プランでは、1リクエストあたり次の制限がかかる。
| 制限 | 無料 | 有料 |
|---|---|---|
| CPU時間(リクエスト毎) | 10 ms | 30 s(Standard)/ 5 min(Unbound) |
| 圧縮Workerバンドル | 1 MB | 10 MB |
| サブリクエスト数 | 50 | 1000 |
| KV書き込み(1日) | 1,000 | 1,000,000 |
ルーティング、JSON変換、レスポンス書き換えといったWorkerの一般的な仕事なら、10 msは十分すぎる。HTMLからMarkdownへの変換は違う。DOMを解析し、全ノードを歩き、変換された文字列を生成する処理だ。CPU集約的で、自前のDOM実装を含むライブラリはだいたいバンドル予算も食い潰す。
面白いのは「無料で できる か?」ではない。いくつかの方法は明らかに可能だ。本当の問いは、与えられた予算のなかで、実世界の入力のばらつきを手作業の介入なしで耐えられる余裕がどの方法にあるか、である。
結論:HTMLRewriter
HTMLRewriterはworkerdに組み込まれている。 (workerdはCloudflareのオープンソースJavaScript/Wasmランタイムで、エッジでWorkerを実行するV8ベースのエンジンそのものであり、wrangler devがローカルで動かすものでもある。)npm依存はゼロ。Cloudflare自身もレスポンス変換に使っている。
アーキテクチャ上の区別が肝心だ。HTMLRewriterは ストリーミング かつ SAXスタイル で、バイトが到着するたびに消費し、<h1> / テキスト / </h1> のイベントを発火する。メモリ上にツリーを構築することはない。turndown / Readability / cheerio系は逆の動きで、ドキュメント全体をバッファに載せ、全ノードと親ポインタを持つDOMを構築してから歩く。この構築パスは、Markdownを1文字も生成する前に払うCPU税であり、これらのライブラリが自前のDOM実装(数百KBのバンドル)を同梱する理由でもある。HTMLRewriterはどちらのコストも払わない。
サンプルの34 KB HTML記事に対して:
- バンドル:10.52 KiB 非圧縮 / 3.74 KiB gzip(1 MB無料予算の0.4%)
- CPU:50回実行の中央値2 ms(最小2、最大8)。10 ms予算の20%。
- 出力:24.9 KBのMarkdown
これはつまり、無料プランの上限に対して CPUで5倍、バンドルで250倍の余裕 だ。筆者が計測した他のどの選択肢も、遠くおよばない。数値はwrangler devのローカルworkerdのものであり、エッジランタイムは通常1.5〜2倍遅いので、現実的な中央値として3〜4 msを想定してほしい。それでも10 msの枠には余裕で収まる。
以降は なぜ HTMLRewriterが勝つのか、どう使うのか、そして予算と格闘するのをやめて有料に切り替えるべきときはいつか、である。
なぜ他の選択肢は収まらないか
turndown + DOMシム
turndownはJavaScriptにおける「HTML to Markdown」の定番ライブラリだ。実戦経験豊富で、きれいな出力を出す。ただしturndownはDOMを必要とする。内部でDOMParserを呼ぶが、workerdにはDOMParserがない。
現実的なシムは@mixmark-io/dominoで、これは純JSのDOM実装であり、圧縮で約240 KBある。turndown本体がさらに約80 KB。つまりWorkers上のturndownは、自分のコードを1行も書く前に約320 KBのバンドルを消費する。これだけで無料プラン予算の約3分の1が既に埋まる計算だ。
代替としてjsdomがあるが、こちらはdominoよりずっと完全な分、約2 MBある。これは 無料プラン予算の2倍 であり、turndown本体を追加する前から破綻している。論外だ。
dominoを使った場合でも、フルページ入力に対する公開ベンチマークから推測すると、34 KBの文書でturndownのCPU時間は15〜30 msあたりに着地する。10 ms予算をすでに超える。シムの解析オーバーヘッドが毎回かかるので、状況は悪化する。
判定:turndownは有料プランで使う。無料プランでは使わない。
Readability + turndown
Mozillaの@mozilla/readabilityはFirefoxのリーダーモードを支える抽出ライブラリだ。「記事本体」をサイト全体のchromeから切り出すのが得意で、turndownと組み合わせれば「任意のWebページから意味のあるMarkdownを取得する」ための定番スタックになる。
ReadabilityもDOMを必要とする。同じシムの話。バンドル:Readability約80 KB+turndown約80 KB+domino約240 KB=約400 KB。CPU:Readabilityがturndownの 前 に自分のDOMウォークを実施するので、合計は「turndown CPU + Readability CPU」、代表的な入力で容易に20〜40 msに達する。
判定:素晴らしいスタックだが、居場所は有料プランだ。
cheerio + 自作エミッター
cheerioは内部でparse5を使う。parse5は仕様準拠のHTMLパーサーで、フルDOMを露出せずに内部ツリーを構築するタイプだ。turndownのDOM依存より軽量で、nodejs_compat_v2のもとで動作する。
Markdownエミッターは自分で書く必要がある。cheerioはjQueryスタイルの走査を提供し、人間工学的には気持ちいいが、バンドル重量を押し上げる。cheerio+parse5で圧縮約100〜150 KB。レイアウトやスタイリングの素振りがない分、CPUは純DOMアプローチよりマシだが、それでも全文書のウォークは発生する。
エミッターを小さく保てば無料プランでも動く余地はある。とはいえHTMLRewriterよりバンドル予算とCPU予算の両方で窮屈になり、得るものは特にない。
判定:ありうる選択肢だが、同じ仕事ならHTMLRewriterのほうが小さくて速い。
node-html-parser
node-html-parserは、まじめに検討する価値のある選択肢のうち最小のものだ。圧縮約40 KB、自己完結、純JS。シンプルなツリーにパースして、あとは自分で歩く。速度も出ていて、公開ベンチマークではcheerioの2〜5倍速いとされる。
何らかの理由でパース後のツリー走査(たとえば文書横断のセレクタクエリ)が必要な場合、無料プランでの現実的な次善策はこれだ。ストリーミング変換で足りるなら、HTMLRewriterがランタイムに組み込まれている分、バンドルではまだ勝つ。
判定:HTMLRewriterのストリーミングモデルが合わないユースケースでの、良いフォールバック。
HTMLRewriterをMarkdownに使う
HTMLRewriterはDOMを渡してくれない。文書がストリームで流れるなかで、start-tag / end-tag / text のイベントを発火する要素ハンドラを提供する。Markdownを生成するには、マッチした要素のそばにMarkdown句読点をテキストとして差し込む。
const rewriter = new HTMLRewriter()
// chromeをまるごと削除。
.on('head, nav, aside, footer, script, style, figure', {
element(el) {
el.remove();
},
})
// 見出しをMarkdownの先頭記号で囲む。
.on('h1', {
element(el) {
el.before('\n\n# ', { html: false });
el.after('\n\n', { html: false });
},
})
.on('h2', {
element(el) {
el.before('\n\n## ', { html: false });
el.after('\n\n', { html: false });
},
})
// リスト:各<li>にバレット接頭辞を追加。
.on('li', {
element(el) {
el.before('\n- ', { html: false });
},
})
// リンク:[text](href)で包む。
.on('a', {
element(el) {
const href = (el.getAttribute('href') || '').replace(/\s+/g, '');
el.before('[', { html: false });
el.after(`](${href})`, { html: false });
},
})
// キャッチオール:特別に扱わなかったタグは落として、中身のテキストだけ残す。
.on('*', {
element(el) {
el.removeAndKeepContent();
},
});
const res = new Response(html, { headers: { 'content-type': 'text/html' } });
const raw = await rewriter.transform(res).text();
リライタが走ったあと、rawはおおむねMarkdown(に空白が潰れたもの)になっている。最低限の後処理でこれを掃除する。
const markdown = raw
.replace(/<!doctype[^>]*>/gi, '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, ' ')
.split('\n')
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
これがコンバーターの全体だ。DOMなし、npmインストールなし、シムも不要。
この方式の既知の制約
HTMLRewriterのセレクタは独立して発火するので、要素間の状態管理はぎこちない。具体的には:
- 順序付きリスト(
<ol>)は1. itemではなく- itemで出てくる。番号を振るには、<ol>でインデックスを追跡するハンドラと、スタックから読む<li>ハンドラが必要で、HTMLRewriterは親コンテキストをくれないので、リライタの外のクロージャでスタックを持つことになる。 <pre>内のインラインコード はバッククォートを落とす。<code>ハンドラからは、親が<pre>か段落かを見分けられないからだ。- 書式タグをまたぐリンクテキスト(例:
<a><em>italic</em></a>)は、*キャッチオールが<em>を剥がすときに強調が失われる。
コンテンツを往復(round-trip)させたい場合、これらは問題だ。「AIエージェントにクリーンなMarkdownを渡す」用途では、どれも問題にならない。エージェントは順序付きリストが普通のリストで来ても許容するし、ネストした強調も気にしない。必要ならセレクタを足してエミッターを拡張すればよい。
自分で計測する
これらの数値を生んだハーネスは、独立した公開リポジトリにある:cf-workers-html-to-markdown-harness。1戦略につき1ルートのWorkerで、計測リグであってライブラリではない。
git clone https://github.com/RickCogley/cf-workers-html-to-markdown-harness
cd cf-workers-html-to-markdown-harness
npm install --ignore-scripts
npm run size # wrangler --dry-runでバンドルサイズ
npm run dev # ローカルworkerdを:8791で起動
# 別ターミナルで:
curl 'http://127.0.0.1:8791/bench?strategy=htmlrewriter&runs=50'
curl 'http://127.0.0.1:8791/output?strategy=htmlrewriter'
戦略を追加する(例:cheerio、node-html-parser、domino-shimつきturndown)のは、src/handlers/配下のハンドラファイル1つと、src/index.tsのマップエントリ1行で済む。ハーネスリポジトリのADD_A_STRATEGY.mdに、turndownの完全な例がある。
筆者が計測していないライブラリを計測し、本記事の記述と矛盾する結果を得た場合は、リポジトリでIssueを開いてほしい。データがそろい次第、記事を更新する。
あきらめて有料に切り替えるべきとき
次のどれかがユースケースに必要なら、無料プランと戦うのをやめよう。
- 往復可能なMarkdown:HTMLに戻したときにオリジナルに近い結果を得たい。turndownを使う。
- 記事抽出:ナビ、サイドバー、コメントを除いた記事本文だけを読者に渡したい。Readabilityを使う。
- HTML表 → Markdown表:HTMLRewriterでは行列のアライメントをきれいに扱えない。turndownか、cheerio+自作エミッターを使う。
- CPU余裕:入力の平均が50 KBを超える、あるいは形状のばらつきが大きい。有料の30 s予算なら、この種のことを考えなくてよくなる。
Workers Paidは月額5ドル+従量課金で、これは重ねて言うがWorkerランタイムのアップグレードであり、Cloudflare Proドメインプランとは別物だ。本格的なコンバーターが本当に必要な用途なら、無料プラン予算と格闘する1日分のエンジニアリングコストより安い。
関連
- SvelteKit + Cloudflare WorkersでAIエージェント向けMarkdownを配信する:「変換せず、すでにあるMarkdownを配信する」姉妹記事
- Cloudflare Workers Hub:筆者のスタック、Cloudflareプラットフォームの最近の変更、関連記事一覧
- Cloudflare PagesからWorkersへの移行ガイド(2026年版)
参考文献
- Cloudflare Workers 制限
- HTMLRewriter ドキュメント
- SAX(Simple API for XML):HTMLRewriterが採用するイベント駆動型パースモデル。DOMとの対比を理解するのに役立つ。
- turndown
- @mozilla/readability
- cheerio
- node-html-parser
- @mixmark-io/domino
Rick Cogley(コグレー・リック)は株式会社イソリアのCEO兼創業者。an

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.