Claude Codeで安全にWebスクレイピングを実装する実務ガイド
Claude CodeでWebスクレイピングを安全に実装。robots.txt、Fetch、Playwright、監査ログまで解説。
Claude Codeに任せる前に決める境界
Webスクレイピングは「Webページから情報を機械的に読むこと」です。便利ですが、公開ページなら何でも集めてよい、という意味ではありません。Claude Codeにコードを書かせるほど実装は速くなりますが、速いぶんだけ、利用規約、robots.txt、レート制限、個人情報、監査ログを最初に決めておく必要があります。
初心者が最初に覚えるべき順番は、スクレイピングより前に公式API、RSS、サイトマップ、CSVエクスポートを探すことです。APIは構造が安定していて、利用条件も明確です。サイトマップはページURLを見つける目的に向いています。HTMLを読むのは、その次の選択肢にします。
この記事ではClaude Codeに渡す前提で、安全なWebスクレイピングの設計をまとめます。対象は公開データだけです。ログイン突破、CAPTCHA回避、アクセス制限の迂回、メールアドレスの大量収集、個人データの無差別保存は扱いません。必要な場合は法務、プライバシーポリシー、各国の個人情報保護ルールを確認してください。
公式情報は原典で確認します。robots.txtの仕様はRFC 9309、検索エンジン向けの説明はGoogleのrobots.txtドキュメント、ブラウザ標準の取得処理はMDN Fetch API、ブラウザ自動化はPlaywright Browser contextsを基準にします。
全体像
flowchart TD
A["目的を1つに絞る"] --> B["公式API・RSS・sitemapを確認"]
B --> C["規約とrobots.txtを確認"]
C --> D{"HTMLだけで読める?"}
D -->|Yes| E["Fetchで1ページずつ取得"]
D -->|No, 自社・許可済み| F["Playwrightで表示後のDOMを確認"]
E --> G["CSVと監査ログにURL・取得時刻を保存"]
F --> G
G --> H["人間がサンプルを確認して公開判断"]
この流れにすると、Claude Codeへの依頼も具体的になります。「このURLを全部抜いて」ではなく、「この許可済みドメインの1ページだけを、2秒以上空けて取得し、sourceUrlとfetchedAtをCSVに残す」と頼めます。曖昧な依頼を避けるほど、危ないコードや壊れやすいセレクタが出にくくなります。
FetchとPlaywrightの使い分け
fetchはHTTPでHTMLを取得する標準APIです。静的HTML、ドキュメント、ブログ記事、価格表のように、ページソースだけで必要な文字が見える場合に向いています。速く、依存が少なく、監査もしやすいのが利点です。逆に、ログイン後画面、無限スクロール、ボタン操作後にだけ表示される表、クライアントサイドで組み立てられるSPAには向きません。
Playwrightは実際のブラウザを動かして、表示後のDOMを調べる道具です。自社サイトのローカルプレビュー、許可を得た検証環境、社内管理画面のテストには便利です。ただし外部サイトへ無差別に使うと、相手のサーバー負荷が増え、規約違反や迷惑行為になりやすいです。Playwrightは「ブラウザが必要な理由」と「対象が自分の管理下にあること」を説明できる場面に絞ります。
Claude Codeには、まずfetch版を作らせ、JavaScript描画が本当に必要なページだけPlaywrightへ分けるよう指示します。レビューでは、page.waitForTimeoutだらけになっていないか、セレクタがCSSクラスの偶然に依存していないか、source URLと取得時刻を保存しているかを見ます。
実用例
1つ目は自社サイトの公開ページ監視です。研修ページ、商品ページ、問い合わせフォーム、記事一覧を定期的に読み、タイトル、更新日、主要CTA、canonical URLが壊れていないか確認します。これは自分のサイトなので、robots.txtも規約も自分で整備できます。変更検知をコンテンツ運用の自動化や収益導線監査につなげると、古いCTAや切れた内部リンクを早く直せます。
2つ目は公開ドキュメントURLの収集です。ライブラリの公式ドキュメントや自社ナレッジのページ一覧を、サイトマップから集めて検索インデックスや社内メモに使います。この用途では本文を丸ごと保存する必要は薄く、URL、タイトル、最終確認時刻だけで十分なことが多いです。コピーではなく参照ログを残すほうが安全です。
3つ目は競合の公開価格ページの定点確認です。価格、プラン名、公開キャンペーンのような誰でも見られるページを少量だけ確認し、人間が最終レビューします。重要なのは「自動判断しない」ことです。価格は地域、税、通貨、キャンペーン条件で変わります。スクレイパーの出力をそのまま営業資料へ入れず、source URLと取得時刻を添えて人間が見る運用にします。
4つ目はリード調査です。ただし、これは最も注意が必要です。会社名、公式問い合わせページ、公開されている事業内容を調べる程度に留め、個人メールを機械的に集めないでください。営業メールを送るなら、配信停止方法、送信者情報、地域ごとの同意要件を確認します。メール自動化はClaude Codeでメール自動化と組み合わせられますが、収集と送信の間に人間の確認を入れるのが現実的です。
失敗例と落とし穴
一番多い失敗は、robots.txtと利用規約を読まずに実行することです。robots.txtはWebロボットへのアクセス方針を示すファイルで、RFC 9309で仕様化されています。法的な許可そのものではありませんが、尊重すべき境界です。規約で自動取得が禁止されている場合もあります。
次に多いのは、メールアドレスや氏名を見つけたら全部保存する実装です。公開ページに載っている情報でも、目的外利用や保管期間の問題は残ります。最初から個人情報を取らない設計にし、必要な場合でも項目、根拠、削除手順、アクセス権限を決めます。
レート制限なしのループも危険です。100ページを一瞬で取りに行くコードは、相手から見ると攻撃に近く見えることがあります。少量、低頻度、明示的なUser-Agent、リトライ控えめ、失敗時停止が基本です。
CSSセレクタの脆さも実務では効きます。.price > span:nth-child(2)のような指定は、デザイン変更で壊れます。main h1、time[datetime]、[data-testid]のような意味のあるセレクタを優先し、取れなかったら黙って空欄にせず失敗としてログに残します。
保護の迂回も避けます。CAPTCHA、ログイン壁、地域制限、bot対策、レート制限を回避する方向にClaude Codeが進みそうなら、そこで止めます。目的が正当でも、手段が相手の明示的な制御を破るなら採用しません。
最後に、source URLと取得時刻がないデータは使いづらいです。監査できない表は、後から間違いを見つけても原因を追えません。CSVにもJSONにもsourceUrl、fetchedAt、userAgent、robotsUrl、robotsStatusを残します。
コピペで動くFetch版
次のコードはNode 18以降で動く1ページ用の最小例です。安全側に倒すため、許可したorigin以外は取得せず、robots.txtが確認できない場合は止まります。自社サイトなどでrobots.txtがまだ無い場合だけ、理由を記録した上でALLOW_WITHOUT_ROBOTS=trueを使います。
// scrape-allowed-page.mjs
import { writeFile } from "node:fs/promises";
const USER_AGENT = "ClaudeCodeLabAuditBot/1.0 (+https://example.com/bot-info)";
const BOT_TOKEN = "ClaudeCodeLabAuditBot";
const targetUrl = new URL(process.env.SCRAPE_URL ?? "https://example.com/");
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(`Blocked by allowlist: ${targetUrl.origin}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchText(url, accept) {
await sleep(delayMs);
return fetch(url, {
headers: {
"user-agent": USER_AGENT,
accept,
},
});
}
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: response.status, text: null };
}
if (!response.ok) {
throw new Error(`robots.txt check failed: HTTP ${response.status}`);
}
return {
url: robotsUrl.toString(),
status: response.status,
text: await response.text(),
};
}
function parseRobots(text) {
const groups = [];
let agents = [];
let rules = [];
function commit() {
if (agents.length > 0) {
groups.push({ agents, rules });
}
agents = [];
rules = [];
}
for (const rawLine of text.split(/\r?\n/)) {
const cleaned = rawLine.split("#")[0].trim();
if (!cleaned) continue;
const separator = cleaned.indexOf(":");
if (separator === -1) continue;
const field = cleaned.slice(0, separator).trim().toLowerCase();
const value = cleaned.slice(separator + 1).trim();
if (field === "user-agent") {
if (rules.length > 0) commit();
agents.push(value.toLowerCase());
continue;
}
if ((field === "allow" || field === "disallow") && agents.length > 0) {
rules.push({ type: field, path: value });
}
}
commit();
return groups;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function pathMatches(pattern, path) {
if (!pattern) return false;
const exact = pattern.endsWith("$");
const normalized = exact ? pattern.slice(0, -1) : pattern;
const source = `^${escapeRegExp(normalized).replace(/\\\*/g, ".*")}${exact ? "$" : ""}`;
return new RegExp(source).test(path);
}
function isAllowedByRobots(robotsText, url) {
if (robotsText === null) {
return process.env.ALLOW_WITHOUT_ROBOTS === "true";
}
const groups = parseRobots(robotsText);
const bot = BOT_TOKEN.toLowerCase();
const exactGroups = groups.filter((group) =>
group.agents.some((agent) => agent !== "*" && bot.includes(agent)),
);
const fallbackGroups = groups.filter((group) => group.agents.includes("*"));
const selectedGroups = exactGroups.length > 0 ? exactGroups : fallbackGroups;
const rules = selectedGroups.flatMap((group) => group.rules);
const targetPath = `${url.pathname}${url.search}`;
let winner = null;
for (const rule of rules) {
if (!pathMatches(rule.path, targetPath)) continue;
const length = rule.path.replace(/[*$]/g, "").length;
if (!winner || length > winner.length || (length === winner.length && rule.type === "allow")) {
winner = { type: rule.type, length };
}
}
return winner ? winner.type === "allow" : true;
}
function normalizeText(value) {
return value
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]*>/g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function firstMatch(html, pattern) {
const match = html.match(pattern);
return match ? normalizeText(match[1]) : "";
}
function extractPageSummary(html) {
const metaMatch =
html.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i) ??
html.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i);
return {
title: firstMatch(html, /<title[^>]*>([\s\S]*?)<\/title>/i),
h1: firstMatch(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i),
metaDescription: metaMatch ? normalizeText(metaMatch[1]) : "",
linkCount: [...html.matchAll(/<a\s+[^>]*href=["'][^"']+["']/gi)].length,
};
}
function csvEscape(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
const robots = await loadRobots(targetUrl.origin);
if (!isAllowedByRobots(robots.text, targetUrl)) {
throw new Error(`Blocked by robots.txt: ${targetUrl.toString()}`);
}
const response = await fetchText(targetUrl, "text/html");
if (!response.ok) {
throw new Error(`Page fetch failed: HTTP ${response.status}`);
}
const html = await response.text();
const fetchedAt = new Date().toISOString();
const row = {
sourceUrl: targetUrl.toString(),
fetchedAt,
...extractPageSummary(html),
};
const headers = ["sourceUrl", "fetchedAt", "title", "h1", "metaDescription", "linkCount"];
const csv = [headers.join(","), headers.map((header) => csvEscape(row[header])).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(`Saved scrape-output.csv for ${row.sourceUrl}`);
実行例はPowerShellなら、$env:SCRAPE_URL="https://your-domain.example/page"; $env:ALLOWED_ORIGINS="https://your-domain.example"; node scrape-allowed-page.mjsです。出力CSVには、必ずsourceUrlとfetchedAtが入ります。これが後で人間が確認するための最低限の証跡です。
自社ページだけPlaywrightで確認する
次は自社サイトやローカルプレビュー向けのPlaywright例です。外部サイトの保護を回避する用途ではなく、表示後のDOMに必要な要素が出ているかを確認するためのコードです。LOCAL_PREVIEW_URLを指定しなければ、Astroなどのローカルプレビューを見に行きます。
// 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(`Playwright check is limited to owned or local pages: ${target}`);
}
const browser = await chromium.launch();
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: "article title", selector: "main h1, article h1" },
{ name: "updated date", selector: "time, [data-updated-date]" },
{ name: "main article", 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 selectors: ${missing.map((result) => result.name).join(", ")}`);
}
console.log(`Saved selector-audit.json for ${target}`);
この例では、ブラウザコンテキストを新しく作っています。コンテキストはブラウザ内の独立した作業場所で、CookieやlocalStorageを分けられます。Playwrightをスクレイピング目的で使う場合でも、ログイン状態や個人情報が混ざらないように、用途ごとにコンテキストを分けるのが基本です。
Claude Codeへの依頼文
Claude Codeに投げるときは、実装内容だけでなく禁止事項も書きます。たとえば次のように依頼します。
このリポジトリに、許可済みoriginだけを対象にする1ページ取得スクリプトを追加してください。最初に公式API、RSS、sitemapの有無をREADMEに記録し、HTML取得はrobots.txtを確認してから行います。CSVにはsourceUrlとfetchedAtを必ず入れてください。メールアドレスや個人名の抽出、ログイン突破、CAPTCHA回避、レート制限回避は実装しないでください。変更後はnode —checkと小さなサンプル実行結果を示してください。
この依頼文の狙いは、Claude Codeを「速く抜く道具」ではなく「監査可能な作業者」として使うことです。Claude Codeが作ったコードでも、最終責任は実行する人にあります。だからこそ、実行前に差分、対象URL、取得件数、保存項目、削除方法を確認します。
運用チェックリスト
- 公式API、RSS、サイトマップを先に確認したか
- 対象ページが公開データで、利用規約に反していないか
- robots.txtを確認し、結果を監査ログに残したか
- 許可済みoriginのallowlistがあるか
- リクエスト間隔を空け、失敗時に止まるか
- User-Agentに連絡先や用途を入れているか
- source URL、取得時刻、取得方法を保存しているか
- セレクタが意味のあるHTML構造に基づいているか
- 個人情報、秘密情報、認証済みデータを保存していないか
- 出力を人間がサンプル確認してから使っているか
セキュリティ面では、スクレイピング結果をそのまま社内DBやCRMに流し込まないことも大事です。CSVに外部HTML由来の文字列が入るため、スプレッドシートで開く場合はCSV injectionにも注意します。より広い防御観点はClaude Codeセキュリティベストプラクティスを合わせて確認してください。
研修・相談で固める
Webスクレイピングは、コードだけを見ると簡単です。しかし実務では、対象選定、規約確認、データ最小化、監査ログ、レビュー、削除手順まで含めて初めて安全に運用できます。ClaudeCodeLabでは、既存サイトや社内ワークフローを題材に、Claude Codeへの指示、CLAUDE.mdの禁止事項、Playwright検証、CSV監査ログ、メール送信前レビューまでClaude Code研修・導入相談で整理できます。
自分で始める場合は、まず1ページだけ、1回だけ、公開データだけに絞ってください。うまく動いた後に件数を増やすのではなく、監査ログ、失敗時停止、人間レビューが先です。その順番を守るほど、後から説明できるデータ収集になります。
まとめ
Claude CodeでWebスクレイピングを実装するなら、最初の成果物は「大量取得スクリプト」ではなく「境界が明確な監査可能スクリプト」です。公式APIやサイトマップを優先し、HTML取得は許可済みoriginとrobots.txt確認の内側で行います。静的ページはFetchで十分です。自社や許可済みの動的ページだけ、Playwrightで表示後のDOMを確認します。
落とし穴は、robots.txtや規約の無視、個人情報の無差別収集、レート制限なしのループ、壊れやすいセレクタ、保護の迂回、source timestampなしの保存です。Claude Codeは実装を速くしますが、何を取らないか、どこで止めるか、人間がどの証跡を見るかまで指定して初めて実務向きになります。
この記事で紹介した内容を実際に試した結果、Masaは「1ページだけ取得し、CSVとJSON監査ログを残す」形に絞るだけでレビューがかなり楽になると感じました。特にsourceUrlとfetchedAtがあると、価格ページや自社ページ監視の結果を後から説明できます。逆に、件数を先に増やした試作は、セレクタ変更や規約確認の抜けを見つけにくく、実運用に回す前に作り直すことになりました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。