Playwrightでスクレイパーを書こうとAIに頼むと、今でも page.query_selector や page.wait_for_selector を使ったコードがよく返ってきます。手元で1回動かすと通るのに、systemd timerで毎日回すと、ある日だけ NoneType で落ちる。こういう経験はないでしょうか。原因はたいてい「その瞬間の要素をつかむ」書き方にあります。この記事では、Playwright(Python版)の locator・get_by_role の自動待機と locator.wait_for() を使った今の書き方を示します。あわせて、そのスクリプトを毎日止めずに動かすところまで説明します。
query_selector・wait_for_selector がAIの出力に残り続ける理由と、Playwright公式の扱い
公式ドキュメントでは「非推奨(discouraged)」の扱い
Playwrightの公式APIリファレンスでは、page.query_selector・page.query_selector_all・page.wait_for_selector と、それらが返す ElementHandle(DOM上の特定の要素1つを指す参照)系のメソッドに「使わず、locatorを使うこと」という注記が付いています。削除されたわけではないので、古いコードは今も動きます。ただし、新しく書くコードで選ぶ理由はありません。
2026年10月時点で提供されている Playwright 1.61 でも、ライブラリの売りは組み込みの自動待機(auto-wait)です。そしてこの自動待機が効くのは locator 経由で操作したときだけです。手元のバージョンは次のコマンドで確認できます。
python3 -V
pip show playwrightAIが古い書き方を出してくるのはなぜか
生成AIは、ネット上に大量に残っている過去のコード例から学習しています。Playwright初期の記事や、Seleniumの find_element に近い書き方の解説は量が多いです。そのため「要素を探す=query_selector」という対応を今も出してきます。AIの出力が動くかどうかと、毎日動き続けるかどうかは別の問題です。前者はその場で試せば分かりますが、後者は運用して初めて表面化します。
AIに直させるときは、指示に次のような一文を加えると、locator前提のコードが返ってきやすくなります。
Playwright(Python・sync_api)で書いてください。
page.query_selector / query_selector_all / wait_for_selector と ElementHandle は使わず、
page.locator() または get_by_role() を使ってください。
要素が出るまで待つ処理は locator.wait_for() で書き、time.sleep() で待たないでください。古いAPIをAIに書き直させる流れは、OpenAIの旧APIでも同じです。手順はChatCompletion廃止をAIに直させる|26年10月にまとめています。
以前の書き方と今の書き方:ElementHandle と locator の違いを1つのコードで比べる
以前の書き方(ElementHandle)
page.goto("https://books.toscrape.com/")
page.wait_for_selector("article.product_pod")
items = page.query_selector_all("article.product_pod")
for item in items:
title = item.query_selector("h3 a").get_attribute("title")
price = item.query_selector("p.price_color").inner_text()このコードは次の3つのことをしています。
wait_for_selectorが、最初の1件が現れるまで待つquery_selector_allが、その瞬間DOMにある要素をまとめてつかんで ElementHandle のリストにする- ループの中で、つかんだ要素の中をさらに
query_selectorで探す
問題は2と3にあります。query_selector は待たずに即座に探し、無ければ None を返します。その結果、価格がJavaScriptで後から描画されるページでは、読み込みが少し遅い日だけ 'NoneType' object has no attribute 'inner_text' で落ちます。また、つかんだ要素がページ側で描き直されると、ElementHandle は古いDOMを指したまま残ります。調査結果にもあるとおり、2026年現在は大半のサイトがJavaScriptでコンテンツを後から差し込む作りです。初期HTMLだけでは中身が揃わない「スケルトン型」の配信が主流なので、この書き方では落ちる確率が上がり続けます。
今の書き方(locator)
cards = page.locator("article.product_pod")
cards.first.wait_for(state="visible")
for card in cards.all():
title = card.locator("h3 a").get_attribute("title")
price = card.locator("p.price_color").inner_text()- 1行目:
page.locator()は要素をつかみません。「この条件で探す」という探し方の定義を作るだけです。 - 2行目:
locator.wait_for(state="visible")で、1件目が画面に表示されるまで待ちます。既定のタイムアウト(30秒)を過ぎると例外になります。 - 3〜5行目:
card.locator(...)も探し方の定義です。get_attribute()やinner_text()を呼んだ時点で探し直し、要素が付くまで自動で待ちます。
2行目の wait_for が必要な理由も押さえておいてください。locator.all() 自体は待たない仕様なので、ページが空のうちに呼ぶと空のリストが返り、0件のCSVが「正常終了」で保存されます。locatorの自動待機は「操作するとき」に効くもので、「件数を数えるとき」には効きません。だから一覧を取る前に、1件目を wait_for で待っておきます。
違いを表で整理する
項目 | 以前(query_selector 系) | 今(locator 系) |
|---|---|---|
要素の扱い | その瞬間の要素をつかむ(ElementHandle) | 探し方を保持し、操作のたびに探し直す |
見つからないとき | None を返す(後の行で落ちる) | タイムアウトまで待ち、だめなら例外 |
ページの描き直し | 古い要素を指したまま残る | 操作時点のDOMを使う |
待機の書き方 |
| 自動待機+ |
複数一致 | 先頭を黙って返す | 操作時に複数あればエラー(厳密モード) |
最後の行の厳密モードは、最初は面倒に感じるかもしれません。ただ、サイトの改修でセレクタが2箇所に一致するようになったとき、黙って違う要素を読むより、エラーで止まってくれるほうが助かります。複数あるのが正しい場面では、.first や .nth(i) で明示します。
get_by_role:見た目のクラス名ではなく役割で探す
調査結果でも、UI変更でクラス名が変わりセレクタが壊れる「DOM Drift」が、現場の大きな課題として挙がっています。get_by_role() は「リンク」「ボタン」といった要素の役割と、画面に出ている名前で探します。そのため、デザイン改修でクラス名が変わっても壊れにくくなります。
next_link = page.get_by_role("link", name="next", exact=True)exact=True を付けているのは、name が既定では部分一致・大文字小文字無視で比べられるためです。本のタイトルに「Next」を含む商品があると、そちらにも一致してしまいます。
systemd timer で毎日回しても落ちない Playwright スクレイパーの全体コード
実行環境
- Python 3 系、Playwright の同期API(
playwright.sync_api)。対応するPythonの下限はリリースごとに変わるため、PyPIの playwright のページで確認してください - Linux(Ubuntu/Debian系を想定)
- 取得先は、スクレイピング練習用に公開されているデモサイト books.toscrape.com(調査結果のPlaywrightの例と同じサイト)
python3 -m venv /home/username/scraper/.venv
source /home/username/scraper/.venv/bin/activate
pip install playwright pandas
playwright install --with-deps chromiumplaywright install はブラウザ本体を取得するコマンドです。--with-deps はChromiumが動くのに必要なOSのライブラリもまとめて入れます。このとき sudo を求められます。バージョンの指定はしていません。理由は、Pythonのplaywrightパッケージと、playwright install で入るブラウザの組み合わせが対応している必要があるからです。パッケージを上げたら playwright install chromium もやり直す、と覚えておけば十分です。
全体コード(/home/username/scraper/books.py)
import sys
import time
import pandas as pd
from playwright.sync_api import sync_playwright
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
START_URL = "https://books.toscrape.com/"
MAX_PAGES = 3
INTERVAL_SEC = 3
OUTPUT = "/home/username/scraper/books.csv"
def scrape_page(page):
cards = page.locator("article.product_pod")
cards.first.wait_for(state="visible")
rows = []
for card in cards.all():
rows.append({
"title": card.locator("h3 a").get_attribute("title"),
"price": card.locator("p.price_color").inner_text(),
})
return rows
def main():
rows = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_default_timeout(15_000)
page.goto(START_URL)
for _ in range(MAX_PAGES):
rows.extend(scrape_page(page))
next_link = page.get_by_role("link", name="next", exact=True)
if next_link.count() == 0:
break
time.sleep(INTERVAL_SEC)
next_link.click()
page.wait_for_load_state("domcontentloaded")
browser.close()
df = pd.DataFrame(rows)
df["price"] = df["price"].str.replace("£", "", regex=False).astype(float)
df.to_csv(OUTPUT, index=False, encoding="utf-8-sig")
print(f"{len(df)}件を保存しました")
if __name__ == "__main__":
try:
main()
except PlaywrightTimeoutError as e:
print(f"要素が時間内に現れませんでした: {e}", file=sys.stderr)
sys.exit(1)ブロックごとの説明
- 定数部分:取得ページ数の上限(
MAX_PAGES)と、ページ間の待ち時間(INTERVAL_SEC)を先頭に出しています。どちらも相手サイトへの負荷を決める値なので、コードの奥に埋めないようにしています。 scrape_page():前の節で説明した今の書き方そのものです。1件目が表示されるまで待ってから全カードを回し、タイトルと価格を辞書にします。page.set_default_timeout(15_000):locator の自動待機の上限を15秒にします。既定の30秒のままでも動きますが、毎日回すなら「どれだけ待って諦めるか」を自分で決めておくほうが、ログを読むときに迷いません。- ページ送りのループ:
get_by_roleで「next」リンクを探し、count()が0なら最終ページなので抜けます。count()は待たずに今の件数を返すので、「あるかないか」の判定に向いています。クリックの前のtime.sleep()は読み込み待ちではなく、相手サーバーへの間隔を空けるためのものです。読み込み待ちはlocatorに任せ、sleepはアクセス間隔のためだけに使う、と役割を分けています。 wait_for_load_state("domcontentloaded"):クリック後、次のページのHTMLが読み込まれるまで待ちます。次のscrape_page()が前のページのカードを読んでしまうのを防ぎます。- pandas の加工:「£51.77」のような文字列から記号を取り、数値にします。
apply()で1件ずつ処理せず、.str.replace()で列ごとまとめて変換しています。調査結果でも、pandasはapply()を避けて列単位の操作を使うことが推奨されています。utf-8-sigはExcelで開いたときの文字化け対策です。pandas側の書き方の変化はpandas 3.0連鎖代入→.loc/assignへ|26年10月も参考にしてください。 - 例外処理:タイムアウトしたら
sys.exit(1)で異常終了させます。0件のCSVを黙って保存するより、失敗としてsystemdに記録させるほうが、翌朝に気づけます。
systemd timer の設定
ユーザー単位のsystemd(root権限が要らない)で、毎朝6時に動かす例です。まず ~/.config/systemd/user/books.service を作ります。
[Unit]
Description=books scraper
[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/books.py次に ~/.config/systemd/user/books.timer を作ります。
[Unit]
Description=run books scraper daily
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --user daemon-reload
systemctl --user enable --now books.timer
loginctl enable-linger username
systemctl --user list-timers
journalctl --user -u books.serviceExecStartに venvのpythonをフルパスで書くのは、systemdから起動されたときに、普段のシェルのPATHやsource activateが効かないためです。Persistent=trueを付けると、電源が落ちていた時間帯の回を、起動時に1度だけ実行します。loginctl enable-lingerは、ログアウト中もユーザー単位のtimerを動かすための設定です。これが無いと、SSHを切った時点で止まります。- Playwrightのブラウザはユーザーのホーム配下(
~/.cache/ms-playwright)に入ります。playwright installをtimerを動かすのと同じユーザーで実行しておかないと、「ブラウザが見つからない」で落ちます。 headless(画面を出さないモード)はlaunch()の既定なので、画面の無いサーバーでもそのまま動きます。
サーバーを持たずに定期実行したい場合は、GitHub Actions cronスクレイピング|26年10月で別の選択肢を紹介しています。
取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔とボット対策の変化
動かす前に確認すること
- robots.txt:
https://example.com/robots.txtのように、取得先のルートにあるファイルを読み、対象のパスがDisallowになっていないか確認します。米国では法的拘束力が無いとされる一方で、多くのボット対策システムがこれを参照しています。 - 利用規約:「自動取得の禁止」が書かれていないか確認します。米国のhiQ対LinkedIn訴訟(2022年)では、公開ページのスクレイピング自体は不正アクセスに当たらないと判示されました。それでもhiQは契約違反で50万ドルの和解金と差し止めに至っています。ログインが必要なページの自動取得は、規約違反のリスクが特に高くなります。
- アクセス間隔:日本では、図書館のサーバーを過負荷にしたクローラーに業務妨害罪(刑法233条・234条の2)が問われた例があり、レート制限が法的な安全策とされています。上のコードの
INTERVAL_SECとMAX_PAGESは、必要な量だけを取るために置いています。並列で一気に取りに行く書き方はしません。 - 個人情報:取得したデータに個人情報が含まれるなら、個人情報保護法に沿った扱いが必要です。EUでは欧州データ保護委員会が2026年7月7日に、生成AIの文脈でのスクレイピングに関するガイドライン案を採択し、「公開されている」データでもGDPRの保護対象になるとしています。
ブロックされたら「回避」ではなく「止める」
Cloudflareは2026年7月13日に、セッション全体の振る舞いからボットを見分ける「Precursor」を全顧客に展開しました。9月15日には、AI学習用のクロールを個別に拒否できる「Disallow AI Training」設定も加えています。サイト側が自動アクセスへの意思をはっきり示せるようになってきた、ということです。
403が返る、確認画面が出るといった形でブロックされたら、それはサイト側の意思表示です。偽装や回避の手口を足すのではなく、取得をやめるか、公式APIやデータ提供の窓口が無いかを確認してください。locatorで安定させるのは「読み込みのタイミング差で落ちない」ためであって、制限をすり抜けるためではありません。
毎日動かし続けるには何が要るか:手元のPC・共有サーバーのcron・VPSの止まる条件
locatorで書き直すと、読み込みのタイミング差による失敗は減ります。それでも、どこで動かすかを間違えると、スクリプトの外側で止まります。Playwrightはブラウザを丸ごと起動するので、requestsだけのスクリプトより置き場所の条件が厳しくなります。
実行場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(cron・launchd・systemd timer) | 1日数回、PCを開いている時間に終わればよい | スリープ中・フタを閉じている間は動かない。ノートPCでは「その日だけ飛ぶ」が起きる |
共有レンタルサーバーのcron | requests+BeautifulSoupで足りる、静的なページの取得 | Playwrightのブラウザ本体やOSのライブラリを入れる権限が無いことが多い。メモリやプロセス数の制限でChromiumが起動できないこともある。契約前にサポート情報で確認が必要 |
VPS・常時稼働の自宅サーバー(systemd timer) | JavaScriptで描画されるページをPlaywrightで毎日取る | メモリ不足でChromiumが落ちる、電源断( |
どの場所でも共通して要るのは、失敗を失敗として残すことです。上のコードはタイムアウトで終了コード1を返すので、systemctl --user status books.service や journalctl に失敗として残ります。逆に try/except で例外を握りつぶすと、毎日0件のCSVを「成功」で保存し続けても気づけません。
Playwrightのパッケージを更新したら playwright install chromium を同じユーザーで再実行し、systemctl --user start books.service で一度手動で回して結果を確かめてください。ブラウザの更新だけで止まる事故は、これで防げます。ブラウザ操作でファイルを落とす処理を足すなら、expect_downloadでCSV自動取得|26年10月も同じ考え方で書けます。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
Playwright locator移行と自動待機のポイント
AIが今も出してくる page.query_selector・wait_for_selector は、その瞬間の要素をつかむ ElementHandle 系の書き方です。Playwright公式でも locator への移行が勧められています。query_selector は待たずに None を返すので、読み込みが遅い日だけ落ちる不安定なスクレイパーになりがちです。今の書き方では、page.locator() と get_by_role() で「探し方」を定義し、操作のたびに探し直して自動で待たせます。locator.all() は待たないので、一覧を取る前に locator.wait_for() で1件目を待ちます。sleepは読み込み待ちではなく、アクセス間隔のためだけに使います。定期実行は、ブラウザが動くVPSや常時稼働のマシンでsystemd timerを使い、Persistent=true と enable-linger を設定します。タイムアウト時は異常終了させ、失敗をログに残してください。取得前に robots.txt・利用規約・アクセス間隔を確認し、ブロックされたら回避ではなく取得を止めましょう。