Fastly エッジクラウドプラットフォーム

革新的なデジタルソリューション

ブログに戻る

フォロー&ご登録

AI エージェントが本当に欲しがる Markdown を提供

Jonathan Speek

プラットフォーム・プロダクト・マネージメント部門責任者, Fastly

サイトが AI に対応できているかどうかにかかわらず、AI クローラーはページをリクエストします。今日、AI クローラーは通常のブラウザと同様に HTML を取得し、その中から記事本文を見つけるために、ナビゲーションやフッターを取り除く処理に CPU リソースを費やしています。このブログ記事では、その中間に位置付けられる軽量な Fastly Compute サービスについて解説します。これまでどおり、通常のリクエストにサイトのコンテンツを提供しつつ、AI エージェントには同じコンテンツのクリーンな Markdown バージョンを提供できるようにします。

これは約200行の JavaScript で簡単に実現でき、こちらのリポジトリでコードを確認できます。パイプラインのセクションをざっと見て全体像を把握し、クローンしてデプロイすることで、簡単に実装できます。

AI エージェントの体験が重要な理由

Fastly 独自のセキュリティ調査レポートでは、ボットがリクエストの49%を占めていることが判明しました。その大部分は望ましくないトラフィックであり、検証済み AI は残りのごく一部にすぎませんが、その一部がビジネスに大きな影響をもたらします。GPTBot、PerplexityBot、ChatGPT-User による1回のリクエストは、1人のユーザーを意味しません。貴社のサイトではなく、大規模言語モデルを通じて最終的に貴社のコンテンツを目にする、すべての実際のユーザーを代表しているのです。そのため、AI エージェントに最適なエクスペリエンスを提供するために、ちょっとしたエンジニアリングの細工を行う価値は十分にあります。

これらのクローラーに HTML を配信するのが問題なのは、クローラーがそれを望んでいないためです。LLM の学習パイプラインと検索システムは、テキストデータを対象として動作します。したがって、クローラーがプロダクトドキュメントを取得し、それを使って回答を生成する際、HTML はクローラーに余計な負荷をもたらします。クローラーは HTML を解析し、定型要素やトラッキングピクセル、メニューの装飾要素などのノイズを除去し、プレーンテキストに変換する必要があります。このようなクリーンアップで一部の情報が失われ、特に表やコードブロック、脚注は、後続の要約結果で崩れた状態で表示されることがよくあります。

Markdown では、それらのほとんどを回避できます。既存のパイプラインが最初からネイティブに処理できるためです。しかも Markdown は軽量で、一般的な記事の場合、HTML のサイズの20-30%に圧縮されるため、帯域の使用が減り、構造的な情報よりも、アイデアに対してトークンが消費されます。

ここで問題なのは、オリジンから Markdown を提供するためにすべてを書き換えるのは、ほとんどのチームにとって現実的ではなく、そもそも、誰もそれを望んでいないということです。ブラウザには依然として HTML が必要です。求められているのは、リクエストの処理経路で動作し、処理速度を低下させず、同じ処理で繰り返しコストが発生しないよう効率的にキャッシュできる変換処理です。

Fastly のソリューション

私たちは、オリジンの前段に配置された Fastly Compute 上で動作する軽量な JavaScript サービスを構築し、リクエスト元に応じて3つの処理が行われるようにしました。

  • 通常のブラウザによるリクエストでは、何の変換も行われず、オリジンから HTML が提供されます。

  • AIクローラーの User-Agent (デフォルトで17個を検出します)または Accept: text/markdown を含むリクエストには、同じページの Markdown バージョンが返されます。

  • 明示的な /md/<path> リクエストには、常に Markdown が返されます。これは、デバッグや社内ツール、クローラーに提供されるデータを部分的に確認したいコンテンツチームにとって便利です。

/md/blog/rate-limits へのリクエストに対する出力は以下のようになります。

---
title: "Rate limits — API docs"
description: "How rate limits work, per-tier quotas, and the headers to inspect."
author: "Platform team"
date: "2026-03-02T00:00:00Z"
url: "https://example.com/docs/rate-limits"
source: "https://your-site.edgecompute.app/md/blog/rate-limits"
---

# Rate limits

Every API key is subject to a request budget per minute and per day...

## Quotas by tier

| Tier | Requests / min | Requests / day |
| --- | --- | --- |
| Free | 60 | 10,000 |
| Pro | 600 | 500,000 |
| Enterprise | Custom | Custom |

