このチュートリアルでは、Queues、Browser Run、Puppeteer を使った Web クローラーの構築とデプロイを説明します。
Puppeteer は、Chrome / Chromium ブラウザーの操作を自動化する高レベルライブラリです。送信された各ページで、クローラーは cloudflare.com へのリンク数を数え、サイトのスクリーンショットを撮影し、結果を Workers KV に保存します。
Puppeteer を使って、ページ上のすべての画像を取得したり、サイトで使われている色を保存したりもできます。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
まず、create-cloudflare CLI ↗ で Worker アプリケーションを作成します。ターミナルを開き、次のコマンドを実行します。
npm create cloudflare@latest -- queues-web-crawleryarn create cloudflare queues-web-crawlerpnpm create cloudflare@latest queues-web-crawlerセットアップでは、次のオプションを選びます。
- 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 queues-web-crawlerKV ストアを作成します。Cloudflare ダッシュボードまたは Wrangler CLI で作成できます。このチュートリアルでは Wrangler CLI を使います。
npx wrangler kv namespace create crawler_linksyarn wrangler kv namespace create crawler_linkspnpm wrangler kv namespace create crawler_linksnpx wrangler kv namespace create crawler_screenshotsyarn wrangler kv namespace create crawler_screenshotspnpm wrangler kv namespace create crawler_screenshots🌀 Creating namespace with title "web-crawler-crawler-links"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "crawler_links"
id = "<GENERATED_NAMESPACE_ID>"
🌀 Creating namespace with title "web-crawler-crawler-screenshots"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "crawler_screenshots"
id = "<GENERATED_NAMESPACE_ID>"Wrangler 設定ファイル に KV バインドを追加する
Wrangler ファイルに、ターミナルで生成された値を使って次を追加します。
{
"kv_namespaces": [
{
"binding": "CRAWLER_SCREENSHOTS_KV",
"id": "<GENERATED_NAMESPACE_ID>",
},
{
"binding": "CRAWLER_LINKS_KV",
"id": "<GENERATED_NAMESPACE_ID>",
},
],
}[[kv_namespaces]]
binding = "CRAWLER_SCREENSHOTS_KV"
id = "<GENERATED_NAMESPACE_ID>"
[[kv_namespaces]]
binding = "CRAWLER_LINKS_KV"
id = "<GENERATED_NAMESPACE_ID>"次に、Worker を Browser Run 向けに設定します。
現在のディレクトリで、Cloudflare の Puppeteer フォーク と robots-parser ↗ をインストールします。
npm i -D @cloudflare/puppeteeryarn add -D @cloudflare/puppeteerpnpm add -D @cloudflare/puppeteerbun add -d @cloudflare/puppeteernpm i robots-parseryarn add robots-parserpnpm add robots-parserbun add robots-parser続けて、Browser Run バインドを追加します。Browser Run バインドを追加すると、Worker から Puppeteer で操作するヘッドレス Chromium インスタンスにアクセスできます。
{
"browser": {
"binding": "CRAWLER_BROWSER",
},
}[browser]
binding = "CRAWLER_BROWSER"次に、Queue を設定します。
npx wrangler queues create queues-web-crawleryarn wrangler queues create queues-web-crawlerpnpm wrangler queues create queues-web-crawlerCreating queue queues-web-crawler.
Created queue queues-web-crawler.Wrangler ファイルに次を追加します。
{
"queues": {
"consumers": [
{
"queue": "queues-web-crawler",
"max_batch_timeout": 60,
},
],
"producers": [
{
"queue": "queues-web-crawler",
"binding": "CRAWLER_QUEUE",
},
],
},
}[[queues.consumers]]
queue = "queues-web-crawler"
max_batch_timeout = 60
[[queues.producers]]
queue = "queues-web-crawler"
binding = "CRAWLER_QUEUE"コンシューマーキューに max_batch_timeout の 60 秒を設定することが重要です。Queue がより長い時間をかけてメッセージをバッチにまとめられます。これにより Browser Run の レート制限 を管理しやすくなり、1 つのブラウザーインスタンスで複数 URL をまとめて処理できます。
最終的な Wrangler ファイルは、次のようになります。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "web-crawler",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"compatibility_flags": ["nodejs_compat"],
"kv_namespaces": [
{
"binding": "CRAWLER_SCREENSHOTS_KV",
"id": "<GENERATED_NAMESPACE_ID>",
},
{
"binding": "CRAWLER_LINKS_KV",
"id": "<GENERATED_NAMESPACE_ID>",
},
],
"browser": {
"binding": "CRAWLER_BROWSER",
},
"queues": {
"consumers": [
{
"queue": "queues-web-crawler",
"max_batch_timeout": 60,
},
],
"producers": [
{
"queue": "queues-web-crawler",
"binding": "CRAWLER_QUEUE",
},
],
},
}"$schema" = "./node_modules/wrangler/config-schema.json"
name = "web-crawler"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]
[[kv_namespaces]]
binding = "CRAWLER_SCREENSHOTS_KV"
id = "<GENERATED_NAMESPACE_ID>"
[[kv_namespaces]]
binding = "CRAWLER_LINKS_KV"
id = "<GENERATED_NAMESPACE_ID>"
[browser]
binding = "CRAWLER_BROWSER"
[[queues.consumers]]
queue = "queues-web-crawler"
max_batch_timeout = 60
[[queues.producers]]
queue = "queues-web-crawler"
binding = "CRAWLER_QUEUE"TypeScript がバインドを正しく型付けできるよう、src/index.ts の環境インターフェイスにバインドを追加します。キューの型は Queue<Message> です。Message は次の手順で定義します。
import type { BrowserWorker } from "@cloudflare/puppeteer";
export interface Env {
CRAWLER_QUEUE: Queue<Message>;
CRAWLER_SCREENSHOTS_KV: KVNamespace;
CRAWLER_LINKS_KV: KVNamespace;
CRAWLER_BROWSER: BrowserWorker;
}クロールするリンクを送信する fetch() ハンドラーを Worker に追加します。
type Message = {
url: string;
};
export interface Env {
CRAWLER_QUEUE: Queue<Message>;
// ... etc.
}
export default {
async fetch(req, env, ctx): Promise<Response> {
await env.CRAWLER_QUEUE.send({ url: await req.text() });
return new Response("Success!");
},
} satisfies ExportedHandler<Env>;任意のサブパスへのリクエストを受け付け、リクエスト本文をクロール対象として転送します。リクエスト本文には URL だけが入っている想定です。本番では、リクエストが POST であることと、本文に正しい形式の URL があることを確認してください。ここでは簡潔さのため省略しています。
送信したリンクを処理する queue() ハンドラーを Worker に追加します。
import puppeteer from "@cloudflare/puppeteer";
import robotsParser from "robots-parser";
async queue(batch, env, ctx): Promise<void> {
let browser: puppeteer.Browser | null = null;
try {
browser = await puppeteer.launch(env.CRAWLER_BROWSER);
} catch {
batch.retryAll();
return;
}
for (const message of batch.messages) {
const { url } = message.body;
let isAllowed = true;
try {
const robotsTextPath = new URL(url).origin + "/robots.txt";
const response = await fetch(robotsTextPath);
const robots = robotsParser(robotsTextPath, await response.text());
isAllowed = robots.isAllowed(url) ?? true; // respect robots.txt!
} catch {}
if (!isAllowed) {
message.ack();
continue;
}
// TODO: crawl!
message.ack();
}
await browser.close();
},これはクローラーの骨格です。Puppeteer ブラウザーを起動し、Queue が受け取ったメッセージを順に処理します。サイトの robots.txt を取得し、robots-parser でクロールが許可されているかを確認します。許可されていない場合はメッセージを ack し、Queue から取り除きます。許可されている場合は、サイトのクロールを続けられます。
puppeteer.launch() は try...catch で囲み、ブラウザー起動に失敗した場合はバッチ全体を再試行できるようにしています。アカウントあたりのブラウザー数上限を超えると、起動に失敗することがあります。
type Result = {
numCloudflareLinks: number;
screenshot: ArrayBuffer;
};
const crawlPage = async (url: string): Promise<Result> => {
const page = await (browser as puppeteer.Browser).newPage();
await page.goto(url, {
waitUntil: "load",
});
const numCloudflareLinks = await page.$$eval("a", (links) => {
links = links.filter((link) => {
try {
return new URL(link.href).hostname.includes("cloudflare.com");
} catch {
return false;
}
});
return links.length;
});
await page.setViewport({
width: 1920,
height: 1080,
deviceScaleFactor: 1,
});
return {
numCloudflareLinks,
screenshot: ((await page.screenshot({ fullPage: true })) as Buffer).buffer,
};
};このヘルパー関数は、Puppeteer で新しいページを開き、指定した URL に移動します。numCloudflareLinks は Puppeteer の $$eval(document.querySelectorAll に相当)で、cloudflare.com ページへのリンク数を数えます。リンクの href が cloudflare.com かどうかの判定は try...catch で囲み、href が URL でない場合に備えます。
続けて、ブラウザーのビューポートサイズを設定し、ページ全体のスクリーンショットを撮影します。スクリーンショットは Buffer として返し、ArrayBuffer に変換して KV に書き込めます。
リンクを再帰的にクロールするには、Cloudflare リンク数を確認したあとに、キューコンシューマーから同じキューへメッセージを再帰送信するスニペットを追加します。クロールのように再帰が深すぎると、Durable Object の Subrequest depth limit exceeded. エラーになります。発生した場合は捕捉しますが、リンクは再試行しません。
// const numCloudflareLinks = await page.$$eval("a", (links) => { ...
await page.$$eval("a", async (links) => {
const urls: MessageSendRequest<Message>[] = links.map((link) => {
return {
body: {
url: link.href,
},
};
});
try {
await env.CRAWLER_QUEUE.sendBatch(urls);
} catch {} // do nothing, likely hit subrequest limit
});
// await page.setViewport({ ...queue ハンドラーで、URL に対して crawlPage を呼び出します。
// in the `queue` handler:
// ...
if (!isAllowed) {
message.ack();
continue;
}
try {
const { numCloudflareLinks, screenshot } = await crawlPage(url);
const timestamp = new Date().getTime();
const resultKey = `${encodeURIComponent(url)}-${timestamp}`;
await env.CRAWLER_LINKS_KV.put(resultKey, numCloudflareLinks.toString(), {
metadata: { date: timestamp },
});
await env.CRAWLER_SCREENSHOTS_KV.put(resultKey, screenshot, {
metadata: { date: timestamp },
});
message.ack();
} catch {
message.retry();
}
// ...このスニペットは、crawlPage の結果を適切な KV 名前空間に保存します。想定外のエラーが起きた場合は、URL を再試行し、キューへ再送します。
クロール時刻を KV に保存すると、過度な頻度のクロールを避けやすくなります。
robots.txt を確認する前に、直近 1 時間以内のクロールが KV にあるかを確認するスニペットを追加します。同じ URL(同じページのクロール)で始まる KV キーを一覧し、直近 1 時間以内のクロールがあるかを調べます。ある場合はメッセージを ack し、再試行しません。
type KeyMetadata = {
date: number;
};
// in the `queue` handler:
// ...
for (const message of batch.messages) {
const sameUrlCrawls = await env.CRAWLER_LINKS_KV.list({
prefix: `${encodeURIComponent(url)}`,
});
let shouldSkip = false;
for (const key of sameUrlCrawls.keys) {
if (timestamp - (key.metadata as KeyMetadata)?.date < 60 * 60 * 1000) {
// if crawled in last hour, skip
message.ack();
shouldSkip = true;
break;
}
}
if (shouldSkip) {
continue;
}
let isAllowed = true;
// ...最終的なスクリプトは次のとおりです。
import puppeteer, { BrowserWorker } from "@cloudflare/puppeteer";
import robotsParser from "robots-parser";
type Message = {
url: string;
};
export interface Env {
CRAWLER_QUEUE: Queue<Message>;
CRAWLER_SCREENSHOTS_KV: KVNamespace;
CRAWLER_LINKS_KV: KVNamespace;
CRAWLER_BROWSER: BrowserWorker;
}
type Result = {
numCloudflareLinks: number;
screenshot: ArrayBuffer;
};
type KeyMetadata = {
date: number;
};
export default {
async fetch(req, env, ctx): Promise<Response> {
// util endpoint for testing purposes
await env.CRAWLER_QUEUE.send({ url: await req.text() });
return new Response("Success!");
},
async queue(batch, env, ctx): Promise<void> {
const crawlPage = async (url: string): Promise<Result> => {
const page = await (browser as puppeteer.Browser).newPage();
await page.goto(url, {
waitUntil: "load",
});
const numCloudflareLinks = await page.$$eval("a", (links) => {
links = links.filter((link) => {
try {
return new URL(link.href).hostname.includes("cloudflare.com");
} catch {
return false;
}
});
return links.length;
});
// to crawl recursively - uncomment this!
/*await page.$$eval("a", async (links) => {
const urls: MessageSendRequest<Message>[] = links.map((link) => {
return {
body: {
url: link.href,
},
};
});
try {
await env.CRAWLER_QUEUE.sendBatch(urls);
} catch {} // do nothing, might've hit subrequest limit
});*/
await page.setViewport({
width: 1920,
height: 1080,
deviceScaleFactor: 1,
});
return {
numCloudflareLinks,
screenshot: ((await page.screenshot({ fullPage: true })) as Buffer)
.buffer,
};
};
let browser: puppeteer.Browser | null = null;
try {
browser = await puppeteer.launch(env.CRAWLER_BROWSER);
} catch {
batch.retryAll();
return;
}
for (const message of batch.messages) {
const { url } = message.body;
const timestamp = new Date().getTime();
const resultKey = `${encodeURIComponent(url)}-${timestamp}`;
const sameUrlCrawls = await env.CRAWLER_LINKS_KV.list({
prefix: `${encodeURIComponent(url)}`,
});
let shouldSkip = false;
for (const key of sameUrlCrawls.keys) {
if (timestamp - (key.metadata as KeyMetadata)?.date < 60 * 60 * 1000) {
// if crawled in last hour, skip
message.ack();
shouldSkip = true;
break;
}
}
if (shouldSkip) {
continue;
}
let isAllowed = true;
try {
const robotsTextPath = new URL(url).origin + "/robots.txt";
const response = await fetch(robotsTextPath);
const robots = robotsParser(robotsTextPath, await response.text());
isAllowed = robots.isAllowed(url) ?? true; // respect robots.txt!
} catch {}
if (!isAllowed) {
message.ack();
continue;
}
try {
const { numCloudflareLinks, screenshot } = await crawlPage(url);
await env.CRAWLER_LINKS_KV.put(
resultKey,
numCloudflareLinks.toString(),
{ metadata: { date: timestamp } },
);
await env.CRAWLER_SCREENSHOTS_KV.put(resultKey, screenshot, {
metadata: { date: timestamp },
});
message.ack();
} catch {
message.retry();
}
}
await browser.close();
},
} satisfies ExportedHandler<Env, Message>;Worker をデプロイするには、次のコマンドを実行します。
npx wrangler deployyarn wrangler deploypnpm wrangler deployクロール対象の URL をキューへ送信し、結果を Workers KV に保存する Worker を作成できました。
Worker をテストするには、次の cURL リクエストでこのドキュメントページのスクリーンショットを撮影できます。
curl <YOUR_WORKER_URL> \
-H "Content-Type: application/json" \
-d 'https://developers.cloudflare.com/queues/tutorials/web-crawler-with-browser-run/'URL の送信とクローラー結果の表示用に Pages でデプロイしたフロントエンドを含む、完全なチュートリアルは GitHub リポジトリ ↗ を参照してください。