Claude CodeでPlaywrightのスクレイピングを作る — 待機・リトライ・スキーマ検証
PlaywrightのPython版で動的ページを取得するスクリプトを、Claude Codeに書かせて回す手順です。待機はlocator、リトライは一時的な失敗だけ、取得結果はPydanticで検証します。
JavaScriptで描画されるページの中身を取りたいときは、PlaywrightのPython版でスクリプトを書き、Claude Codeにその実行と修正を任せる形が扱いやすい構成です。壊れやすいのは取得そのものではなく、待ち方、失敗時のやり直し、取れた値の確認の3か所です。この記事では、この3つを関数に分けたスクレイパーを作ります。
ブラウザ操作をClaudeに直接やらせるMCPの形は、Playwright MCPの設定記事が扱っています。ここで作るのは、一度書けば誰が実行しても同じ動きをする普通のPythonスクリプトです。
MCPではなくスクリプトにする理由
Claudeがブラウザを動かす形は、毎回の判断が入るぶん、同じ結果を再現しにくくなります。定期的に同じ項目を集める用途では、手順が固定されたスクリプトのほうが向いています。
役割を分けると次のとおりです。
- Claude Codeの役割: スクリプトを書く。実行して、失敗の出力を読み、直す。
- スクリプトの役割: ページを開く。待つ。取る。検証する。
- 人の役割: 取得してよいサイトかを決める。取得項目の定義を決める。
スクリプトにしておくと、後からGitHub Actionsやcronに載せることも、claude なしで動かすこともできます。ログイン済みの画面を取りたい場合は、認証状態の扱いが別の論点になるのでstorageStateの記事を先に読んでください。
準備とCLAUDE.mdの規約
インストールは、Playwright公式のPython向け手順に従います。uvを使う場合は次の2行です。
uv add playwright pydantic
playwright installplaywright install が、Chromium、Firefox、WebKitのブラウザバイナリを取得します。
次に、Claude Codeが毎回守るべき規約をCLAUDE.mdに固定します。スクレイパーでは、やり方の好みより「やってはいけない待ち方」を書くのが効きます。
## スクレイパー(scrape/ 配下)
- ブラウザ操作は Playwright の同期API(sync_playwright)で書く
- time.sleep は使わない。待つときは locator.wait_for か page.wait_for_timeout
- wait_until="networkidle" は使わない
- 取得項目は scrape/models.py の Item(Pydantic)に通す。通らない行は捨てずに errors.jsonl へ
- 変更後は `python scrape/run.py --limit 3` を実行し、件数と検証エラー数を確認するこのうち time.sleep の禁止は、Playwright公式の既知の問題に基づきます。内部で非同期の処理に頼っているため、time.sleep(5) では処理が正しく進まず、状態が古くなることがある、と説明されています。どうしても固定時間で待つなら page.wait_for_timeout(5000) を使う、とされています。公式は、そもそも手動で待つ必要はほとんどない、とも書いています。
CLAUDE.mdは強制力のある設定ではなくClaudeが読むコンテキストなので、同じ内容を検証コマンド側にも持たせます。それが次の節以降の関数分割です。
全体の構成:取得・抽出・検証を分ける
スクリプトは3つの層に分けます。層ごとに失敗の種類が違うからです。
3つの層と失敗の種類
取得(fetch)
ページを開き、必要な要素が出るまで待ちます。失敗は、タイムアウトやHTTPエラーなど、やり直せば通るかもしれないものです。
抽出(extract)
画面上の要素から素の文字列を取り出します。失敗は、セレクタの不一致など、やり直しても直らないものです。
検証(validate)
取れた値が定義どおりかを確かめます。失敗は、サイト側の表示変更や欠損です。
リトライの対象は取得層だけにします。抽出と検証の失敗を何度やり直しても結果は変わらず、相手サイトへのリクエストが増えるだけだからです。
待機:locatorで待ち、networkidleは避ける
動的ページで最初につまずくのは「いつ取ってよいか」です。Playwrightは操作の前に、要素が見える、安定している、イベントを受けられる、有効である、といった確認(actionability check)を自動で待ちます。条件がタイムアウトまでに満たされなければ、TimeoutError で失敗します。
取得だけで操作をしないスクリプトでは、この自動待機が効きません。代わりに、取りたい要素の出現を明示的に待ちます。公式のPage APIでは、page.query_selector() はlocatorベースの page.locator() を使うよう案内される非推奨の扱いで、要素を待つには locator.wait_for() を使う、と書かれています。
cards = page.locator("[data-testid='item-card']")
cards.first.wait_for(state="visible")page.goto() の wait_until にも注意があります。"networkidle" は「500ミリ秒以上ネットワーク接続が無い状態」を待つ指定で、公式がDISCOURAGED(非推奨)と明記しています。広告や計測タグが定期的に通信するページでは、いつまでも静まらないことがあるためです。"domcontentloaded" で読み込みを区切り、あとは目的の要素を待つ形にします。
タイムアウトの既定値は30000ミリ秒(30秒)です。スクレイパーでは長すぎることが多いので、page.set_default_timeout() で短くしておくと、壊れたページに30秒ずつ使わずに済みます。
もう1つの落とし穴は、page.goto() が404や500でも例外を投げない点です。公式は、有効なHTTPステータスが返れば404や500でもエラーにならず、ステータスは response.status で取る、と説明しています。つまり、エラーページを正常なページとして読み進めてしまう余地があります。取得層の最初で、ステータスを必ず見ます。
リトライ:一時的な失敗だけをやり直す
リトライは「やり直して通る失敗」に限ります。判定の目安は次の表です。
| 失敗 | やり直すか | 理由 |
|---|---|---|
ナビゲーションのタイムアウト(TimeoutError) | やり直すか回数を決めてやり直す | 理由一時的な遅延の可能性がある |
| 429、500、502、503、504 | やり直すか間隔を空けてやり直す | 理由サーバー側の一時的な状態 |
| 404、403 | やり直すかやり直さない | 理由同じ結果になる |
| 検証エラー(Pydantic) | やり直すかやり直さない | 理由表示内容そのものが定義と違う |
Playwrightの TimeoutError は、from playwright.sync_api import TimeoutError as PlaywrightTimeoutError の形で取り込みます。Python標準の TimeoutError とは別物なので、別名を付けないと捕まえられません。
import random
import time
from playwright.sync_api import Browser
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class RetryableError(Exception):
pass
class PermanentError(Exception):
pass
def fetch_once(browser: Browser, url: str) -> list[dict]:
context = browser.new_context() # 試行ごとに新しい状態で開く
try:
page = context.new_page()
page.set_default_timeout(15_000)
page.route("**/*.{png,jpg,jpeg,gif,webp}", lambda r: r.abort())
resp = page.goto(url, wait_until="domcontentloaded")
if resp is None or resp.status in RETRYABLE_STATUS:
raise RetryableError(f"status={resp.status if resp else None}")
if resp.status >= 400:
raise PermanentError(f"status={resp.status}")
page.locator("[data-testid='item-card']").first.wait_for(state="visible")
return page.locator("[data-testid='item-card']").evaluate_all(
"""els => els.map(e => ({
title: e.querySelector('h2')?.textContent?.trim() ?? null,
price: e.querySelector('.price')?.textContent ?? null,
url: e.querySelector('a')?.href ?? null,
}))"""
)
finally:
context.close()
def fetch(browser: Browser, url: str, attempts: int = 3) -> list[dict]:
for n in range(1, attempts + 1):
try:
return fetch_once(browser, url)
except (RetryableError, PlaywrightTimeoutError):
if n == attempts:
raise
time.sleep((2 ** n) + random.random()) # 取得層の外の待機なので可見てほしい点が3つあります。
page.route()で画像リクエストを中断しています。公式のドキュメントに、画像リクエストをすべて中断するハンドラの例があります。取得に不要な通信を止めると、待ち時間が短くなります。- リトライの待機は指数的に伸ばし、乱数で揺らしています。同じ間隔で連続して叩かないためです。この
time.sleepはPlaywrightを呼んでいない箇所なので、先ほどのCLAUDE.mdの禁止とは矛盾しません。気になるなら、CLAUDE.mdの文言を「Playwrightの呼び出し中はtime.sleepを使わない」と書き分けておきます。 attemptsを使い切ったら例外をそのまま上げています。呼び出し側が「取れなかったURL」として記録できるようにするためです。
セレクタの data-testid や .price は例です。実際のサイトに合わせて、Claude Codeに画面の構造を見せて決めさせます。
スキーマ検証:取れた値を型で受け止める
抽出した直後の値は、文字列か null の混ざった辞書にすぎません。これをPydanticのモデルに通して、定義どおりでない行を早めに弾きます。
import re
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
class Item(BaseModel):
model_config = ConfigDict(extra="forbid") # 想定外の項目は拒否する
title: str
price: int
url: HttpUrl
@field_validator("price", mode="before")
@classmethod
def parse_price(cls, v):
if isinstance(v, str):
digits = re.sub(r"[^\d]", "", v) # 「¥1,200」→「1200」
if digits:
return int(digits)
return vPydanticの model_validate() は、与えられたオブジェクトをモデルに照らして検証します。検証に失敗すると ValidationError が出ます。モデル設定の extra を 'forbid' にすると、定義にない追加データは許可されません。「サイトが項目を増やした」という変化も、黙って流さず失敗として見えるようになります。
検証の呼び出しは、1件ずつ行って、通った行と通らなかった行を分けます。
import json
from pydantic import ValidationError
def validate_rows(rows: list[dict]) -> tuple[list[Item], list[dict]]:
ok, errors = [], []
for row in rows:
try:
ok.append(Item.model_validate(row))
except ValidationError as e:
errors.append({"row": row, "errors": e.errors(include_url=False)})
return ok, errors
def write_errors(errors: list[dict], path: str = "errors.jsonl") -> None:
with open(path, "a", encoding="utf-8") as f:
for e in errors:
f.write(json.dumps(e, ensure_ascii=False, default=str) + "\n")ここで大事なのは、0件を成功にしないことです。セレクタが外れて空のリストが返っても、検証は1件も失敗せず、エラーなしに見えてしまいます。実行スクリプトの最後に、次の判定を入れます。
def check_result(ok: list[Item], errors: list[dict], min_ok: int = 1,
max_error_rate: float = 0.2) -> None:
total = len(ok) + len(errors)
if len(ok) < min_ok:
raise SystemExit(f"取得0件: セレクタの変更を疑う (total={total})")
if total and len(errors) / total > max_error_rate:
raise SystemExit(f"検証エラー率が高い: {len(errors)}/{total}")終了コードが0以外になるので、Claude Codeは実行結果から失敗を判断できます。人が出力を目で追わなくても、ループが回ります。
実行スクリプトにまとめる
3つの層をつなぐ入口は短く保ちます。
# scrape/run.py
import argparse
from playwright.sync_api import sync_playwright
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0, help="0なら全件")
args = ap.parse_args()
urls = load_urls() # 取得対象URLの一覧(別途用意)
if args.limit:
urls = urls[: args.limit]
all_ok, all_errors = [], []
with sync_playwright() as p:
browser = p.chromium.launch()
try:
for url in urls:
rows = fetch(browser, url)
ok, errors = validate_rows(rows)
all_ok += ok
all_errors += errors
finally:
browser.close()
write_errors(all_errors)
check_result(all_ok, all_errors)
if __name__ == "__main__":
main()--limit を用意したのは、Claude Codeが修正のたびに全件を走らせて相手サイトへ負荷をかけないためです。CLAUDE.mdで「変更後は --limit 3 で確かめる」と決めておけば、検証ループの1周が数ページで終わります。
並列化が必要になったときの注意も、公式の既知の問題に書かれています。Playwrightのスレッド安全性は保証されておらず、マルチスレッドではスレッドごとにPlaywrightのインスタンスを作る必要があります。asyncio版(async_playwright)を使う場合、Playwrightの呼び出しを実行中のタスクのキャンセルは未サポートで、動作が未定義になると書かれています。まず上の逐次版で形を固め、並列化は後から足すほうが安全です。
Claude Codeに回させる
規約とスクリプトの骨格が揃ったら、実行と修正のループをClaude Codeに任せます。依頼文は、成功条件を書くのがコツです。公式のワークフロー集でも、定期実行などの自律タスクのプロンプトでは何をもって成功とするかを明示するよう勧めています。
scrape/run.py を実行して、次の条件を満たすまで直して。
- `python scrape/run.py --limit 3` が終了コード0で終わる
- errors.jsonl の件数が取得件数の2割以下
- CLAUDE.md のスクレイパー規約を守る(time.sleep と networkidle は使わない)
修正のたびに実行結果の件数を報告して。サイトへのアクセスは--limit 3の範囲に留めて。実行の許可は、権限ルールで絞れます。Claude Codeのルールは Bash(コマンド) の形で書き、Bash(npm run build) のように完全一致も、* を使った前方一致も指定できます。スクレイパーなら、短い確認実行は許可し、全件実行は毎回確認する構成が考えられます。
{
"permissions": {
"allow": ["Bash(python scrape/run.py --limit *)"],
"ask": ["Bash(python scrape/run.py)"]
}
}この2つのルールは、--limit 付きの実行を確認なしで通し、引数なしの全件実行は都度確認を挟みます。ask側が優先される仕組みなので、全件実行がallowに紛れて通ることはありません。
非対話で一度だけ走らせたい場合は、claude -p にプロンプトを渡して使います。公式は、標準入力と標準出力がUnixツールと同じように使えると説明しています。許可するツールは --allowedTools で指定します。
つまずきやすい点
落とし穴
エラーページを成功として読み進める
page.goto() は404や500でも例外を投げません。ステータスを見ないスクリプトは、エラーページの空の一覧を「0件の正常な結果」として扱います。取得層でステータスを必ず判定し、最後に0件を失敗にするのは、この見落としへの二重の備えです。
落とし穴
リトライが検証エラーまで巻き込む
例外を広く捕まえてやり直すと、サイトの表示変更による検証エラーも毎回やり直され、相手への負荷だけが増えます。リトライするのは TimeoutError と特定のHTTPステータスだけにします。
落とし穴
Claudeが待機を足して通してしまう
テストが落ちたとき、Claude Codeが time.sleep や networkidle を足して「通った」ように見せることがあります。CLAUDE.mdで禁止し、完了条件にも書いておくと、要素を明示的に待つ形へ直す方向に寄ります。
アクセスのマナー(robots.txtの遵守、リクエスト間隔の設定)は、フレームワーク側が設定を持つScrapyのほうが組み込みやすい面があります。そちらの設定はScrapyのrobots.txt遵守とcontracts検証にまとめられています。Playwrightで書く場合は、取得URLの一覧を作る段階でrobots.txtを確認し、1URLごとの間隔をスクリプトに持たせてください。
既存のSeleniumスクリプトがある場合
すでにSeleniumで書かれたスクレイパーがあるなら、一から書き直すより、待機とロケータの置き換えから始められます。固定のsleepを明示的な待機へ差し替える考え方は、SeleniumからPlaywrightへの移行記事と共通です。
まとめ
動的ページのスクレイパーは、取得・抽出・検証の3層に分けると、直す場所が見えやすくなります。待機はlocatorに任せて networkidle を避け、リトライはやり直して通る失敗に限り、取れた値はPydanticで受け止めて0件を失敗にします。Claude Codeには、成功条件と禁止事項をCLAUDE.mdと依頼文の両方に書いておけば、実行結果を読みながら直す作業を任せられます。
人が最後に決めることは、対象サイトに取得してよいか、と、取得項目の定義の2点です。ここがスクリプトの外側にあるおかげで、残りの試行錯誤はClaude Codeに回せます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。