ChatGPTやClaude Codeに「ECサイトの価格を毎朝取ってCSVに残すスクリプト」をPlaywrightで書かせると、手元では動きます。それなのに定期実行に載せた途端、朝だけ TimeoutError で止まる。そんな相談をよく受けます。原因の多くは、AIが書く page.goto(wait_until="networkidle") と page.wait_for_timeout() です。どちらも公式ドキュメントで非推奨(DISCOURAGED)とされています。この記事では、AIへの最初の指示、出てきたコードのどこが危ういか、直させる指示文、直った後のコードを順に示します。最後に、手元のPC・共有サーバーのcron・VPSのどこで毎日動かし続けるかまで書きます。

AIが書くPlaywrightの価格監視は networkidle と wait_for_timeout で待っている

最初の指示はこう書かれていることが多い

コードを書き慣れていない人がAIに最初に送る指示は、だいたい次のような一文です。私も依頼者から見せてもらう指示文の多くがこの形でした。

PythonのPlaywrightで、https://shop.example.com/item/12345 の商品価格を取得して、
日時と一緒に prices.csv に追記するスクリプトを書いてください。毎朝実行したいです。

この指示には「ページの何を待つか」「失敗したらどうするか」が書かれていません。そのためAIは、もっとも汎用的に見える待ち方を選びます。

出てきたコード(問題のある例)

from playwright.sync_api import sync_playwright
import csv, datetime

URL = "https://shop.example.com/item/12345"
WEBHOOK = "https://example.com/webhook/xxxx"

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    try:
        page.goto(URL, wait_until="networkidle")
        page.wait_for_timeout(3000)
        price = page.query_selector(".price").inner_text()
        with open("prices.csv", "a") as f:
            csv.writer(f).writerow([datetime.datetime.now(), price])
    except Exception:
        pass
    browser.close()

手元で1回動かすと、価格はCSVに入ります。だから「完成した」と思ってしまいます。ところが、このコードには毎日動かすと止まる要素が5つ入っています。

networkidle が定期実行で止まる理由

wait_until="networkidle" は「ページの通信がしばらく途絶えるまで待つ」という指定です。ECサイトの商品ページでは、価格の表示が終わったあとも通信が続きます。広告の差し替え、計測タグの送信、レコメンド枠の読み込み、チャットウィジェットの接続確認などです。これらが通信を続けている限り「途絶えた」状態にならず、goto は既定のタイムアウトまで待ってから失敗します。

手元で動いたのは、たまたまその回の広告や計測の通信が早く終わったからです。配信される広告は毎回違い、早朝はタグの送り先の応答時間も変わります。動くかどうかが相手サイトの広告事情で決まるので、毎朝の実行では止まる日が出ます。公式ドキュメントが networkidle をテスト用途で非推奨にしているのは、この不安定さが理由です。

wait_for_timeout(3000) は「3秒で足りる」という賭け

page.wait_for_timeout() は、ただ指定時間だけ止まる命令です。公式ドキュメントでは本番での使用を避けるよう書かれています。3秒で価格が出る日もあれば、出ない日もあります。出なかった日は価格要素が見つからず、次の行で落ちます。逆に、1秒で出ている日も必ず3秒待つので無駄になります。

AI生成コードで確認すべき5箇所と、直させる指示文

コードを全部読めなくても、次の5箇所は文字列検索で見つけられます。AIが書いたスクリプトを受け取ったら、まずエディタの検索でこれらを探してください。

検索する文字列

何が危ないか

今の書き方

networkidle

広告・計測の通信が続くサイトでは待ちが終わらずタイムアウトする

wait_until="domcontentloaded" で読み込み、目的の要素だけを待つ

wait_for_timeout / time.sleep(取得前)

固定秒数の賭けになる。足りない日は落ち、足りる日は無駄に待つ

expect(locator).to_be_visible() か locator.wait_for()

query_selector

要素が無いと None が返り、次の .inner_text() で落ちる。自動で待たない

page.locator()(自動待機つき)

except Exception: の直後の pass

失敗しても何も記録されず、正常終了扱いになる。何日も止まっていたことに気づけない

ログに残し、通知し、終了コードを1にする

"https:// や "sk- で始まる直書きの値

Webhook URL・APIキーがコードに入ったまま共有・バックアップされる

環境変数から読む(os.environ.get())

