Playwright for Pythonで page.wait_for_selector と query_selector を組み合わせたスクレイピングが、手元では動くのにサーバーの定期実行だとたまに落ちる。そんな経験はないでしょうか。原因の多くは、取得した要素がページの再描画で置き換わっていることにあります。今の書き方は、自動で待つ page.locator と expect に任せることです。この記事では書き換え方を示し、Ubuntu 24.04で playwright install --with-deps と systemd timer(Persistent=true)を使って毎日止めずに動かすところまでを解説します。

Playwright 1.63.0時点で wait_for_selector・query_selector から locator へ移る理由

Playwrightの安定版は2026年9月4日リリースの1.63.0です(その前は7月30日の1.62、6月18日の1.61)。Playwrightは開発を主導するMicrosoftが「自動待機(auto-wait)」を最大の特徴に掲げています。自動待機とは、クリックや文字の取得の前に、対象の要素が操作できる状態になるまでライブラリ側が待つ仕組みです。

この自動待機を活かせるのは Locator を使ったときだけです。query_selector が返す ElementHandle(取得した瞬間のDOM要素そのものへの参照)や、wait_for_selector で一度待ってから取り直す書き方は、公式ドキュメントで推奨されない(discouraged)扱いになっています。

ElementHandle が動的ページで壊れる仕組み

最近のサイトは、JavaScriptで画面を組み立てるクライアントサイドレンダリングが主流です。表示直後に一覧が再描画されることも珍しくありません。ElementHandle は「そのとき掴んだ要素」を指し続けるので、要素が差し替わると参照先が消えてしまいます。そうなると次のエラーで止まります。

Error: Element is not attached to the DOM

一方、Locator は「どう探すか」という条件だけを保持しています。操作するたびに探し直すので、再描画を挟んでも最新の要素を使えます。これが書き換える一番の理由です。

以前の書き方と今の書き方の対応表

やりたいこと

以前の書き方

今の書き方

要素が出るまで待つ

page.wait_for_selector(".item")

expect(page.locator(".item").first).to_be_visible()

1件取得

page.query_selector(".item")

page.locator(".item")

全件取得

page.query_selector_all(".item")

page.locator(".item").all()(待った後に呼ぶ)

子要素の文字

el.query_selector(".name").inner_text()

card.locator(".name").inner_text()

表示の切り替わりを待つ

time.sleep(5)

expect(locator).to_have_text("2")

なお調査では、1.63で可視要素だけに絞る locator.visible() が :visible 疑似クラスの推奨代替として追加されたとされています。Python版で使えるかは、手元の版を pip show playwright で確かめ、公式リリースノートで確認してから使ってください。

locator と expect で動的ページを取得するPythonコード(Ubuntu 24.04)

実行環境

  • OS:Ubuntu 24.04(サーバー版でも同じ手順で動きます)
  • Python:OS標準の python3 を venv(プロジェクト専用の独立したPython環境)で使います。版は python3 -V で確認します
  • パッケージ:playwright==1.63.0、pandas

Playwrightのバージョンを固定するのには理由があります。PythonパッケージとPlaywrightがダウンロードするブラウザ本体は、版の組み合わせが決まっています。サーバーで pip install -U を実行して版だけ上がると、ブラウザが見つからずに止まります。上げるときは、下の playwright install も必ず一緒にやり直してください。

mkdir -p /home/username/scraper/data /home/username/scraper/logs
cd /home/username/scraper
python3 -m venv .venv
.venv/bin/pip install "playwright==1.63.0" pandas

# ブラウザが動くのに必要なOSのライブラリ(aptで入るためsudoが要る)
sudo .venv/bin/python -m playwright install-deps chromium
# ブラウザ本体は、定期実行するユーザー自身で入れる
.venv/bin/python -m playwright install chromium

playwright install --with-deps chromium を使えば1行で両方を入れられます。ただし sudo 付きで実行すると、ブラウザ本体が root のホームディレクトリに入ります。すると systemd から一般ユーザーで動かしたときにブラウザが見つかりません。依存ライブラリだけを sudo で入れ、ブラウザ本体は実行ユーザーで入れる、という2段階に分けるのが確実です。

取得部分のコード

import sys
import time
from datetime import date
from pathlib import Path

import pandas as pd
from playwright.sync_api import sync_playwright, expect
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError

BASE = Path("/home/username/scraper")
URL = "https://example.com/products"
MAX_PAGES = 3
INTERVAL_SEC = 3