ヒューリスティックに頼らずに下流のパイプラインで解析できる YAML フロントマター、クリーンな見出し、適切に構造化された Markdown テーブルで構成されています。ナビゲーションやフッター、関連記事、ニュースレターの案内、インラインスクリプトはすべて削除されています。

スタック

以下の4つの要素によってすべての作業が行われます。

  • Fastly Compute はユーザーの近くで WebAssembly として全体のプロセスを実行します。JavaScript SDK(@fastly/js-compute)を使用しています。

  • linkedom は元の HTML を解析して DOM に変換します。これは、Node 固有の多くの動作を取り込む jsdom とは異なり、WASM にクリーンにコンパイルされる、軽量で標準に近い実装です。

  • Defuddle は主要コンテンツを抽出します。これは、Obsidian Web Clipper チームが開発した比較的新しい抽出ツールで、エージェント向けの Markdown 生成に特化しています。既知のパブリッシングサイト向けにサイト別の抽出ロジックを備えているため、サイト固有のクセにも対応できます。コードブロックや脚注を統一された HTML 形式に整え、必要に応じてヒューリスティックスコアリングにフォールバックします。

  • Turndown は抽出された DOM を走査し、Markdown を出力します。テーブルと取り消し線に対応するための GFM プラグインに加え、linkdom 固有の挙動に対処する小さなカスタムルールが1つ追加されています(これについては後述します)。

さらに、エッジキャッシュのために fastly:cache の SimpleCache が使用されています (他の依存関係はありません)。

変換パイプライン

HTML を Markdown に変換するすべての処理は、src/converter.js の単一ファイルに集約されています。

import Defuddle from 'defuddle';
import { parseHTML } from 'linkedom';
import TurndownService from 'turndown';
import { gfm } from '@joplin/turndown-plugin-gfm';

const turndown = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced',
  bulletListMarker: '-',
});
turndown.use(gfm);

export function htmlToMarkdown(html, sourceUrl) {
  const { document } = parseHTML(html);

  const result = new Defuddle(document, { url: sourceUrl }).parse();
  const articleDoc = parseHTML(result?.content || '').document;
  const markdown = turndown.turndown(articleDoc.documentElement).trim();

  if (!markdown) {
    throw new Error('Could not extract readable content from page');
  }

  const frontmatter = buildFrontmatter(result, document, sourceUrl);
  return `${frontmatter}\n\n${markdown}\n`;
}

パイプラインは直線的です。まず linkedom で解析し、Document をDefuddle に渡します。Defuddle に抽出と標準化を任せ、その後、Defuddle が出力した HTML をもう一度 linkedom で解析し、Turndown がたどれる実際の DOM ノードを生成します。2 回目の解析は冗長に感じられますが、重要であり、その理由については後ほど説明します。

buildFrontmatter ヘルパーは、Defuddle のメタデータからタイトル、説明、作成者、公開日を取得し、Defuddle にそれらがない場合は標準の <meta> タグにフォールバックします。また、カノニカル URL も出力されるため、この Markdown を使用する側は元のページを参照できます。

文字列ではなく DOM ノードが必要という落とし穴

Defuddle のドキュメントには markdown: true というオプションがあり、Turndown が担っている処理を実行してくれるように見えます。しかし、これは Node では機能するものの、Compute では機能しません。

Defuddle に組み込まれている Markdown ステップは、turndownService.turndown(htmlString) を呼び出します。Turndown は、文字列が与えられると、内部で document.implementation.createHTMLDocument を呼び出してそれを解析します。Compute の JS ランタイムは SpiderMonkey で、linkdom が DOM を生成しますが、linkedom は document.implementation を公開していません。Turndown が例外をスローしても、Defuddle はその例外を伝番せず、「エラーが発生したため、コンバージョンが部分的に完了しました」というようなフォールバックメッセージと、元の HTML を返します。

Turndown に DOM ノードを渡すと、このパーサーを完全に回避します。そのまま指定されたツリーをたどります。2番目の parseHTML の呼び出しがあるのはそのためです。

テーブル変換ルール

linkedom のもう1つの特殊な挙動:HTMLTableElement.rows が設定されていません。GFM プラグインのテーブル変換ルールは、テーブルを変換するかスキップするかを判断するために node.rows[0] を参照しますが、rows が定義されていないため、すべてのテーブルがフラットなテキストに変換されます。

この問題は、GFM の後に簡単なカスタムルールを登録することで修正できます。

