Playwrightでスクレイピングを書いたのに、サイトの改修でwait_for_selectorが待ち続けたり、クラス名が変わって空のデータを返したりしていませんか。最近のページは、表示の裏でJSONを受け取ってから画面を組み立てています。このJSONをPlaywrightのpage.expect_responseでそのまま受け取れば、描画の待ち合わせもHTMLの解析も要りません。この記事では、受け取ったJSONをpandasで表にする今の書き方を、動くコードとブロックごとの説明で示します。最後に、それを毎日止めずに動かすには手元PC・cron・VPSのどこで回すべきかを、止まる条件と合わせて整理します。
wait_for_selectorでDOMを読む書き方をやめ、expect_responseでJSONを受け取る理由
以前の書き方:描画を待ってからHTMLを読む
JavaScriptで後から中身が入るページ(CSR:ブラウザ側で画面を組み立てる方式)に対しては、これまで「要素が表示されるまで待つ→その要素の文字を読む」と書くのが定番でした。
# 以前の書き方(参考)
page.goto("https://example.com/items")
page.wait_for_selector(".item-card")
cards = page.query_selector_all(".item-card")
rows = []
for card in cards:
rows.append({
"name": card.query_selector(".item-name").inner_text(),
"price": card.query_selector(".item-price").inner_text(),
})
この書き方には、毎日動かすと効いてくる弱点が3つあります。
- 見た目の変更で壊れる:クラス名やタグの入れ子が変わるだけで、セレクタが一致しなくなります。デザイン変更はデータ構造の変更よりずっと頻繁です。
- 表示用に加工された値しか取れない:価格が「1,980円」のような文字列になっており、数値に戻す処理が必要です。画面に出ていない項目(IDや更新日時)は取れません。
- 「何件目まで描画されたか」が曖昧:最初の1枚が出た時点で
wait_for_selectorは抜けるので、残りが描画途中のまま読んでしまうことがあります。
今の書き方:画面の元になったJSONを直接受け取る
動的なページは、ブラウザがXHR/fetch(ページ表示後に裏で行う通信)でJSONを取得し、それを画面に流し込んでいます。page.expect_responseは「この条件に合う応答が来るまで待ち、その応答を渡す」機能です。画面に出る前の、整った元データを受け取れます。
観点 | 以前(DOMを読む) | 今(応答JSONを受け取る) |
|---|---|---|
待つ対象 | 要素の表示 | 特定URLへの通信の完了 |
壊れるきっかけ | クラス名・レイアウトの変更 | APIのパスや項目名の変更(頻度は低め) |
値の形 | 表示用の文字列 | 数値・ID・日時などの元の型 |
後処理 | 文字列の整形が必要 | pandasにほぼそのまま渡せる |
まず「どの通信がデータを運んでいるか」を確かめる
コードを書く前に、ブラウザの開発者ツールでネットワークタブを開き、種類を「Fetch/XHR」に絞ってページを再読み込みします。応答の中身に一覧のデータが入っている通信を探し、そのURLの一部(例:/api/items)を控えます。これが後でexpect_responseに渡す条件になります。
Playwright(Python版)でXHRのJSONを受け取るコードと各ブロックの意味
実行環境
- Python 3.11以降。Python 3.10は2026年10月2日のリリースをもってサポートが終了しました。3.10のまま動かしている環境は、この機会に上げておくのが安全です(移行手順はPython 3.10 EOL→uv移行 2026年10月版にまとめています)。
- パッケージ:
pip install playwright pandasのあと、ブラウザ本体をplaywright install chromiumで入れます。Linuxで必要なシステムライブラリも一緒に入れる場合はplaywright install --with-deps chromium(sudoが必要)です。 - バージョンは固定値をここに書かず、手元で
pip show playwright pandasを実行して確認してください。expect_responseの引数の詳細はPlaywright公式ドキュメントのPython版APIリファレンスで確認できます。
コード全体
取得先は説明用に https://example.com/items とし、一覧のデータが /api/items を含むURLからJSONで返ってくる想定です。
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from playwright.sync_api import sync_playwright
PAGE_URL = "https://example.com/items"
API_PART = "/api/items"
OUT_DIR = Path("/home/username/scraper/data")
MAX_PAGES = 5
WAIT_SEC = 5
def is_item_api(response):
return API_PART in response.url and response.request.method == "GET"
def read_json(response):
if not response.ok:
raise RuntimeError(f"API応答が異常です: status={response.status}")
return response.json()
def fetch_all():
records = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# 1ページ目:遷移と同時に流れる応答を受け取る
with page.expect_response(is_item_api, timeout=30_000) as info:
page.goto(PAGE_URL)
data = read_json(info.value)
records.extend(data.get("items", []))
# 2ページ目以降:「次へ」を押した結果の応答を受け取る
for _ in range(MAX_PAGES - 1):
next_button = page.get_by_role("button", name="次へ")
if next_button.count() == 0 or next_button.is_disabled():
break
time.sleep(WAIT_SEC)
with page.expect_response(is_item_api, timeout=30_000) as info:
next_button.click()
data = read_json(info.value)
records.extend(data.get("items", []))
browser.close()
return records
def to_dataframe(records):
df = pd.json_normalize(records)
df["fetched_at"] = datetime.now(timezone.utc).isoformat()
return df
def main():
records = fetch_all()
if not records:
print("0件でした。APIのパスや項目名が変わった可能性があります。", file=sys.stderr)
sys.exit(1)
df = to_dataframe(records)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / f"items_{datetime.now(timezone.utc):%Y%m%d}.csv"
df.to_csv(out, index=False, encoding="utf-8-sig")
print(f"{len(df)}件を保存しました: {out}")
if __name__ == "__main__":
main()
ブロックごとの説明
- 定数(
PAGE_URL〜WAIT_SEC):取得先、待ち受けるAPIのURLの一部、保存先、最大ページ数、ページ間の待ち時間をまとめています。サイト側が変わったときに直す場所を冒頭に集めておくと、修正が1か所で済みます。MAX_PAGESは「想定外に何百ページも辿ってしまう」事故を防ぐ上限です。 is_item_api():expect_responseに渡す判定関数です。ページを開くと画像・広告・計測など多数の通信が流れるので、「URLに/api/itemsを含み、かつGETである」ものだけを対象にします。URLの一部だけで判定しているのは、末尾に付くページ番号などのクエリが毎回変わるためです。read_json():応答のステータスを確かめてからJSONに変換します。response.okはステータスが200番台かどうかです。エラー応答を黙ってJSONとして読むと、空の一覧として扱われて「0件で正常終了」になるので、ここで例外にしています。- 1ページ目の取得(
with page.expect_response(...) as info:):この記事でいちばん大事な部分です。待ち受けを先に始めてから、その中でpage.goto()を呼んでいます。順番を逆にしてgotoの後で待ち受けると、応答がすでに届き終わっていて、来ない応答を待ち続けてタイムアウトします。withを抜けた時点でinfo.valueに条件に合った応答が入ります。 - 2ページ目以降のループ:「次へ」ボタンを押すと次のページ分のJSONが流れるので、同じく待ち受け→その中でクリックの順で書きます。ボタンは
get_by_roleで「役割と表示名」から探しています。クラス名で探すより見た目の変更に強いためです。ボタンが無い、または押せない状態なら最終ページなので抜けます。押す前にtime.sleep(WAIT_SEC)で間を空けています(理由は後の節で説明します)。 to_dataframe():pd.json_normalize()は入れ子のJSON(例:{"shop": {"name": ...}})をshop.nameのような列に展開して表にします。取得時刻はdatetime.now(timezone.utc)で、タイムゾーン付きのUTCとして記録しています。main():0件なら終了コード1で終わらせています。定期実行では「エラーは出ていないのに中身が空」がいちばん気づきにくい壊れ方です。異常終了にしておけば、cronやsystemdのログ・通知で拾えます。CSVはutf-8-sig(先頭に識別用の印が付くUTF-8)で保存し、Excelで開いても文字化けしないようにしています。
JSONの項目名が分からないときの確かめ方
最初はrecords.extend(data.get("items", []))の"items"が合っているか分かりません。一度だけ次のように応答の一番上のキーを表示し、一覧が入っているキーを確かめてから書き換えます。
data = read_json(info.value)
print(list(data.keys()))
応答が一覧そのもの(JSONの配列)で返ってくるサイトもあります。その場合はrecords.extend(data)にします。
expect_responseで詰まりやすい3つの書き方の違い
待ち受けの「外」で操作してしまう
前の節で触れたとおり、page.goto()やclick()をwithの外に出すと応答を取り逃します。原因が分かりにくいのは、手元で遅い回線のときはたまたま取れて、速い回線やサーバー上では毎回タイムアウトする、という形で現れるためです。「通信を起こす操作は、必ず待ち受けのwithの中」と決めておけば避けられます。
条件が広すぎて別の応答を掴む
判定を"api" in response.urlのように緩くすると、同じタイミングで流れる計測用やおすすめ欄用のAPIを先に掴むことがあります。開発者ツールで確かめたURLの、一覧に固有の部分まで含めて判定してください。同じパスでも「件数だけ返す」「中身を返す」の2種類がある場合は、response.request.methodやクエリ文字列の有無でさらに絞ります。
応答の後に描画を待ち直してしまう
JSONを受け取った後にwait_for_selectorを足すと、また見た目の変更に弱い書き方に戻ります。データはすでに手元にあるので、DOMは「次へ」ボタンのような操作対象を探すときだけ使うのが今の書き方です。
症状 | よくある原因 | 直し方 |
|---|---|---|
毎回タイムアウトする | 操作を待ち受けの外で行っている |
|
想定と違う中身が取れる | 判定条件が広い | URLの固有部分・メソッドで絞る |
0件で終わる | キー名違い、またはエラー応答をそのまま読んだ | キーを表示して確認、 |
取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔
JSONを直接受け取れると処理が速くなる分、相手のサーバーへの配慮を忘れやすくなります。次の点は毎回確認してください。
- robots.txt:ページのURLだけでなく、裏で叩かれるAPIのパスも対象に入っていないか確かめます。robots.txtに法的な拘束力はありませんが、サイト運営者の明確な意思表示であり、無視したアクセスは業務妨害の成否や悪質性を判断する材料になり得ます。Pythonなら標準ライブラリの
urllib.robotparserで確認できます。 - 利用規約:Amazon、X(旧Twitter)、Instagramなど多くの大手プラットフォームは規約で自動収集を禁じています。規約違反は直ちに犯罪ではありませんが、民事訴訟のリスクがあります。「スクレイピング」という語を使わずに「ロボット等による収集」と書かれていることもあるので、注意して読みます。
- 公式APIがあればそちらを使う:正規のAPIが提供されているなら、それを使うのが最も安全です。逆に、公式に提供されていた経路が閉じられることもあります。Redditは2026年9月30日に、RSSフィードを2026年11月13日に終了し、公開データAPIへのアクセスも2027年3月までに終了する計画を発表しました。提供側が自動収集を止める方針を示したサイトでは、画面の裏のAPIを使う書き方に切り替えても趣旨に反します。
- アクセス間隔:コードの
WAIT_SEC = 5は、ページ送りのたびに数秒空けるためのものです。国内では、1秒に1回程度のアクセスを行った人が逮捕された(後に起訴猶予)事例も報じられています。1日1回の実行で数ページを、間隔を空けて取る程度に留め、MAX_PAGESで上限を決めておきます。 - 認証や制限を回り込まない:ログインが必要なデータを他人のIDで取る、仕組みの弱点を突いて認証を回避する、といった行為は不正アクセス禁止法違反です。ブロックされたときにIPやブラウザの名乗りを替えて取り直すような書き方もしません。止められたら、そのサイトからは取らないのが答えです。
- 個人情報・著作物:取得したデータに個人情報や著作物(文章・画像)が含まれる場合は、使い道によって法的な評価が変わります。社内の集計に使うのか、外に出すのかを先に決めておきます。
Playwrightのスクリプトを毎日止めずに動かす:手元PC・共有サーバーのcron・VPS
requestsだけのスクリプトと違い、Playwrightはブラウザ本体とそれが依存するシステムライブラリを必要とし、メモリも相応に使います。これが「どこで動かせるか」を大きく左右します。
3つの置き場所と、それぞれが止まる条件
置き場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(launchd/タスクスケジューラ) | 週に数回、PCを開いている時間帯に動けば足りる | スリープ中・フタを閉じている間・電源オフ。時刻になってもPCが寝ていれば、その回は実行されません |
共有レンタルサーバーのcron | requests+BeautifulSoupで済む、ブラウザ不要の取得 | Playwrightの場合はブラウザの依存ライブラリをsudoで入れられず起動できないことが多い。プロセスの実行時間・メモリの上限で途中で止められることもある |
VPS・常時稼働の自宅サーバー(systemd timer) | Playwrightを毎日決まった時刻に確実に動かしたい | サーバー自体の停止・停電、ディスクの逼迫、Pythonのサポート終了後に放置すること |
判断の目安はシンプルです。JSONのURLが分かり、それが普通のHTTPリクエストで取れて利用が認められているなら、ブラウザを使わずrequestsで書き直せば共有サーバーのcronでも動きます。ページを開く操作が必要なままなら、Playwrightが動く環境、つまりVPSか常時稼働のマシンが要ります。手元のMacで回す場合は、launchdの登録方法が変わっている点に注意してください(launchd定期実行はbootstrapで|2026年10月)。
VPSでの設定例(systemd timer)
systemd timerは、cronより実行ログが追いやすく、止まっていた間の回を起動後に取り戻す設定もできます。ユーザーusername、ホストmyserver、スクリプトを/home/username/scraper/に置いた例です。
# /home/username/.config/systemd/user/scraper.service
[Unit]
Description=Daily item scraper
[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/fetch_items.py
TimeoutStartSec=900
# /home/username/.config/systemd/user/scraper.timer
[Unit]
Description=Run item scraper every morning
[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
[Install]
WantedBy=timers.target
ExecStartは仮想環境のpythonを絶対パスで:systemdやcronは、ログイン時とは違う最小限の環境で実行します。pythonとだけ書くと、システムの別バージョンや、Playwrightの入っていないPythonが使われます。TimeoutStartSec=900:ブラウザが固まった場合に15分で打ち切ります。上限が無いと、翌日の実行まで居座ることがあります。Persistent=true:サーバーが止まっていた時間帯の回を、起動後にまとめて実行します。- ブラウザ本体はサービスと同じユーザーで入れる:
playwright installはユーザーごとのキャッシュにブラウザを置くため、別ユーザーで入れると「ブラウザが見つからない」で起動しません。
有効化とログの確認は次のとおりです。ログアウト後も動かし続けるにはloginctl enable-lingerが必要です。
systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
loginctl enable-linger username
systemctl --user list-timers
journalctl --user -u scraper.service
動き続けているかを確かめる仕組み
- 0件を異常終了にする:コードの
sys.exit(1)がこれです。APIのパスや項目名が変わったとき、エラーにならず空のCSVが毎日増えるだけ、という壊れ方を防ぎます。 - 件数の推移を見る:前日の件数から極端に減ったら知らせる、といった判定を足すと、部分的な欠けにも気づけます。
- Pythonのサポート期間を控えておく:Python 3.13以降はバグ修正2年・セキュリティ修正3年(リリースから計5年)です。サーバーに入れたPythonのサポート終了時期をメモしておき、その前に入れ替えます。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
まとめ:expect_responseで受け取り、止まらない場所で回す
動的化したページからデータを取るとき、wait_for_selectorで描画を待ってHTMLを読む書き方は、見た目の変更のたびに壊れます。今は、Playwrightのpage.expect_responseで一覧の元になったJSONを受け取り、pd.json_normalize()で表にするのが確実です。要点は「判定条件をURLの固有部分で絞る」「通信を起こす操作は必ず待ち受けのwithの中で行う」「0件やエラー応答を異常終了にする」の3つです。取得の前にはページとAPIの両方のrobots.txt、利用規約を確かめ、数秒の間隔と上限ページ数を守ってください。毎日動かすなら、ブラウザが要るまま手元PCや共有サーバーのcronに置くとスリープや制限で止まります。VPSか常時稼働のマシンでsystemd timerを使い、仮想環境のPythonを絶対パスで指定しておけば、翌朝も同じように動き続けます。