def scrape_page(page):
    cards = page.locator(".product-card")
    expect(cards.first).to_be_visible()
    rows = []
    for card in cards.all():
        rows.append({
            "name": card.locator(".name").inner_text().strip(),
            "price": card.locator(".price").inner_text().strip(),
        })
    return rows


def main():
    rows = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)
        try:
            page.goto(URL, wait_until="domcontentloaded")
            for n in range(1, MAX_PAGES + 1):
                rows.extend(scrape_page(page))
                next_link = page.get_by_role("link", name="次へ")
                if n == MAX_PAGES or next_link.count() == 0:
                    break
                time.sleep(INTERVAL_SEC)
                next_link.click()
                expect(page.locator(".pagination .current")).to_have_text(str(n + 1))
        except PlaywrightTimeoutError:
            page.screenshot(path=str(BASE / "logs" / f"failed_{date.today()}.png"))
            print("要素が時間内に現れませんでした。サイトの構造が変わった可能性があります", file=sys.stderr)
            sys.exit(1)
        finally:
            browser.close()

    df = pd.DataFrame(rows)
    out = BASE / "data" / f"products_{date.today()}.csv"
    df.to_csv(out, index=False, encoding="utf-8-sig")
    print(f"{len(df)}件を保存しました: {out}")


if __name__ == "__main__":
    main()

ブロックごとの説明

  • scrape_page():page.locator(".product-card") は「商品カードを探す条件」を作るだけで、この時点ではまだ何も探していません。expect(cards.first).to_be_visible() で、1枚目が表示されるまで待ちます。これが wait_for_selector の置き換えです。
  • cards.all():その時点で見つかった件数ぶんの Locator をリストで返します。all() 自体は待たないので、必ず expect で待ってから呼びます。この順番を逆にすると、描画前に呼んで0件になります。
  • card.locator(".name").inner_text():カードの中から名前を探して文字を取り出します。Locator なので、取り出す直前に探し直して自動で待ちます。以前のように query_selector が None を返し、AttributeError で落ちることはありません。要素が見つからなければタイムアウトとして扱われます。
  • get_by_role("link", name="次へ"):見た目の役割とリンク文字で探します。CSSのクラス名はサイト改修でよく変わりますが、「次へ」というリンクは変わりにくいので長持ちします。click() も、押せる状態になるまで自動で待ちます。
  • to_have_text(str(n + 1)):ページ番号の表示が次の番号に変わるまで待ちます。time.sleep(5) のように「たぶん5秒あれば足りる」と決め打ちする書き方の置き換えです。
  • time.sleep(INTERVAL_SEC):コード内に sleep が1箇所だけ残っています。これは要素を待つためではありません。取得先へのアクセス間隔を空けるためのもので、目的がまったく違います。
  • except と sys.exit(1):タイムアウトしたら画面を保存し、終了コード1で終わります。定期実行では、失敗を失敗として記録させることが何より大事です。except Exception: pass で握りつぶすと、何日も空のCSVが作られ続けても気づけません。
  • to_csv(..., encoding="utf-8-sig"):Excelで開いても文字化けしない形式で、日付付きのCSVに保存します。

Seleniumで同じことをする場合は、WebDriverWait と expected_conditions を使った明示的な待機が今も基本です。Selenium 4.49.0(2026年9月9日リリース)での書き方は Selenium 4で価格監視をAIに書かせる2026年9月版 にまとめています。

robots.txt・利用規約・アクセス間隔:取得先に負荷をかけない書き方

日本の法律には、スクレイピングを一律に禁止する規定はありません。総務省も消費者物価指数(CPI)の調査にWebスクレイピングを使っています。ただし、次のような場合は法的責任を問われる可能性があります。

  • 相手のサーバーに過度な負荷をかけ、業務を妨げた場合
  • 同意した利用規約に違反した場合
  • 取得した著作物を転載・再公開した場合(情報解析目的は著作権法第30条の4で比較的広く認められていますが、転載は別の問題です)
  • 個人情報を不適切に扱った場合
  • ログインなどの認証を突破した場合(不正アクセス禁止法に触れる可能性があります)

robots.txt そのものに法的な拘束力はありません。それでも、サイト運営者の意思表示です。無視してアクセスし続ければ、悪質性を判断する材料になり得ます。取得の前に、次のように機械的に確認しておきましょう。

from urllib.robotparser import RobotFileParser

