このガイドでは、Browser Run の /content エンドポイント で単一 Web ページのレンダリング済み HTML を取得し、Items API で AI Search インスタンスの 組み込みストレージ にアップロードする Worker を作ります。AI Search は、ほかのアップロード済みドキュメントと同じようにページをインデックスし、検索可能にします。Worker はインデックス済みページを照会する /search エンドポイントも公開します。1 つのサービスでインデックスと検索の両方を行います。
1 ページ、または少数の手作業で選んだページをオンデマンドでインデックスする場合に、このパターンを使います。サイト全体をクロールして継続的にインデックスするには、代わりに AI Search の ウェブサイトデータソース を使います。
Browser Run と AI Search インスタンスはどちらもバインディング経由で到達するため、間に公開エンドポイントを置かずに、1 つの Worker でページの取得とインデックスができます。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
アップロード先の AI Search インスタンスも必要です。作成方法は はじめに を参照してください。このガイドはインスタンスの組み込みストレージにアップロードするため、外部データソースは不要です。
create-cloudflare CLI(C3)で新しい Worker プロジェクトを作成します。C3 ↗ は、Cloudflare 向けの新しいアプリケーションのセットアップとデプロイを支援するコマンドラインツールです。
次を実行し、fetch-and-index という名前の新しいプロジェクトを作成します。
npm create cloudflare@latest -- fetch-and-indexyarn create cloudflare fetch-and-indexpnpm create cloudflare@latest fetch-and-indexセットアップでは、次のオプションを選びます。
- What would you like to start with? では、
Hello World exampleを選びます。 - Which template would you like to use? では、
Worker onlyを選びます。 - Which language do you want to use? では、
TypeScriptを選びます。 - Do you want to use git for version control? では、
Yesを選びます。 - Do you want to deploy your application? では、
Noを選びます(デプロイ前にいくつか変更します)。
アプリケーションディレクトリに移動します。
cd fetch-and-indexWrangler 設定ファイル に 2 つのバインディングを追加します。Browser Run 用の browser バインディング と、アップロード用の AI Search 名前空間バインディング です。/content エンドポイントは browser バインディング経由で実行されるため、Puppeteer などのパッケージをインストールする必要はありません。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "fetch-and-index",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"browser": {
"binding": "BROWSER",
"remote": true
},
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default",
"remote": true
}
]
}name = "fetch-and-index"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
[browser]
binding = "BROWSER"
remote = true
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = truebrowser バインディングの quickAction メソッドには、互換性日付 2026-03-24 以降が必要です。リモートモードなしのローカル開発には対応していません。browser バインディングで remote = true を設定すると、wrangler dev のリモートモードが有効になります。AI Search バインディングの remote オプションは、デプロイ済みインスタンスへのアップロードをプロキシします。AI Search はローカルでは動作しないためです。
src/index.ts を更新します。この Worker には 2 つのルートがあります。?url= パラメーター付きのリクエストはそのページのレンダリング済み HTML を取得してインデックスし、/search?q= へのリクエストはインデックス済みコンテンツを照会します。my-instance をインスタンス名に置き換えます。
// The instance that indexes the fetched page.
const INSTANCE_NAME = "my-instance";
// Build a stable item key that ends in .html, so AI Search converts the HTML
// to Markdown before indexing it.
function itemKey(pageUrl) {
const slug = `${pageUrl.hostname}${pageUrl.pathname}`
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return `${slug || "index"}.html`;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Search route: query the indexed content and return the matching chunks.
if (url.pathname === "/search") {
const query = url.searchParams.get("q");
if (!query) {
return new Response("Add a ?q= query parameter", { status: 400 });
}
const results = await env.AI_SEARCH.get(INSTANCE_NAME).search({ query });
return Response.json({
query: results.search_query,
results: results.chunks.map((chunk) => ({
key: chunk.item.key,
score: chunk.score,
text: chunk.text,
})),
});
}
// Index route: fetch a URL's rendered HTML and index it.
const target = url.searchParams.get("url");
if (!target) {
return new Response(
"Add a ?url= parameter to index a page, or use /search?q= to search",
{ status: 400 },
);
}
const pageUrl = new URL(target);
// Fetch the fully rendered HTML with the Browser Run /content endpoint.
// networkidle2 waits until the page has no more than two network
// connections for at least 500 ms, giving client-side JavaScript time
// to render the content.
const response = await env.BROWSER.quickAction("content", {
url: pageUrl.toString(),
gotoOptions: {
waitUntil: "networkidle2",
timeout: 30000,
},
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 500);
return new Response(
`Browser Run failed with ${response.status}: ${detail}`,
{ status: 502 },
);
}
// The /content endpoint returns a JSON envelope with the rendered HTML
// in the result field.
const data = await response.json();
if (!data.success || typeof data.result !== "string") {
return new Response("Browser Run returned an unsuccessful response", {
status: 502,
});
}
const html = data.result;
// Upload the rendered HTML to built-in storage. uploadAndPoll waits until
// the page is indexed and searchable.
const item = await env.AI_SEARCH.get(INSTANCE_NAME).items.uploadAndPoll(
itemKey(pageUrl),
html,
{ timeoutMs: 60_000 },
);
return Response.json({ key: item.key, status: item.status });
},
};export interface Env {
BROWSER: BrowserRun;
AI_SEARCH: AiSearchNamespace;
}
// The instance that indexes the fetched page.
const INSTANCE_NAME = "my-instance";
// Build a stable item key that ends in .html, so AI Search converts the HTML
// to Markdown before indexing it.
function itemKey(pageUrl: URL): string {
const slug = `${pageUrl.hostname}${pageUrl.pathname}`
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return `${slug || "index"}.html`;
}
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
// Search route: query the indexed content and return the matching chunks.
if (url.pathname === "/search") {
const query = url.searchParams.get("q");
if (!query) {
return new Response("Add a ?q= query parameter", { status: 400 });
}
const results = await env.AI_SEARCH.get(INSTANCE_NAME).search({ query });
return Response.json({
query: results.search_query,
results: results.chunks.map((chunk) => ({
key: chunk.item.key,
score: chunk.score,
text: chunk.text,
})),
});
}
// Index route: fetch a URL's rendered HTML and index it.
const target = url.searchParams.get("url");
if (!target) {
return new Response(
"Add a ?url= parameter to index a page, or use /search?q= to search",
{ status: 400 },
);
}
const pageUrl = new URL(target);
// Fetch the fully rendered HTML with the Browser Run /content endpoint.
// networkidle2 waits until the page has no more than two network
// connections for at least 500 ms, giving client-side JavaScript time
// to render the content.
const response = await env.BROWSER.quickAction("content", {
url: pageUrl.toString(),
gotoOptions: {
waitUntil: "networkidle2",
timeout: 30000,
},
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 500);
return new Response(
`Browser Run failed with ${response.status}: ${detail}`,
{ status: 502 },
);
}
// The /content endpoint returns a JSON envelope with the rendered HTML
// in the result field.
const data = (await response.json()) as {
success: boolean;
result?: string;
};
if (!data.success || typeof data.result !== "string") {
return new Response("Browser Run returned an unsuccessful response", {
status: 502,
});
}
const html = data.result;
// Upload the rendered HTML to built-in storage. uploadAndPoll waits until
// the page is indexed and searchable.
const item = await env.AI_SEARCH.get(INSTANCE_NAME).items.uploadAndPoll(
itemKey(pageUrl),
html,
{ timeoutMs: 60_000 },
);
return Response.json({ key: item.key, status: item.status });
},
} satisfies ExportedHandler<Env>;.html のアイテムキーにより、AI Search はコンテンツを Markdown 変換 に通します。インデックス前にヘッダーやフッターなどのボイラープレートを取り除きます。
この手順は任意です。この Worker はアップロードを制御するため、各ページにタイトルやセクションなどの構造化 メタデータ を付け、それらのフィールドで 検索をフィルター できます。組み込みクローラーだけではできないことです。
まず、インスタンスにカスタムメタデータフィールドを定義します。インスタンスをこれから作成する場合は、create に渡します。
npx wrangler ai-search create my-instance --type builtin --custom-metadata title:text --custom-metadata section:text既存インスタンスにフィールドを追加するには、ダッシュボードの Settings、または update() バインディングメソッドを使います。インスタンスは最大 5 つのカスタムフィールドに対応し、各フィールドは text、number、boolean、datetime 型のいずれかです。スキーマを変更すると、既存ドキュメントが再インデックスされます。
次に、Browser Run の /json エンドポイント で、同じページからそれらのフィールドを抽出します。同じ browser バインディング経由で実行され、指定したスキーマに合う構造化 JSON を返します。手順 3 の fetch ハンドラーで、レンダリング済みの html を得たあと、アップロードの前に次を追加します。
// Extract structured metadata from the page with the /json endpoint.
// response_format constrains the model to the fields you defined above.
// Treat extraction as best-effort: if it fails, index the page without metadata.
const metadata: Record<string, string> = {};
try {
const jsonResponse = await env.BROWSER.quickAction("json", {
url: pageUrl.toString(),
prompt: "Extract the page title and its top-level section.",
response_format: {
type: "json_schema",
json_schema: {
type: "object",
properties: {
title: { type: "string" },
section: { type: "string" },
},
required: ["title"],
},
},
});
const extracted = (await jsonResponse.json()) as {
result?: Record<string, unknown>;
};
// Metadata values must be strings, so coerce each value and drop empty ones.
for (const [key, value] of Object.entries(extracted.result ?? {})) {
if (value) metadata[key] = String(value);
}
} catch {
// Ignore extraction errors and index the page without metadata.
}次に、アップロードオプションで metadata を渡します。
const item = await env.AI_SEARCH.get(INSTANCE_NAME).items.uploadAndPoll(
itemKey(pageUrl),
html,
{ timeoutMs: 60_000, metadata },
);インデックス後は、たとえば特定セクションのページにクエリを制限できます。クエリ構文は フィルタリング を参照してください。
ローカル開発サーバーを起動します。browser バインディングで remote = true が設定されているため、wrangler dev は /content エンドポイントをリモートモードで実行します。
npx wrangler devURL を渡してページをインデックスします。
curl "http://localhost:8787/?url=https://example.com/"応答にはアイテムキーとステータス(インデックス完了後は completed)が含まれます。
{ "key": "example-com.html", "status": "completed" }同じ Worker の /search エンドポイントで、インデックス済みコンテンツを照会します。
curl "http://localhost:8787/search?q=what+is+this+domain+for"{
"query": "what is this domain for",
"results": [
{
"key": "example-com.html",
"score": 0.75,
"text": "# Example Domain\nThis domain is for use in documentation examples..."
}
]
}Cloudflare アカウントでログインし、Worker をデプロイしてインターネットからアクセスできるようにします。
npx wrangler login
npx wrangler deploy