Node.jsで分散ウェブクローラーを構築する:キューと重複排除
Senior Web Scraping Engineer
概要:
- 分散型ウェブクローラには、耐久性のあるURLフロンティア、ステートレスワーカー、正規化されたURLキー、ホストごとのリクエスト予算、および明示的なターミナル検疫パスが必要です。
- Node.jsには、発見、スケジューリング、状態、およびストレージの責任を持たせます。ソースが必要とする場合、JavaScriptレンダリングとページ取得を管理された実行レイヤーに委譲します。
- 正規化されたURLハッシュをキューのジョブIDとストレージの冪等性キーの両方として使用します。これにより、ワーカーに到達する前に重複作業が遮断されます。
- グローバルワーカーの同時実行性とホストごとのペーシングは異なる問題を解決します。最初のものは容量でスケールし、2つ目はソースの許可と観測されたサーバーの動作から設定します。
- 小規模な承認されたソースセットから始め、試行したページではなく受け入れられたページを測定し、キューの深さ、新鮮さの遅れ、スキーマの拒否が可視化されてからのみスケールします。
シングルプロセスクローラは予測可能な方法で失敗します:再起動時にそのメモリキューが消失し、重複リンクが増加し、遅いホストがイベントループを占有し、ブラウザ実行が作業をスケジューリングすべきマシンを消費します。
分散型ウェブクローラはこれらの責任を分離します。Node.jsはコントロールプレーン―URLフロンティア、ジョブ状態、重複排除、ストレージの決定を所有します。独立したワーカーはデータパスを所有します。JavaScriptでレンダリングされた公開ページについては、管理されたサービスがページを実行し、MarkdownまたはHTMLを返すことで、各ワーカーコンテナ内にブラウザプロセスを配置する必要がありません。
要件と失敗モードを定義する
キューを選択する前に、クローリング契約を作成します:
- どのドメインとパスが承認されていますか?
- 各クローリングが発見できるページとレベルの数はいくつですか?
- 各ソースにはどの新鮮さのウィンドウが必要ですか?
- どの応答フォーマットと必要なフィールドが受け入れたページを定義しますか?
- ホストごとに許認可されたリクエストのペースはどのくらいですか?
- 無効、空、または予期しない結果はどこに行きますか?
- ジョブおよびページのバージョンはどのくらいの期間保持する必要がありますか?
最初のバージョンには停止条件も含めるべきです:最大深度、最大受け入れページ、最大発見URL、そして締切です。これらの制限は、カレンダーアーカイブ、ファセットナビゲーション、またはトラッキングパラメータが小規模なクローリングを無限のグラフウォークに変えるのを防ぎます。
一般的な失敗モードはアーキテクチャに起因します:
| 失敗 | 根本原因 | コントロール |
|---|---|---|
| フロンティアの喪失 | メモリ内キュー | Redisバックされた耐久性のあるジョブ |
| 重複ページ | 生のURLをキーとして使用 | 正規化されたURLハッシュ |
| 1つのホストが過負荷 | グローバル同時実行性のみ設定 | ホストごとのキューとペース |
| 空のコンテンツが保存される | 輸送成功がデータ成功と見なされる | コンテンツ受け入れ契約 |
| ワーカーがスケールできない | ローカルブラウザ状態 | ステートレスワーカーと管理された実行 |
| 毒ジョブが無限にサイクルする | ターミナル状態がない | 手動リリースのある検疫キュー |
| クローリングが健康そうに見えるが古い | スループットのみ測定 | 新鮮さの遅れと受け入れページのメトリクス |
コントロールプレーンとページ実行を分離する
クローラは2つのプレーンで描画できます:
コントロールプレーン: シード → URL正規化 → 重複排除 → Redisキュー → ジョブ状態 → ストレージメタデータ
実行プレーン: ワーカー → ページ取得 → コンテンツ検証 → リンク抽出 → 受け入れた記録または検疫
この境界は重要です。なぜなら、スケジューリングとブラウザ実行は異なる方法でスケールするからです。キュー操作は小さく、ステートフルです。ページ実行はネットワークに依存し、JavaScript、地域ルーティング、または隔離されたブラウザセッションを必要とする場合があります。
Scrapeless Crawlは、Markdown、HTML、リンク、メタデータ、およびスクリーンショットを含むフォーマットでシングルページ、バッチ、リンクサイトの収集をサポートします。実行パスにインタラクティブなブラウザが必要な場合、Scrapeless Scraping Browserは、そのランタイムをワーカーコンテナの外に保ちます。Nodeアプリケーションは、スコープと状態の記録システムとして残り、Scrapelessが取得を処理できます。
発見と抽出についての概念的な紹介を読むには、ウェブクローラとは何かを参照してください。以下の設計は、シングルマシンクローラが停止するところから始まります:共有キュー、冪等性のあるエンキュー、および複数のワーカー。
RedisバックされたURLフロンティアを構築する
BullMQはRedis上で分散ジョブ実行を実装します。その公式ドキュメントでは、キュー、ワーカー、イベント、遅延ジョブ、レート制限、およびジョブ状態について説明しています。
フロンティアは小さなジョ payloadを保存する必要があります:
| フィールド | 目的 |
|---|---|
url |
収集する正規化されたURL |
host |
キューのパーティションとポリシーのルックアップ |
depth |
発見の境界 |
crawlId |
相関関係とキャンセル |
parentUrl |
発見の由来 |
schemaVersion |
下流契約 |
ページHTMLをRedisに保存しないでください。大きな結果はオブジェクトストアやデータベースに格納し、キューには識別子、状態、コンパクトメタデータのみを保持してください。
異なるドメインが異なるリクエスト予算を必要とする場合、承認されたホストごとに別々のキューを使用してください。docs-example-com に割り当てられたワーカーは1つのリミッターを使用でき、別のホストは独自のペースを得ることができます。グローバルキューはより簡単ですが、そのリミッターは独立したホストポリシーを表現できません。
エンキューを冪等にする
2つのページは、生のURLが異なっていても等価である可能性があります:
- フラグメントは同じ文書の内部の位置を指します。
- トラッキングパラメータが内容を変更することなく変わります。
- クエリパラメータが異なる順序で現れます。
- デフォルトのポートやトレーリングスラッシュが異なります。
- 相対リンクが同じ絶対ページに解決されます。
キューの挿入前に正規化してください。WHATWG URL標準は、Node.js の URL クラスによって実装された解析モデルを定義しています。
安全なポリシーはソース固有です。すべてのクエリパラメータを削除すると、本当に異なるページが統合される可能性があります。既知のパラメータに対してホワイトリストまたはブラックリストを維持し、リソースを変更するパラメータは保持してください。
正規化後、URLをSHA-256でハッシュ化してください。その16進ダイジェストを以下のように使用します:
- BullMQジョブID;
- データベースのupsertキー;
- ジョブと保存されたページの間の由来リンク。
BullMQは、そのキューに同じIDの別のジョブがすでに存在する場合、新しいジョブを無視します。保持によって削除されたジョブレコードは、その保護を提供しなくなりますので、耐久性のあるストレージは同じ冪等性キーを保持しなければなりません。
最小限のバージョン指定されたNode.jsプロジェクト
このプロジェクトは意図的にコンパクトです:
distributed-crawler/
├── package.json
└── src/
└── crawler.mjs
依存関係バージョンは、草案作成時にそのパッケージレジストリに対して確認されました。例は前提条件ギャップブロックです:Node.js、Redis、SCRAPELESS_API_KEY、およびALLOWED_HOSTに設定された承認されたパブリックホスト名が必要です。1つのホスト用に設計されているため、キューリミッターは本当にホスト固有です。
json
{
"name": "distributed-crawler-example",
"private": true,
"type": "module",
"scripts": {
"start": "node src/crawler.mjs"
},
"dependencies": {
"@scrapeless-ai/sdk": "1.3.1",
"bullmq": "5.79.3"
},
"engines": {
"node": ">=22"
}
}
以下のワーカーには、4つの端末的な結果があります:accepted、unchanged、rejected、およびquarantined。自動的な失敗ループは作成しません。オペレーターは隔離記録を確認し、その原因を修正し、カノニカルURLを明示的に再キューに追加できます。
javascript
import { createHash } from "node:crypto";
import { Queue, Worker } from "bullmq";
import { ScrapingCrawl } from "@scrapeless-ai/sdk";
const redis = {
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number(process.env.REDIS_PORT ?? 6379)
};
const allowedHost = process.env.ALLOWED_HOST ?? "example.com";
const queueName = `crawl-${hostKey(allowedHost)}`;
const frontier = new Queue(queueName, { connection: redis });
const quarantine = new Queue(`${queueName}-quarantine`, { connection: redis });
const crawl = new ScrapingCrawl({
apiKey: process.env.SCRAPELESS_API_KEY
});
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function hostKey(host) {
return host.toLowerCase().replaceAll(".", "-");
}
function canonicalize(input) {
const url = new URL(input);
url.hash = "";
url.hostname = url.hostname.toLowerCase();
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
url.searchParams.delete(key);
}
url.searchParams.sort();
return url.href;
}
async function enqueue(url, crawlId, depth = 0, parentUrl = null) {
const canonicalUrl = canonicalize(url);
const parsed = new URL(canonicalUrl);
if (parsed.hostname !== allowedHost) {
throw new Error(`クローリングスコープ外のホスト: ${parsed.hostname}`);
}
const key = sha256(canonicalUrl);
await frontier.add(
"collect-page",
{
url: canonicalUrl,
host: parsed.hostname,
depth,
crawlId,
parentUrl,
schemaVersion: "crawl-page-v1"
},
{
jobId: key,
removeOnComplete: 1000,
removeOnFail: false
}
);
return key;
}
const worker = new Worker(
queueName,
async (job) => {
try {
const result = await crawl.scrapeUrl(job.data.url, {
formats: ["markdown", "links"],
onlyMainContent: true,
timeout: 15000
});
const markdown = result.markdown ?? result.data?.markdown;
if (typeof markdown !== "string" || markdown.length < 200) {
return { state: "rejected", reason: "コンテンツ契約", url: job.data.url };
}
const record = {
key: job.id,
url: job.data.url,
crawlId: job.data.crawlId,
depth: job.data.depth,
ja
contentHash: sha256(markdown),
markdown
};
console.log(JSON.stringify({ state: "accepted", ...record }));
return { state: "accepted", key: record.key, contentHash: record.contentHash };
} catch (error) {
await quarantine.add("inspect-page", {
...job.data,
sourceJobId: job.id,
reason: error instanceof Error ? error.message : "unknown"
});
return { state: "quarantined", key: job.id };
}
},
{
connection: redis,
concurrency: 4,
limiter: { max: 2, duration: 1000 }
}
);
worker.on("completed", (job, result) => {
console.log(JSON.stringify({ event: "completed", jobId: job.id, result }));
});
worker.on("failed", (job, error) => {
console.error(JSON.stringify({
event: "worker-failed",
jobId: job?.id,
message: error.message
}));
});
await enqueue(`https://${allowedHost}/`, "demo-crawl");
この例は、データ契約を可視化するために受け入れられたレコードを印刷します。console.logをrecord.keyでキー付けされたデータベースのアップサートに置き換えます。新しいページバージョンを書き込む前に、contentHashを保存された値と比較します。
タスク状態マシンを理解する
クローラーには、オペレーターが説明できる状態モデルが必要です:
| 状態 | 意味 | 次のアクション |
|---|---|---|
queued |
標準URLが待機中 | ワーカーが請け負う |
active |
1つのワーカーがリースを所有 | 獲得して検証 |
accepted |
コンテンツが契約を通過 | 保存、インデックス化、リンクを発見 |
unchanged |
コンテンツハッシュが保存されたバージョンと一致 | 新鮮さのメタデータを更新 |
rejected |
応答が完了したが、コンテンツが無効 | 検証者またはソースをレビュー |
quarantined |
実行が決定を出せなかった | 手動で検査して解放 |
cancelled |
クローリングの範囲または期限が終了 | 監査メタデータを保持 |
failedはキューによって報告されるインフライベントとして、唯一のビジネス状態としてではなく、それを維持します。空のアプリケーションシェルを返すジョブは技術的には完了していますが、rejectedであるべきです。スコープ外のページは、取得前にcancelledであるべきです。これらの違いは、ダッシュボードをアクション可能にします。
ホストによる同時実行を制御する
ワーカーの同時実行は、「このプロセスが処理できるジョブの数は?」という疑問に答えます。ホスト要求の予算は「このオリジンが受け取ることができるトラフィックの量は?」という疑問に答えます。これらは独立して構成する必要があります。
承認されたドメインの場合:
- ロボットルールと契約制限を読み取る。
- 保守的なホストごとのペースを設定する。
- キューとホストの予算が許す場合にのみ複数のワーカーを実行する。
- 応答のステータス、コンテンツの受け入れ状況、およびサーバーの待機時間を追跡する。
- ソースが苦しんでいる、または契約が変更された場合はホスト予算を減らす。
BullMQワーカーは、プロセスやマシンを超えてキューを共有できます。キューリミッタは、選択されたキューを調整します。これがこの例でホスト固有のキューを使用する理由です。多くのドメインの場合、承認されたレジストリからキューを生成し、アクティブなワーカーオブジェクトの数を制限します。
ロボット排除プロトコルは、RFC 9309によって標準化されています。ロボットルールは許可の付与ではなく、サイトの利用規約、プライバシー義務、または適用される法律を置き換えるものではありません。
コントロールを失うことなくページ取得を委任する
Node.jsコントロールプレーンは、何を収集可能かを決定する必要があります。実行レイヤーは、認可されたページ表現をどのように取得するかを決定する必要があります。
Scrapeless Crawlクイックスタートは、非同期のクローリングステータスとページレベルの結果を文書化しています。広範なインタラクションやJavaScriptの実行を必要とするページには、キューがスコープ、ジョブアイデンティティ、およびストレージの決定を保持している間、Crawlのブラウザオプションを使用してください。
実行者がビジネスの決定を下したと仮定せず、返されたコンテンツを検証します。期待されるテキスト、フィールド、言語、URL、最小コンテンツを要求します。取得ルートを出所に保存し、後の監査で各ページがデータセットにどのように入ったかを説明できるようにします。
スコープを逃すことなくリンクを発見する
リンクの発見は、コンテンツの受け入れ後に属します。コンテンツ契約に合致したページのみを解析し、その後以下のフィルターを適用してキューに入れます:
- ホワイトリストに登録されたホスト名;
- 許可されたパスプレフィックス;
- サポートされるHTTPスキーム;
- 最大深さとページ数;
- 標準化とジョブIDのルックアップ;
- ファイルタイプの除外;
- ソース固有のクエリパラメータポリシー。
リダイレクトが許可されたホストセットを静かに拡大させないようにします。最終URLを記録し、それをスコープと比較し、ソースレジストリが明示的にそれらを承認しない限り、クロスドメインの結果を拒否します。
サイトマップについては、各URLを信頼できる出力ではなく、発見された入力として扱います。同じフロンティアパスを通じて標準化し、フィルターし、重複を排除します。
ページバージョンとクローリングの出所を保存する
役立つストレージモデルには、3つのレコードがあります:
1. **クロール:** スコープ、種URL、締切、ポリシーバージョン、全体の状態。
2. **ページ:** 正規キー、ソースURL、最新の受け入れハッシュ、収集時間、スキーマバージョン。
3. **ページバージョン:** コンテンツハッシュ、ペイロードの場所、メタデータ、取得の出所。
キューは長期データベースではありません。ジョブのクリーンアップポリシーにより、完了したエントリが削除される場合がありますが、ページテーブルはアイデポテントキーとバージョン履歴を保持しなければなりません。
ページが変更されていない場合は、ペイロードを重複させずに新鮮さのタイムスタンプを更新します。変更された場合は、新しい不変のページバージョンを書き込み、ページレコードをそれに指し示し、別のイベントを通じて下流のインデクシングに通知します。
## クロールの観察
キューの長さだけでは誤解を招く可能性があります。クローラーはフロンティアを空にできながら、すべてのページを拒否することができます。以下を追跡します:
| 信号 | 答えられる質問 |
|---|---|
| 1分あたりの受け入れページ数 | 有用なデータは到着していますか? |
| 発見から受け入れまでのラグ | パイプラインはどれほど古くなっていますか? |
| 重複エンキュー率 | 正規化は効果的ですか? |
| 理由別の拒否率 | ソースマークアップや検証が変更されましたか? |
| 検疫の年齢 | 運用の負債が蓄積していますか? |
| ホストのリクエストペース | ポリシーは遵守されていますか? |
| コンテンツ変更率 | リフレッシュスケジュールは適切ですか? |
| キューの年齢パーセンタイル | ワーカーのキャパシティは十分ですか? |
OpenTelemetryは、<a href="https://opentelemetry.io/docs/concepts/signals/" rel="nofollow"><strong>テレメトリシグナルガイド</strong></a>でトレース、メトリクス、ログ、バゲージについて説明しています。プロデューサー、キューイベント、取得コール、ストレージ書き込み、および下流インデックスイベントで同じクロールIDを使用します。
過去の受け入れデータや古い検疫エントリについて警告を出しますが、プロセスのクラッシュだけではありません。プロセスは健全である一方、データセットは静かに変化が止まることがあります。
## デプロイメントチェックリスト
- ワークロードに適した永続性、認証、ネットワーク制御、およびバックアップを伴うRedisを実行します。
- ワーカーをステートレスに保ち、インスタンス間で同じイメージをデプロイします。
- APIキーをシークレットマネージャまたは環境注入に保存し、ジョペイロード内には決して保存しません。
- キューの保持ポリシーをページの保持ポリシーから独立して定義します。
- クロール深度、ページ数、時間、およびホストスコープを制限します。
- プロデューサーとワーカーが共有する一つのホストポリシーソースを使用します。
- デプロイ中にワーカーを排出して、アクティブなリースが放棄されないようにします。
- キャンセル、検疫解除、ストレージのアイデポテント性、およびソースポリシーの変更をテストします。
- Node.js、BullMQ、Scrapeless SDK、およびページスキーマのバージョンを記録します。
まず一つの承認されたホストと小さなページ制限から始めます。ダッシュボードが受け入れたデータ、新鮮さ、リクエストポリシーが目標内にあることを示すまで、ドメインを追加しません。
## 結論: 不確実性ではなくフロンティアを拡大する
分散型ウェブクローラーは、すべてのURLに1つの正規のアイデンティティがあり、すべてのホストに明確な予算があり、すべてのページが意味のある状態で終わるときに信頼できるものになります。Node.jsとBullMQはフロンティアおよびワーカーの調整を担当し、Scrapelessは管理されたレンダリングとネットワーク処理が必要な場合にページ実行を担当できます。
一つの公的で承認されたドメインを持つ限定された概念実証を作成し、[Scrapelessの価格ページ](https://www.scrapeless.com/ja/pricing?utm_source=website&utm_medium=blog&utm_campaign=crawl&utm_term=distributed-web-crawler-nodejs)を確認した後、[Scrapelessアカウントを作成](https://app.scrapeless.com/passport/login?utm_source=website&utm_medium=blog&utm_campaign=crawl&utm_term=distributed-web-crawler-nodejs)します。取得をクロールを通じてルーティングし、受け入れたページと新鮮さを測定した後にワーカー数を増やします。
## よくある質問
### ウェブクローラーを分散型にする要因は何ですか?
そのURLフロンティアとジョブ状態が複数のワーカープロセスまたはマシンで共有されていること。ワーカーは独立したジョブを請け負い、結果を共有ストレージに書き込むことができ、一つのプロセスのメモリに依存せずにスケールできます。
### Node.jsクローラーにBullMQを使用する理由は?
BullMQはRedisバックのキュー、分散ワーカー、同時実行制御、イベント、ジョブ識別子を提供します。クローラーは依然として独自のURLポリシー、コンテンツ契約、耐久性のあるストレージ、および可観測性が必要です。
### URLの重複排除はワーカー間でどのように機能しますか?
エンキューする前にURLを正規化し、正規の形式をハッシュ化し、ダイジェストをキュージョブIDおよびストレージキーとして使用します。Redisがジョブの作成を調整し、データベースはキューの保持が古いジョブを削除した後もアイデポテント性を保ちます。
### すべてのワーカーが独自のブラウザを起動するべきですか?
必ずしも必要ではありません。ローカルブラウザはコンテナのサイズ、メモリ使用量、および運用作業を増加させます。管理された取得レイヤーはレンダリングされたコンテンツを返すことができ、ワーカーはスケジューリング、検証、発見、およびストレージに集中できます。
### 失敗したページジョブはどのように処理するべきですか?
ビジネスの成果をインフラのイベントから分離します。無効なコンテンツは拒否できます。不明な実行エラーは検査と明示的なリリースのために検疫キューに入れることができます。無限の自動ループを避けます。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