rp = RobotFileParser("https://example.com/robots.txt")
rp.read()
if not rp.can_fetch("*", URL):
    print("robots.txt で許可されていないため取得しません", file=sys.stderr)
    sys.exit(1)
  • 頻度:必要な頻度だけにします。1日1回で足りるデータなら、1日1回にします。
  • 間隔:ページ遷移の間には、上のコードのように数秒の間隔を空けます。並列で一気に取りに行く書き方はしません。
  • 範囲:MAX_PAGES のように上限を決めます。無限にページを辿らないようにします。
  • 公式API:公式APIが提供されているなら、そちらを優先します。スクレイピングより安定していて、規約違反のリスクも低くなります。
  • ブロックされたら止める:CAPTCHAやアクセス制限が出たら、それはサイト側が「来ないでほしい」と示しているサインです。回避策を探さず、取得をやめて規約を確認してください。

systemd timer(Persistent=true)で毎日止めずに動かす設定

service と timer のユニットファイル

処理の中身を書く service と、起動時刻を書く timer の2つを /etc/systemd/system/ に置きます。

# /etc/systemd/system/scraper.service
[Unit]
Description=Daily product scraper
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=username
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
# /etc/systemd/system/scraper.timer
[Unit]
Description=Run scraper every morning

[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now scraper.timer
systemctl list-timers scraper.timer          # 次回の実行予定を確認
sudo systemctl start scraper.service         # 手動で1回動かして確認
journalctl -u scraper.service -n 50          # 出力と終了コードを見る
  • Type=oneshot:1回動いて終わる処理であることを示します。
  • User=username:ブラウザ本体を入れたユーザーと同じにします。ここが食い違うと、ブラウザが見つからずに落ちます。
  • After=network-online.target:起動直後にネットワークがまだ無い状態で走り、失敗するのを避けます。
  • Persistent=true:予定時刻にマシンが止まっていた場合、次に起動したときに1回だけ実行します。取りこぼしを防げますが、3日止まっていても3回ではなく1回しか走りません。日付ごとに必要なデータを埋め直す処理は別途必要です。

スクリプトは失敗時に終了コード1を返します。そのため失敗した日は systemctl status scraper.service に failed と残り、journalctl で理由を追えます。ログが溜まってディスクを圧迫しないかの点検は journalctl --disk-usageでログ点検2026 を参考にしてください。

手元のPC・共有レンタルサーバーのcron・VPSのどれで動かすか

置き場所

向いている条件

止まる条件

手元のPC(macOSのlaunchd、Linuxのsystemd timer)

試験運用中、または毎日PCを起動する場合

スリープ中・電源オフ中は動きません。launchdはスリープ中に予定時刻を迎えると発火しません。持ち出し中や再起動のタイミングにも左右されます

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

requests と BeautifulSoup で済む、ブラウザ不要の取得

Playwrightの動作に必要なOSライブラリを apt で入れられないことが多く、ブラウザが起動しません。プロセスやメモリの制限で強制終了される場合もあります

VPS・常時稼働の自宅サーバー(Ubuntu 24.04 + systemd timer)

Playwrightでブラウザを動かす取得を毎日続ける場合

契約切れ・停電のほか、OSやブラウザの更新を放置した場合、ディスクが満杯になった場合、取得先のHTML構造が変わった場合に止まります

Playwrightでブラウザを動かす処理を毎日続けるなら、現実的な選択肢はVPSか常時稼働のマシンです。どれを選んでも最後に止まる原因になるのは「取得先の構造変更」です。これはタイムアウトとして終了コード1で現れるので、failed を見たら保存したスクリーンショットでセレクタを見直してください。

関連する選択肢

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

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

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

まとめ:Playwrightは locator に探させ、expect に待たせる

Playwright 1.63.0時点での今の書き方は、page.locator で「探す条件」を作り、expect で表示や文字の変化を待つことです。wait_for_selector と query_selector の組み合わせは公式に推奨されていません。ElementHandle は再描画で消えるので、動的ページでは不安定になります。all() は待たないので、必ず expect の後に呼びます。コードに残す sleep は、取得先への配慮のためのアクセス間隔だけにします。

サーバーでは、install-deps を sudo で、ブラウザ本体を実行ユーザーで入れます。版は playwright==1.63.0 のように固定し、上げるときは playwright install もやり直します。systemd timer の Persistent=true で停止中の取りこぼしを1回ぶん補い、失敗は終了コードで記録します。取得前には robots.txt を確認し、頻度と範囲は必要最小限に抑えてください。