加えて、open( に encoding= が無いかも見てください。2026年10月9日にリリースされたPython 3.15では、ファイルを開くときの既定のエンコーディングがUTF-8になりました。ただ、古いPythonの入ったサーバーで動かすと、同じコードでも日本語が化けることがあります。どの環境でも同じ結果になるよう、encoding="utf-8" を明示させておく方が安全です。

直させる指示文(そのままコピーして使える形)

「エラーが出たので直して」だけでは、AIは wait_for_timeout の秒数を増やして返してくることがあります。何をどう直すかを条件として書くのがコツです。

先ほどのスクリプトを、毎朝の定期実行で止まらないように次の条件で書き直してください。

1. page.goto の wait_until="networkidle" は使わない。"domcontentloaded" で読み込み、
   価格の要素だけを page.locator() と expect(...).to_be_visible(timeout=...) で待つ。
2. page.wait_for_timeout() と取得前の time.sleep() は使わない。
3. query_selector は使わず locator を使う。
4. except で握りつぶさない。失敗したURLは logging でファイルに記録し、
   失敗時のスクリーンショットを保存し、最後に終了コード1で終わる。
5. 通知先のWebhook URLはコードに書かず、環境変数 NOTIFY_WEBHOOK_URL から読む。
6. 複数URLを取るときは1件ごとに10秒空ける。並列化はしない。
7. CSVとログは encoding="utf-8" を明示し、スクリプトと同じフォルダに書く
   (cronから起動しても場所がずれないように)。
8. 変更した箇所ごとに、なぜそう変えたかを日本語で説明する。

8番目は、読者が「AIの修正が正しいか」を判断するための材料です。説明が付いていれば、条件を守ったかを自分で照合できます。

直った後のコード:expect(locator).to_be_visible() で価格だけを待つ

実行環境

  • Python:3.12以降を想定。Python 3.15は10月9日に出たばかりです。依存パッケージが新しいPythonへの対応を終えるまで時間がかかることがあるので、pip install が通らなければひとつ前のバージョンで仮想環境を作ってください。
  • パッケージ:pip install playwright requests
  • ブラウザ本体:python -m playwright install chromium(Linuxのサーバーでは依存ライブラリも要るので --with-deps を付けます。root権限が必要です)
  • 入ったバージョンは pip show playwright で確認します。この記事は特定のバージョン番号に依存しない書き方にしています。
import csv
import logging
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path

import requests
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import expect, sync_playwright

BASE = Path(__file__).resolve().parent
URLS = [
    "https://shop.example.com/item/12345",
    "https://shop.example.com/item/67890",
]
PRICE_SELECTOR = "[data-testid='price']"
INTERVAL_SEC = 10
TIMEOUT_MS = 30_000

logging.basicConfig(
    filename=BASE / "watch.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    encoding="utf-8",
)


def notify(text):
    url = os.environ.get("NOTIFY_WEBHOOK_URL")
    if not url:
        logging.warning("通知先が未設定のため送信しない: %s", text)
        return
    try:
        requests.post(url, json={"text": text}, timeout=10)
    except requests.RequestException:
        logging.exception("通知の送信に失敗")


def fetch_price(page, url):
    page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT_MS)
    price = page.locator(PRICE_SELECTOR).first
    expect(price).to_be_visible(timeout=TIMEOUT_MS)
    expect(price).to_have_text(re.compile(r"\d"), timeout=TIMEOUT_MS)
    return price.inner_text().strip()