turndown.addRule('linkedom-table', {
  filter: (node) => node.nodeName === 'TABLE',
  replacement: (_content, node) => {
    const rows = Array.from(node.querySelectorAll('tr'));
    if (!rows.length) return '';
    const cells = (tr) =>
      Array.from(tr.querySelectorAll('th, td')).map((c) =>
        c.textContent.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'),
      );
    const header = cells(rows[0]);
    const body = rows.slice(1).map(cells);
    const sep = header.map(() => '---');
    const fmt = (row) => `| ${row.join(' | ')} |`;
    return `\n\n${[fmt(header), fmt(sep), ...body.map(fmt)].join('\n')}\n\n`;
  },
});

querySelectorAll('tr') は、.rows が機能しない所で機能します。カスタムルールは最後に登録されるため、Turndown は GFM のデフォルトよりもそれを優先します。数行の追加コードで、テーブルを含むページも正しく処理できるようになります。

ルーティングとコンテンツネゴシエーション

Compute の fetch ハンドラーは src/index.js に実装されています。ルーティングレイヤー全体は約50行です。

async function handleRequest(event) {
  const req = event.request;
  const url = new URL(req.url);

  if (url.pathname === '/health') return jsonResponse({ status: 'ok' });
  if (url.pathname === '/__html-2-md__') return landingResponse();

  if (url.pathname.startsWith('/md/') || url.pathname === '/md') {
    const originPath = url.pathname.replace(/^\/md/, '') || '/';
    return await convertAndRespond(req, url, originPath);
  }

  const ua = req.headers.get('User-Agent') || '';
  const accept = req.headers.get('Accept') || '';

  if (isAiCrawler(ua) || wantsMarkdown(accept)) {
    return await convertAndRespond(req, url, url.pathname);
  }

  return fetch(req, { backend: 'origin' });
}

処理は順に4つの判断ポイントで行われます。ヘルスとデバッグのルートはローカルで処理されます。/md/<path> プレフィックスによって、ヘッダーに関係なく強制的に Markdown として扱われます。その後、リクエストを確認し、既知の AI クローラーによるアクセスの場合や、Markdown を明示的に要求している場合は、Markdown 形式に変換します。それ以外の場合は、オリジンへそのままパススルーします。

クローラー検出は src/agents.js にある小規模なリストで実装され、主要なクローラーを対象とする17種類のユーザーエージェントパターン(GPTBot、ChatGPT-User、ClaudeBot、anthropic-ai、PerplexityBot、GoogleOther、cohere-ai など)をカバーしています。これは、大文字と小文字を区別しない部分文字列の照合です。エージェントは進化するため、このリストをあくまで出発点として扱い、実際にログに現れる内容に基づいてエージェントを削除または追加してください。

キャッシュ

コールドリクエストでは Markdown 変換に数百ミリ秒かかり、その大半は Defuddle のスコアリングに費やされます。最初のクローラーによるアクセスでは問題ありませんが、100回目ともなると負荷が多くなります。SimpleCache を使えば、これを1行で済ませられます。

const cacheKey = `html-2-md:${originUrl.pathname}${originUrl.search}`;
const cached = SimpleCache.get(cacheKey);

if (cached) {
  body = await cached.text();
} else {
  body = await fetchAndConvert(originUrl, url);
  SimpleCache.set(cacheKey, body, CACHE_TTL); // 5 minutes
}

ほとんどのコンテンツサイトでは、5分が妥当なデフォルト値です。それを基準に、更新頻度に合わせて調整しましょう。キャッシュは POP 単位で管理されるため、各リージョンの最初のリクエストではコールド変換が発生し、その後はキャッシュされたレスポンスが返されます。

また、レスポンスに Vary: Accept、User-Agent が設定されています。これにより、下流のキャッシュ (お客様側やクローラー側の) は、同じコンテンツネゴシエーションに従います。

ローカルでテスト

コンバーターは純粋関数であり、HTML を入力して Markdown を出力します。そのため、Compute ランタイムを必要とせずに、通常の Node 環境で簡単にテストできます。

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { htmlToMarkdown } from '../src/converter.js';

