本指南将构建一个 Worker,该 Worker 使用 Browser Run 的 /content 端点 获取单个网页的已渲染 HTML,并使用 Items API 将其上传到 AI Search 实例的内置存储。随后,AI Search 会对该页面进行索引,使其与其他任何已上传文档一样可以被搜索。该 Worker 还公开了一个 /search 端点来查询已索引的页面,因此单个服务既能进行索引又能进行搜索。
当你需要按需索引单个页面或一小组精选页面时,请使用此模式。要爬取并持续索引整个网站,请改用 AI Search 网站数据源 (website data source)。
Browser Run 和 AI Search 实例都是通过绑定 (bindings) 来访问的,因此单个 Worker 就可以抓取页面并进行索引,而无需在中间暴露公共端点。
- 注册 Cloudflare 账户 ↗。
- 安装
Node.js↗。
Node.js 版本管理器
使用 Volta ↗ 或 nvm ↗ 等 Node 版本管理器,以避免权限问题并切换 Node.js 版本。本指南后续将介绍的 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-index向你的 Wrangler 配置文件 中添加两个绑定:一个用于 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-08-17",
"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-08-17"
[browser]
binding = "BROWSER"
remote = true
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = truebrowser 绑定的 quickAction 方法需要 2026-03-24 或更晚的兼容性日期,并且在没有 remote 模式的本地开发中不受支持。在 browser 绑定上设置 remote = true 为 wrangler dev 启用了远程模式。AI Search 绑定上的 remote 选项会将上传代理发送到你已部署的实例,因为 AI Search 不在本地运行。
更新 src/index.ts。此 Worker 具有两个路由:带有 ?url= 参数的请求会抓取该页面的已渲染 HTML 并对其进行索引;对 /search?q= 的请求则会查询已索引的内容。将 my-instance 替换为你实例的名称。
// 索引抓取页面的实例。
const INSTANCE_ID = "my-instance";
// 构建一个以 .html 结尾的稳定 item key,以便 AI Search 在索引前将 HTML 转换为 Markdown。
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);
// 搜索路由:查询已索引的内容并返回匹配的块 (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_ID).search({ query });
return Response.json({
query: results.search_query,
results: results.chunks.map((chunk) => ({
key: chunk.item.key,
score: chunk.score,
text: chunk.text,
})),
});
}
// 索引路由:抓取 URL 的已渲染 HTML 并对其进行索引。
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);
// 使用 Browser Run /content 端点抓取完全渲染的 HTML。
// networkidle2 会等待直至页面在至少 500 ms 内不超过两个网络连接,从而给客户端 JavaScript 留出渲染内容的时间。
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 },
);
}
// /content 端点返回包含 result 字段中已渲染 HTML 的 JSON 包裹。
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;
// 将已渲染的 HTML 上传到内置存储。uploadAndPoll 会等待直至页面被索引且可搜索。
const item = await env.AI_SEARCH.get(INSTANCE_ID).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;
}
// 索引抓取页面的实例。
const INSTANCE_ID = "my-instance";
// 构建一个以 .html 结尾的稳定 item key,以便 AI Search 在索引前将 HTML 转换为 Markdown。
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);
// 搜索路由:查询已索引的内容并返回匹配的块 (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_ID).search({ query });
return Response.json({
query: results.search_query,
results: results.chunks.map((chunk) => ({
key: chunk.item.key,
score: chunk.score,
text: chunk.text,
})),
});
}
// 索引路由:抓取 URL 的已渲染 HTML 并对其进行索引。
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);
// 使用 Browser Run /content 端点抓取完全渲染的 HTML。
// networkidle2 会等待直至页面在至少 500 ms 内不超过两个网络连接,从而给客户端 JavaScript 留出渲染内容的时间。
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 },
);
}
// /content 端点返回包含 result 字段中已渲染 HTML 的 JSON 包裹。
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;
// 将已渲染的 HTML 上传到内置存储。uploadAndPoll 会等待直至页面被索引且可搜索。
const item = await env.AI_SEARCH.get(INSTANCE_ID).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 控制上传,你可以使用结构化元数据 (metadata)(如标题和章节)来丰富每个页面,然后按这些字段过滤搜索。这是内置爬虫自身无法做到的。
首先,在你实例上定义自定义元数据字段。如果你现在正在创建实例,请将其传递给 create:
npx wrangler ai-search create my-instance --type builtin --custom-metadata title:text --custom-metadata section:text要向现有实例添加字段,请在仪表板中的 Settings(设置) 下使用,或者使用 update() 绑定方法。一个实例最多支持五个自定义字段,且每个字段可以是 text、number、boolean 或 datetime 类型。更改 schema 会重新索引现有的文档。
下一步,使用 Browser Run 的 /json 端点 从同一个页面提取这些字段。它运行在相同的 browser 绑定上,并返回符合你提供 schema 的结构化 JSON。在步骤 3 的 fetch 处理函数中,当你拥有已渲染的 html 且在上传之前,添加:
// 使用 /json 端点从页面提取结构化元数据。
// response_format 会将模型限制为你上述定义的字段。
// 将提取视为尽力而为:如果失败,则在没有元数据的情况下索引页面。
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>;
};
// 元数据值必须是字符串,因此对每个值进行强制转换并丢弃空值。
for (const [key, value] of Object.entries(extracted.result ?? {})) {
if (value) metadata[key] = String(value);
}
} catch {
// 忽略提取错误并在没有元数据的情况下索引页面。
}然后在上传选项中传递 metadata:
const item = await env.AI_SEARCH.get(INSTANCE_ID).items.uploadAndPoll(
itemKey(pageUrl),
html,
{ timeoutMs: 60_000, metadata },
);一旦完成索引,你就可以将查询限制在例如给定章节中的页面。有关查询语法,请参阅过滤 (Filtering)。
启动本地开发服务器。由于在 browser 绑定上设置了 remote = true,wrangler dev 会在远程模式下运行 /content 端点:
npx wrangler dev通过传递页面的 URL 来索引该页面:
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