Node.jsでWebスクレイピング:fetch+cheerioで取得し、Playwrightで動的ページに対応する
Node.jsでのWebスクレイピング実務。fetchとcheerioで取得・解析し、動的ページはPlaywright。robots.txt確認とマナー、ブロックや構造変化への備えまで。
「価格ページ、毎朝目視で確認してます」
知り合いの話です。競合の公開価格を、毎朝ブラウザで開いて、表をExcelに手で写していた。15分。週末以外、毎日。「自動化したいんだけど、スクレイピングって怖くて」と。
その気持ち、わかります。僕も最初のスクレイパーは事故だらけでした。1秒間に何十回もアクセスして、相手から見たら攻撃みたいなコードを書いて、しかも翌週にはサイトのデザインが変わって全部壊れた。データは取れたけど、どのURLからいつ取ったのか記録が無くて、間違いを見つけても追えない。
Webスクレイピングは「ページから情報を機械的に読むこと」です。やること自体は単純で、Node.jsなら30分で動くものが書けます。難しいのはコードじゃなくて、どこまでやっていいかの線引きと、壊れたときに気づける仕組みのほう。今日はそこを含めて、取得から保存までを通しで書きます。
この記事の要点
- Webスクレイピングは「取得 → 解析 → 整形・保存」の3段。取得は静的なら
fetch、動的ページだけPlaywright、解析はcheerioで十分。 - やる前に、公式API・RSS・サイトマップを探す。それが無いときだけHTMLを読む。
- robots.txtと利用規約は必ず確認する。法的な許可とは別物だが、尊重すべき境界。守らないと最悪アクセス遮断や法的トラブルになる。
- マナーの基本は「少量・低頻度・間隔を空ける・連絡先入りUser-Agent・失敗したら止まる」。100ページを一瞬で叩かない。
- 保存する全行に
sourceUrlとfetchedAtを入れる。後で人間が確認できない表は使えない。
まず線引き:スクレイピングは最後の手段
いきなりHTMLを読みに行く前に、僕は必ず4つ探します。公式API・RSS・サイトマップ・CSVエクスポートです。
APIがあるなら、それが正解です。構造が安定していて、利用条件もはっきりしている。HTMLは見た目都合でいつ変わるか分からないけど、APIのレスポンスは契約だから簡単には壊れません。RSSは更新情報を取るのに向いているし、サイトマップ(/sitemap.xml)はサイト内のURL一覧を見つけるのにちょうどいい。HTMLを直接読むのは、これらが全部無いときの最後の選択肢です。
そして対象は公開データだけにします。ログイン突破、CAPTCHA回避、アクセス制限の迂回、メールアドレスの大量収集、個人データの無差別保存——このあたりは扱いません。手段が相手の明示的な制御を破る方向なら、目的が正当でもそこで止めます。
全体像:取得 → 解析 → 整形・保存
スクレイピングは3つの段に分けると、ぐっと見通しがよくなります。
| 段 | やること | 道具 |
|---|---|---|
| 1. 取得 | HTTPでHTMLを取る/ブラウザで描画後のDOMを取る | fetch(標準)/Playwright |
| 2. 解析 | HTMLから欲しい要素を選んで抜き出す | cheerio |
| 3. 整形・保存 | 構造化して、証跡付きで残す | CSV/JSON |
この3段を意識すると、コードの責任が混ざりません。取得の関数は「文字列を返すだけ」、解析の関数は「文字列から構造を作るだけ」、保存の関数は「構造を書き出すだけ」。1つの関数に全部詰めると、デザイン変更で解析が壊れたときに、取得まで巻き添えでデバッグするはめになります。
robots.txtと利用規約:飛ばすと一番痛い
ここを飛ばす人が一番多くて、一番痛い目を見ます。
robots.txtは、サイトのルート(https://example.com/robots.txt)に置かれる、Webロボットへのアクセス方針を書いたファイルです。「このパスは自動取得しないで」という宣言が並んでいます。仕様はRFC 9309で標準化されていて、Googleもrobots.txtの解説を公開しています。
注意してほしいのは、robots.txtは「法的な許可証」ではないということ。これは技術的なお願いであって、守らなくても自動的に違法になるわけじゃない。でも逆も真で、robots.txtが許可していても、利用規約で自動取得が禁止されていれば、それはアウトです。順番としては、規約を読む → robots.txtを確認する → それから初めてコードを書く、です。
僕の運用ルールはシンプルで、「robots.txtが確認できなければ取得しない」。自社サイトでまだrobots.txtを置いていない、みたいな例外のときだけ、理由を記録した上で進めます。
マナー:相手のサーバーを攻撃しないために
技術的には簡単に大量アクセスできてしまうからこそ、ブレーキは自分で設計します。僕が守っているのは5つ。
- 間隔を空ける。 リクエストごとに最低でも数秒。
await sleep(2000)を挟むだけです。100ページを一瞬で叩くコードは、相手のサーバーから見るとDoS攻撃と区別がつきません。 - 少量・低頻度。 一度に全ページではなく、必要な分だけ。毎分ではなく1日1回で足りないか考える。
- 正直なUser-Agent。 誰が何の目的でアクセスしているか分かるように、連絡先URLを入れます。ブラウザのふりをして正体を隠すのは、もうマナー違反の側です。
- リトライは控えめ。 失敗したら何度も叩き直さない。相手が重いから失敗しているのに、リトライで追い打ちをかけたら最悪です。
- 失敗したら止まる。 エラーで黙って次に進むより、止まって人間に知らせるほうが安全です。
この5つは「相手への配慮」であると同時に「自分を守る盾」でもあります。行儀よくアクセスしていれば、ブロックされにくいし、後ろめたさもない。
取得 → 解析 → 保存:コピペで動く最小スクリプト
説明より動かすほうが早いので、1ページ取得の最小例を置きます。Node.js 18以降ならそのまま動きます(fetchが標準で入っているため)。安全側に倒すため、許可したorigin以外は取得せず、robots.txtが確認できなければ止まります。
cheerioを使うので、先に入れておきます。
mkdir scrape-demo && cd scrape-demo
npm init -y
npm install cheerio
次が本体です。長く見えますが、やっていることは「allowlist確認 → robots.txt確認 → 取得 → cheerioで解析 → CSVと監査ログに保存」の素直な流れです。
// scrape-allowed-page.mjs
import { writeFile } from "node:fs/promises";
import * as cheerio from "cheerio";
// 連絡先入りのUser-Agent。正体を隠さない
const USER_AGENT = "ClaudeCodeLabAuditBot/1.0 (+https://example.com/bot-info)";
const targetUrl = new URL(process.env.SCRAPE_URL ?? "https://example.com/");
// 許可したoriginだけ取得する。事故防止の門番
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "https://example.com")
.split(",")
.map((value) => new URL(value.trim()).origin);
const delayMs = Number.parseInt(process.env.REQUEST_DELAY_MS ?? "2000", 10);
if (!allowedOrigins.includes(targetUrl.origin)) {
throw new Error(`許可リスト外のためブロック: ${targetUrl.origin}`);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 取得は必ず間隔を空けてから。相手への配慮
async function fetchText(url, accept) {
await sleep(delayMs);
return fetch(url, { headers: { "user-agent": USER_AGENT, accept } });
}
// robots.txtを取りに行く。404なら「無し」として扱う
async function loadRobots(origin) {
const robotsUrl = new URL("/robots.txt", origin);
const response = await fetchText(robotsUrl, "text/plain");
if (response.status === 404) {
return { url: robotsUrl.toString(), status: 404, text: null };
}
if (!response.ok) {
throw new Error(`robots.txt確認に失敗: HTTP ${response.status}`);
}
return { url: robotsUrl.toString(), status: response.status, text: await response.text() };
}
// User-Agent向けのDisallowに対象パスが該当しないかを最小限チェック
function isAllowedByRobots(robotsText, url) {
if (robotsText === null) {
// robots.txtが無い場合は、理由を記録した上でだけ許可する
return process.env.ALLOW_WITHOUT_ROBOTS === "true";
}
const lines = robotsText.split(/\r?\n/).map((line) => line.split("#")[0].trim());
const disallowed = [];
let appliesToAll = false;
for (const line of lines) {
const [field, ...rest] = line.split(":");
const key = field.trim().toLowerCase();
const value = rest.join(":").trim();
if (key === "user-agent") appliesToAll = value === "*";
if (key === "disallow" && appliesToAll && value) disallowed.push(value);
}
return !disallowed.some((rule) => url.pathname.startsWith(rule));
}
// --- ここから本番の流れ ---
// 1. robots.txtを確認
const robots = await loadRobots(targetUrl.origin);
if (!isAllowedByRobots(robots.text, targetUrl)) {
throw new Error(`robots.txtで拒否されています: ${targetUrl.toString()}`);
}
// 2. ページを取得
const response = await fetchText(targetUrl, "text/html");
if (!response.ok) {
throw new Error(`ページ取得に失敗: HTTP ${response.status}`);
}
const html = await response.text();
// 3. cheerioで解析。意味のあるセレクタを使う
const $ = cheerio.load(html);
const fetchedAt = new Date().toISOString();
const row = {
sourceUrl: targetUrl.toString(),
fetchedAt,
title: $("title").first().text().trim(),
h1: $("main h1, article h1, h1").first().text().trim(),
metaDescription: $('meta[name="description"]').attr("content")?.trim() ?? "",
linkCount: $("a[href]").length,
};
// 4. CSVと監査ログに保存
const csvEscape = (v) => {
const text = String(v ?? "");
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
const headers = ["sourceUrl", "fetchedAt", "title", "h1", "metaDescription", "linkCount"];
const csv = [headers.join(","), headers.map((h) => csvEscape(row[h])).join(",")].join("\n");
await writeFile("scrape-output.csv", `${csv}\n`, "utf8");
await writeFile(
"scrape-audit.json",
JSON.stringify(
{ checkedAt: fetchedAt, userAgent: USER_AGENT, robotsUrl: robots.url, robotsStatus: robots.status, allowedOrigins, sourceUrl: row.sourceUrl },
null,
2,
),
"utf8",
);
console.log(`保存しました: scrape-output.csv (${row.sourceUrl})`);
実行はこれだけです。PowerShellなら次のように環境変数を渡します。
$env:SCRAPE_URL="https://your-domain.example/page"
$env:ALLOWED_ORIGINS="https://your-domain.example"
node scrape-allowed-page.mjs
ポイントは、cheerioのセレクタにmain h1やmeta[name="description"]のような意味のあるものを選んでいること。.price > span:nth-child(2)みたいな指定はデザイン変更で即死しますが、HTMLの構造に根ざしたセレクタなら長持ちします。cheerioのAPIはload()でHTMLを読み込み、$("セレクタ")で選び、.text()や.attr()で値を取る——jQueryを触ったことがあれば一瞬です。詳しくはcheerio公式ドキュメントを見てください。
動的ページはPlaywright:ただし対象は自分の管理下だけ
fetch+cheerioで取れるのは、ページソースに最初から文字が入っている静的HTMLです。ブログ記事、ドキュメント、価格表のように、ソースを見れば中身が見えるページならこれで十分。
困るのは、JavaScriptで後から描画されるページです。無限スクロール、ボタンを押すと出てくる表、クライアント側で組み立てるSPA。ソースを見ても<div id="app"></div>しか無くて、cheerioで読んでも空っぽ。こういうときだけPlaywrightの出番です。実際のブラウザを動かして、描画後のDOMを読みます。
ただし大事な前提があります。Playwrightは外部サイトへ無差別に使わない。ブラウザを動かすぶん相手のサーバー負荷も上がるし、JavaScript描画が必要なページはたいてい「自動取得を想定していない」ページです。僕がPlaywrightを使うのは、自社サイトのローカルプレビュー、許可を得た検証環境、社内管理画面——対象が自分の管理下にあると説明できる場面に限っています。
次は自社ページの要素チェック用の例です。指定したセレクタが描画後に出ているかを確認します。
// check-own-site-selectors.mjs
import { writeFile } from "node:fs/promises";
import { chromium } from "playwright";
const target = process.env.LOCAL_PREVIEW_URL ?? "http://127.0.0.1:4321/blog/claude-code-web-scraping/";
// 自分の管理下のページだけに限定する門番
const allowedPrefixes = ["http://127.0.0.1:", "http://localhost:", "https://claudecodelab.com/"];
if (!allowedPrefixes.some((prefix) => target.startsWith(prefix))) {
throw new Error(`自社・ローカルページ以外には使いません: ${target}`);
}
const browser = await chromium.launch();
// コンテキストを分けてCookieやlocalStorageを混ぜない
const context = await browser.newContext({ userAgent: "ClaudeCodeLabAuditBot/1.0 local-preview-check" });
const page = await context.newPage();
await page.goto(target, { waitUntil: "domcontentloaded" });
const checks = [
{ name: "記事タイトル", selector: "main h1, article h1" },
{ name: "更新日", selector: "time, [data-updated-date]" },
{ name: "本文", selector: "main article, article" },
];
const results = [];
for (const check of checks) {
const locator = page.locator(check.selector);
const count = await locator.count();
const firstText = count > 0 ? ((await locator.first().textContent()) ?? "").trim().slice(0, 120) : "";
results.push({ ...check, count, firstText });
}
await writeFile("selector-audit.json", JSON.stringify({ target, checkedAt: new Date().toISOString(), results }, null, 2), "utf8");
await context.close();
await browser.close();
// 取れなかったセレクタがあれば、黙って続けず止める
const missing = results.filter((result) => result.count === 0);
if (missing.length > 0) {
throw new Error(`見つからないセレクタ: ${missing.map((r) => r.name).join(", ")}`);
}
console.log(`保存しました: selector-audit.json (${target})`);
ここでbrowser.newContext()を使っているのは、ブラウザ内に独立した作業場所を作るためです。コンテキストはCookieやlocalStorageを分けられるので、用途が混ざりません。仕組みはPlaywright公式のBrowserContextドキュメントに詳しく載っています。なお、取得処理そのものの標準仕様を確認したいときはMDNのFetch APIが原典です。
僕がやらかした失敗3つ
正直に書きます。最初の数本は全滅でした。
1つ目は、レート制限を入れなかったこと。 「速いほうがいいだろう」と間隔ゼロでループを回したら、20ページ目あたりでアクセスを遮断されました。相手からすれば、急に高速連打してくる正体不明のbot。当然です。sleepを1行足すだけで、その後は遮断されなくなりました。
2つ目は、壊れやすいセレクタに頼ったこと。 div.card > div:nth-child(3) > spanみたいな指定で価格を抜いていたら、相手がデザインを少し変えた瞬間に全部空欄になった。しかも空欄なのにエラーにならず、空っぽのCSVが静かに保存され続けていた。今は[data-testid]やmain h1みたいな意味のあるセレクタを使い、取れなかったら空欄にせず失敗として止めるようにしています。構造変化は「いつか必ず起きる」前提で組むのが正解でした。
3つ目は、証跡を残さなかったこと。 どのURLからいつ取ったか記録していなかったので、後で「この数字おかしくない?」と言われても確認しようがない。sourceUrlとfetchedAtを全行に入れるようにしてから、「これは6/5の10時にこのページから取った値です」と即答できるようになりました。監査できないデータは、結局使えないんです。
始め方:1ページ・1回・公開データから
いきなり「全ページを毎時間巡回する全自動クローラー」を作らないでください。失敗しても痛くない小さい仕事から始めます。
順番はいつも同じです。①対象を1ページだけに絞る → ②公式API・RSS・サイトマップが無いか確認する → ③規約とrobots.txtを確認する → ④fetch+cheerioで1回だけ取得する → ⑤sourceUrlとfetchedAt付きで保存し、人間がサンプルを目視する。ここまでが安全だと確認できてから、件数や頻度を増やす。動的ページが必要だと分かったときだけ、Playwrightを足す。
取れたデータをどう使うかも、最初に決めておくと迷いません。CSVに落としたあとはスプレッドシート連携の自動化で集計につなげたり、Claude Codeのデータ可視化でグラフにしたりできます。自分でAPIとして配るなら、設計の勘どころはREST API設計の進め方が参考になります。取得した文字列を社内ツールに流し込む前には、Claude Codeセキュリティ対策も一度目を通しておくと安心です。
よくある質問
Q. robots.txtで禁止されていなければ、何を取ってもいいですか? いいえ。robots.txtは技術的なお願いで、利用規約とは別物です。robots.txtが許可していても、規約で自動取得が禁止されていればアウトです。両方を確認してください。
Q. 静的ページか動的ページか、どう見分けますか?
ブラウザでページを開き、右クリックの「ページのソースを表示」で欲しい文字が入っていれば静的なのでfetch+cheerioでOKです。ソースには無く、画面には出ている場合はJavaScript描画なので、自分の管理下のページに限ってPlaywrightを検討します。
Q. cheerioとPlaywright、どちらを使えばいいですか?
まずfetch+cheerioを試します。これで取れれば、速くて依存も少なく監査も楽です。cheerioで空になる(=JavaScript描画が必要な)ページだけ、対象が自分の管理下にあることを前提にPlaywrightへ切り替えます。
Q. リクエスト間隔はどれくらい空ければいいですか? 最低でも数秒、僕は2秒から始めます。相手のサーバー規模や規約のクロール指定にもよるので、迷ったら長めに。毎分巡回が本当に必要か、1日1回で足りないかも考えます。
Q. ブロックされたらどうすればいいですか? 迂回しようとしないでください。ブロックは「来ないで」という意思表示です。User-Agentを偽装したりIPを変えたりして突破するのは、規約違反であり、相手の明示的な制御を破る行為です。公式APIや問い合わせなど、別の正規ルートを探します。
まとめ
Node.jsでのWebスクレイピングは、技術的には「取得 → 解析 → 整形・保存」の素直な3段です。静的ページはfetch+cheerioで十分。JavaScript描画が必要な動的ページだけ、自分の管理下に限ってPlaywrightを使う。
でも、最初に作るべきは「大量取得スクリプト」ではなく「境界が明確な、監査できるスクリプト」です。公式APIやサイトマップを優先し、規約とrobots.txtを確認し、間隔を空けて少量だけ取り、sourceUrlとfetchedAtを全行に残す。落とし穴は、規約とrobots.txtの無視、レート制限なしのループ、壊れやすいセレクタ、保護の迂回、証跡なしの保存。この5つを避けるだけで、後から堂々と説明できるデータ収集になります。
冒頭の知り合いには、fetch+cheerioで1ページ・1日1回・間隔2秒の最小スクリプトを渡しました。毎朝15分の目視は消えて、CSVにsourceUrlとfetchedAtが並ぶようになった。「数字の根拠を聞かれても怖くなくなった」と言っていたのが、何より良かったです。実務でのスクレイピングは、速さではなく、止まれることと説明できることで決まります。
実装の指示出しや、CLAUDE.mdへの禁止事項の落とし込み、Playwright検証やCSV監査ログの整え方まで一緒に固めたいときは、Claude Code研修・導入相談で相談できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。