test('docs page: preserves tables and nested lists', async () => {
  const html = await readFile('test/fixtures/docs-page.html', 'utf8');
  const md = htmlToMarkdown(html, 'https://example.com/docs/rate-limits');

  assert.match(md, /# Rate limits/);
  assert.match(md, /\|\s*Tier\s*\|/);  // markdown table header
  assert.match(md, /\|\s*Free\s*\|\s*60\s*\|/);
});

test/fixtures/ に代表的なフィクスチャをいくつか配置し(ブログ記事、テーブルを含むドキュメントページ、定型文を含むニュース記事など)、重要なプロパティについてアサーションを行います。付属のリポジトリには3つ含まれています。npm test は約200ミリ秒で実行されるため、WASMを再ビルドすることなく、抽出処理の特殊な挙動をイテレーションできます。

完全なエッジパイプラインを実行するために、fastly compute serve127.0.0.1:7676 で Viceroy (Fastly のローカル Compute エミュレーター) を起動させます。

curl -s "http://127.0.0.1:7676/" -H "Accept: text/markdown" | head -30
curl -s "http://127.0.0.1:7676/" -H "User-Agent: GPTBot/1.0" | head -30
curl -s "http://127.0.0.1:7676/md/blog/my-post" | head -30
curl -sI "http://127.0.0.1:7676/"   # confirm HTML pass-through

fastly.toml[local_server.backends.origin] をプロキシ先にしたい任意のオリジンに設定すれば、一連の動作をエンドツーエンドで検証できる環境が整います。

デプロイ

他の Compute サービスと同じ2つのコマンドを使用します。

npm run build        # compile to bin/main.wasm
fastly compute deploy

最初の実行時には、サービスの作成と、本番環境用のオリジンバックエンドの設定を行うよう求められます。設定が完了すると、<service>.edgecompute.app で応答する Compute エンドポイントが利用できるようになります。カスタムドメインを指定することも、既存の Fastly サービスをオリジンシールド設定として前段に配置することもできます。トポロジーに適した方法をお選びください。

ネットワーク上での実際の流れ

GPTBot から /blog/my-post へのリクエストの場合

  1. Compute がリクエストを受信する。User-Agent が GPTBot と一致する場合は、Markdown 変換用の処理経路にルーティングする。

  2. SimpleCache で html-2-md:/blog/my-post を確認する。キャッシュミス。

  3. オリジン (fastly.toml で宣言されている origin バックエンド) から HTML を取得する。

  4. linkedomで解析 → Defuddleを実行 → 再解析 → Turndown → frontmatter

  5. 5分間の有効期限 (TTL) で SimpleCache に保存する。レスポンスを返す。

  6. レスポンス: Content-Type: text/markdowncharset=utf-8Vary: AcceptUser-AgentX-Markdown-Tokens: <estimate>

一般的なブラウザが同じ URL に同時にアクセスする場合は、ステップ2が完全にスキップされます。これまでどおり、オリジンから直接 HTML を取得します。

今後の進め方

稼働後、以下などによってさらに発展させることができます。

トークンのカウント : ヒューリスティック (length / 4)は、GPT スタイルのトークン化の大まかな近似です。正確な計算が必要な場合は、実際のトークナイザーに置き換えてください。Compute で動作する WASM 対応の tiktoken ビルドがあります。

リンクの書き換え : 現在の出力ではオリジンからの相対 URL が保持されるため、クローラーはそれらをリクエスト URL を基準に解決する必要があります。Turndown を実行する前に、Defuddle の結果内の相対リンクを絶対リンクに書き換えることができます。

サイトごとの抽出ロジック : Defuddle では、特殊な構造を持つサイト向けに独自の抽出ロジックを定義できます。特定のパブリッシングサイトやドキュメントサイトをプロキシする場合、汎用的なヒューリスティックよりも、専用の抽出ロジックを作成する方が、はるかにクリーンな出力が得られます。

ストリーミング : 非常に長い記事の場合、現在の実装では、レスポンスを返す前に本文全体をバッファリングします。そこで、この変換処理をストリーミング処理すると、最初の1バイトを受信するまでの時間(TTFB)を短縮できます。ただし、Defuddle はスコアリングのためにドキュメント全体を必要とするため、より複雑になりますが、セクション単位でチャンク化すれば実現可能です。

エージェントごとのレート制限: GPTBot のリクエストには応えつつ、よりノイズの多いボットを抑制したい場合は、このサービスに Edge Rate Limiting 機能を組み合わせることをお勧めします。

最後に

AI エージェントに Markdown を提供することは、小さな取り組みのひとつですが、大きな効果をもたらす可能性があります。このソリューションは、エージェントの負荷だけでなく、サイト運営者側の帯域幅 (そして最終的には収益)にも配慮します。リクエストの近くでミリ秒単位の速さで処理が行われ、キャッシュ可能な Compute はこの用途に最適です。求められているのは、リクエストの処理経路で動作し、処理速度を低下させず、同じ処理で繰り返しコストが発生しないよう効率的にキャッシュできる変換処理です。

サービスはこちらから自由にクローンできます。このソリューションをベースにさらに何か役立つ機能(トークンカウンター、独自の抽出ツール、リンクの書き換え機能など)を構築された場合は、ぜひお知らせください。

始める準備はできましたか?

ぜひご連絡ください