def main():
    rows, failures = [], []
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        for i, url in enumerate(URLS):
            if i:
                time.sleep(INTERVAL_SEC)
            try:
                price = fetch_price(page, url)
                rows.append([datetime.now().isoformat(timespec="seconds"), url, price])
            except (PlaywrightError, AssertionError):
                logging.exception("取得失敗: %s", url)
                failures.append(url)
                try:
                    page.screenshot(path=BASE / f"fail_{i}.png", full_page=True)
                except PlaywrightError:
                    logging.exception("スクリーンショットも失敗")
        browser.close()

    with open(BASE / "prices.csv", "a", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(rows)

    logging.info("取得 %d件 / 失敗 %d件", len(rows), len(failures))
    if failures:
        notify(f"価格取得に失敗: {len(failures)}件\n" + "\n".join(failures))
        sys.exit(1)


if __name__ == "__main__":
    main()

ブロックごとの説明

  • 定数のブロック:BASE はスクリプト自身の置き場所です。cronやsystemdから起動すると「今いるフォルダ」が自宅ディレクトリなどにずれるため、CSVとログの場所をこれで固定します。PRICE_SELECTOR は、ブラウザの開発者ツールで価格の要素を右クリック→「検証」して確かめた値に置き換えてください。data-testid や id のように見た目の変更で変わりにくい属性を優先します。
  • logging の設定:実行結果をすべて watch.log に残します。定期実行では画面に何も出ないので、ログが唯一の手がかりになります。
  • notify():Webhook URLを環境変数から読みます。未設定ならログに警告を残すだけで落ちません。通知の失敗で本処理まで巻き込まないよう、ここだけは例外を捕まえて記録します。
  • fetch_price():ここが主役です。domcontentloaded はHTMLの読み込みが終わった時点で戻るので、広告の通信が続いていても関係ありません。その後に価格の要素だけを待ちます。to_be_visible() で表示されるまで待ち、to_have_text() で数字が入るまで待ちます。後者を足しているのは、価格欄が先に枠だけ表示され、中身があとからJavaScriptで入るサイトがあるためです。「見えた=値が入った」とは限りません。
  • main() のループ:1件ごとに10秒空け、順番に取ります。失敗したURLは記録し、そのときの画面をPNGで保存します。翌朝その画像を見れば、セレクタが変わったのか、ページ自体が出ていないのかを区別できます。
  • 例外の種類:expect() は待っても条件を満たさないと AssertionError を出します。goto のタイムアウトは Playwright の TimeoutError(Error の一種)です。両方を捕まえないと、片方だけ素通りして途中で落ちます。AIが except TimeoutError だけを書いてきたら、ここを指摘してください。
  • 終了コード:1件でも失敗したら sys.exit(1) で終わります。systemdやcronの側で「失敗した」と記録されるので、外から気づけます。元のコードの except: pass は、ここを常に「成功」にしていました。

wait_for_selector との違い

page.wait_for_selector(PRICE_SELECTOR, state="visible") でも「目的の要素だけを待つ」ことはできます。AIがこちらを出してきたら、それ自体は networkidle よりはるかに安定します。ただ、今のPlaywrightは locator を中心に設計されていて、locator は操作や expect() のたびに自動で待ちます。新しく書くなら locator に寄せておくと、待ち方の書き漏れが減ります。

requests-htmlなど別の手段で書かれた古いスクリプトをPlaywrightへ載せ替える場合は、requests-htmlをPlaywrightへ|26年10月も参考になります。

取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔

毎日自動で同じサイトへアクセスする以上、相手にとって迷惑にならないことが前提です。AIは指示しない限りこの点を考慮しません。

  • robots.txt を確認する:https://shop.example.com/robots.txt を開き、商品ページのパスが Disallow になっていないかを見ます。
  • 利用規約を読む:ECサイトは規約で自動取得そのものを禁じていることがあります。禁じられていれば取得しません。公式のAPIや提携事業者向けのデータ提供があるなら、そちらを使います。
  • 間隔を空け、回数を絞る:価格の定点観測なら1日1回で足ります。上のコードは1件ごとに10秒空け、並列化していません。監視する商品が増えても、並列化で速くするのではなく、総所要時間が延びるのを受け入れます。
  • 拒否されたら止める:403や確認画面が出るようになったら、それは相手の意思表示です。回避する手段を探すのではなく、取得をやめるか、相手に許可を取ります。

日本では著作権法第30条の4により、情報解析目的のデータ取得は原則として認められています。ただし、サーバーに過度な負荷をかけるアクセスや、取得したデータを無断で再配布することは別の問題になります。取得した価格は自分の判断材料にとどめておくのが安全です。

毎日止めずに動かす場所:手元のPC・共有サーバーのcron・VPSが止まる条件

コードが正しくても、動かす場所が止まれば価格は記録されません。それぞれ「どんな条件で止まるか」を先に知っておくと、選び間違えを防げます。

置き場所

定期実行の仕組み

止まる条件

向いている人

手元のPC

Windowsのタスクスケジューラ、macOSのlaunchd、Linuxのcron・systemd timer

スリープ・電源オフ・ログアウト中は動かない。ノートPCを閉じた日は丸ごと抜ける。OS更新の再起動でも抜ける

まず数週間試したい人。抜けた日があっても困らない用途

共有レンタルサーバーのcron

管理画面からcronを登録

ブラウザ(Chromium)の依存ライブラリを入れる権限が無いことが多く、Playwright自体が起動しない場合がある。実行時間やメモリの制限でプロセスが途中で終了させられることもある

Playwrightを使わず requests だけで取れるサイト。契約前にサポート情報で「ヘッドレスブラウザが動くか」を確認する

VPS

cron または systemd timer

メモリ不足でChromiumが落ちる。Pythonやブラウザの更新を放置して動かなくなる。契約切れ・障害

Playwrightで毎日確実に取りたい人。最小構成でもメモリに余裕のあるプランを選ぶ

cronで動かすときに必ず入れる3点

cronは、ターミナルで手動実行したときと環境が違います。手動で動いたのにcronで動かない原因の大半はここです。

  1. Pythonは仮想環境の絶対パスで指定する:cronの PATH は短いので、python とだけ書くと別のPythonが起動し、Playwrightが見つかりません。
  2. 環境変数はcron側で渡す:ログイン時に読み込んでいる設定はcronでは読まれません。通知先のURLはファイルに分け、権限を自分だけに絞ります(chmod 600)。
  3. 出力をファイルに残す:スクリプトのログとは別に、Python自体が起動できなかったときのエラーも拾えるようにします。
0 7 * * * . /home/username/price-watch/.env; cd /home/username/price-watch && /home/username/price-watch/.venv/bin/python watch.py >> /home/username/price-watch/cron.log 2>&1

.env には export NOTIFY_WEBHOOK_URL=... の1行だけを書きます。この方法だとPythonの外で環境変数を渡すので、python-dotenv は不要です。

VPSなら systemd timer で「止まっていた分」も拾う

Linuxのサーバーなら、cronより systemd timer を勧めます。Persistent=true を付けておくと、再起動などで7:00を逃しても、起動後にその回を実行してくれます。さらに、終了コードが1なら「失敗」として記録されるので、systemctl --user status で前回の結果が分かります。

# /home/username/.config/systemd/user/price-watch.service
[Unit]
Description=価格の定点観測

[Service]
Type=oneshot
WorkingDirectory=/home/username/price-watch
EnvironmentFile=/home/username/price-watch/.env
ExecStart=/home/username/price-watch/.venv/bin/python watch.py

# /home/username/.config/systemd/user/price-watch.timer
[Unit]
Description=毎朝7時に価格を取得

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target

systemdの EnvironmentFile は export を付けない NOTIFY_WEBHOOK_URL=... の形で書きます(cron用の .env とは書式が違うので、ファイルを分けるか書式をそろえてください)。登録は systemctl --user enable --now price-watch.timer です。ログアウト後も動かすには、一度だけ loginctl enable-linger username を実行します。これを忘れると、SSHを切った時点でタイマーも止まります。

「止まったことに気づく」仕組みは場所を問わず要る

どこに置いても、いつかは止まります。サイトのHTMLが変われば、セレクタは何の前触れもなく外れます。だからこそ、上のコードでは「失敗したら通知・スクリーンショット・終了コード1」を必須にしました。もう一歩進めるなら、成功したときも件数をログに残し、週に一度CSVの最終行の日付を確かめる習慣をつけてください。通知が来ないことと、正常に動いていることは別です。通知の仕組みそのものが壊れていても、通知は来ないからです。同じ通知を毎日繰り返さない工夫は sqlite3 datetime非推奨と重複通知|26年10月 で扱っています。

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

まとめ:AIに「何を待つか」と「失敗したらどうするか」を指示する

ChatGPTやClaude Codeが書くPlaywrightのスクリプトは、networkidle と wait_for_timeout() で待っていることが多くあります。どちらも公式ドキュメントで非推奨です。広告や計測の通信が続くECサイトでは、手元で動いても毎朝の定期実行で TimeoutError になります。

直し方は、domcontentloaded で読み込み、expect(locator).to_be_visible() と to_have_text() で価格の要素だけを待つことです。AIには「使わない書き方」「失敗時の動き」「秘密の置き場所」「アクセス間隔」を条件として渡すと、狙った形で返ってきます。受け取ったコードは、networkidle・wait_for_timeout・query_selector・except: pass・直書きのURLを検索するだけで、危うい箇所を見つけられます。

動かす場所は、試すなら手元のPCで十分です。共有サーバーはPlaywrightが起動できない場合があるので、事前の確認が要ります。毎日確実に取るならVPSでsystemd timerの Persistent=true を使います。どの場所でも、失敗を通知して終了コード1で終わる仕組みを入れておくことが、止めずに動かし続ける一番の近道です。