ChatGPTやClaude Codeに「JavaScriptで表示されるページから新着を集めるPythonを書いて」と頼むと、今でも requests-html の r.html.render() を使ったコードが出てくることがあります。このコードは初回実行時にChromiumを取りに行き、その段階で落ちがちです。中で使っている pyppeteer の保守が止まっているためです。この記事では、AIに Playwright の sync_api へ書き換えさせる指示文を実物で示します。あわせて、AIのコードで確認すべき箇所と、毎日止めずに回す置き場所までを順に説明します。

AIが今も出す requests-html の r.html.render() はどこが危ういか

最初の指示文と、返ってきたコード

コードを書き慣れていない人がAIに頼むときは、だいたい次のような指示になります。

https://example.com/news の新着記事のタイトルとURLを取得するPythonスクリプトを書いてください。
記事一覧はJavaScriptで表示されるページです。

このとき、AIが返しがちなのがこの形です。

from requests_html import HTMLSession

session = HTMLSession()
r = session.get("https://example.com/news")
r.html.render(sleep=2)

for a in r.html.find("a.item"):
    print(a.text, a.absolute_links)

短くて読みやすいコードです。ただし、毎日動かす前提で見ると問題がいくつもあります。

  • render() の中身が古い:render() は内部で pyppeteer を使います。初回実行時に pyppeteer がChromiumをダウンロードしますが、pyppeteer は保守が止まっているため、この取得で落ちます。手元で一度動いたとしても、別のマシンに移した日に動かなくなります。
  • アクセス間隔が無い:ページ数を増やすよう頼むと、AIは for 文で連続アクセスするコードを足してきます。間に待ち時間を入れません。
  • 失敗が見えない:タイムアウトの指定も例外処理もありません。止まったときに原因が残りません。逆に try: ... except: pass を足させると、今度は黙って0件で終わるようになります。
  • 既読の管理が無い:毎回全件を出力するだけです。「新着だけ」を拾う仕組みになっていません。

新しいモデルでも古い書き方が出る理由

2026年9月22日にOpenAIはGPT-6世代の「Sol」「Luna」を投入しました。Claude Codeでは9月1日に Claude Fable 5.1 が既定のFableモデルになっています。それでも、学習データに古い書き方が大量に残っていれば、AIはそれを出してきます。

同じことはpandasでも起きています。2026年1月の pandas 3.0 でCopy-on-Writeが既定になり、文字列列の型も object から専用の str に変わりました。そのため、AIが書いた ser.dtype == "object" という判定が False を返すようになっています。

つまり、モデルを新しくすれば済む話ではありません。指示文で書き方を指定し、出てきたコードを自分で点検する必要があります。

AIが出したコードで確認する4箇所(grepで見つける)

コードを全部読めなくても、危うい箇所は文字列検索で見つけられます。保存したファイル(ここでは collector.py)に対して、次を実行します。

grep -nE "requests_html|\.render\(|except.*:\s*pass|except:|sleep|api_key\s*=\s*['\"]|token\s*=\s*['\"]" collector.py

見る箇所

引っかかる書き方

何が困るか

直させる方向

古いAPI

requests_html・.render(

Chromium取得で落ちる。移設先で再現しない

Playwright の sync_api に置き換える

アクセス間隔

sleep が1件も出てこない

相手のサーバーに連続でリクエストが飛ぶ

ページ間に数秒の待ちを入れ、定数で管理する

例外の握りつぶし

except: pass・except Exception: pass

失敗しても「正常終了」に見える

ログに残し、終了コード1で終わらせる

キーの直書き

api_key = "...."・token = "...."

ファイルを共有・Git管理した時点で漏れる

環境変数から読む

このほかに、「0件だったとき」をどう扱っているかも確認してください。サイトのHTML構造が変わると、セレクタ(要素を指定する文字列。ここでは a.item)が何にも当たらなくなります。このときエラーは出ず、ただ0件で終わります。毎日回すスクリプトでは、0件を異常として扱わないと止まったことに気づけません。

Playwright の sync_api に書き換えさせる指示文と、直った後のコード

直させる指示文(そのまま貼れる形)

「Playwrightにして」とだけ頼むと、AIは async 版(非同期版)で書いてくることがあります。また、例外処理を pass で済ませることもあります。欲しい性質を箇条書きで全部指定するのがコツです。

先ほどのスクリプトを、次の条件で書き直してください。

1. requests-html は使わず、Playwright の sync_api(同期版)で書く。async は使わない
2. ページを開いたら、記事一覧のセレクタ a.item が表示されるまで待ってから取得する。
   goto と待機にはそれぞれタイムアウトを付ける
3. 複数ページを取得するときは、ページの間に5秒待つ。待ち時間は定数にする
4. 取得前に robots.txt を確認し、禁止されているURLは取得しない
5. 画像とフォントは読み込まない(相手と自分の通信量を減らすため)
6. 取得したURLを seen.json に保存し、前回までに無かったものだけを「新着」として出す。
   保存は一時ファイルに書いてから置き換える形にする
7. 0件だったら異常として扱う
8. 例外を握りつぶさない。失敗したらログに残し、終了コード1で終わる
9. 失敗通知のWebhook URLはコードに書かず、環境変数 SLACK_WEBHOOK_URL から読む
10. User-Agent に連絡先として contact@example.com を入れる

実行環境の準備

  • Pythonのバージョンは python3 -V で確認します。Playwrightがそのバージョンに対応しているかは、公式のリリースノートで確かめてください。
  • パッケージは pip install playwright で入れます。続けて、ブラウザ本体を python -m playwright install chromium で入れます。入ったバージョンは pip show playwright で確認できます。
  • 画面表示の要らない定期実行なら、ヘッドレス用(画面を出さない用)のシェルだけを入れる方法もあります。こちらのほうが軽く済みます。手順は Playwright --only-shell導入|26年10月 にまとめています。
  • Linuxでは、ブラウザが使うOSのライブラリも必要です。入れるには python -m playwright install-deps chromium を実行します。これには sudo(管理者権限)が要ります。
  • プロジェクトごとに python3 -m venv .venv で仮想環境を作ってください。入れたものは pip freeze > requirements.txt で固定しておきます。こうしておけば、AIが別の書き方を前提にしたときにも差分を追えます。

直った後のコード

import json
import logging
import os
import sys
import time
import urllib.request
from pathlib import Path
from urllib import robotparser

from playwright.sync_api import sync_playwright

BASE = "https://example.com"
PAGES = [f"{BASE}/news?page=1", f"{BASE}/news?page=2"]
SELECTOR = "a.item"
UA = "news-collector/1.0 (contact@example.com)"
INTERVAL_SEC = 5
SEEN_FILE = Path(__file__).with_name("seen.json")

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("collector")


def load_seen():
    if SEEN_FILE.exists():
        return set(json.loads(SEEN_FILE.read_text(encoding="utf-8")))
    return set()


def save_seen(seen):
    tmp = SEEN_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(sorted(seen), ensure_ascii=False), encoding="utf-8")
    tmp.replace(SEEN_FILE)


def notify(text):
    url = os.environ.get("SLACK_WEBHOOK_URL")
    if not url:
        log.warning("SLACK_WEBHOOK_URL が未設定のため通知しません")
        return
    body = json.dumps({"text": text}).encode()
    req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=10)


def collect(page, url):
    page.goto(url, wait_until="domcontentloaded", timeout=30_000)
    page.wait_for_selector(SELECTOR, timeout=15_000)
    return page.locator(SELECTOR).evaluate_all(
        "els => els.map(e => ({title: e.textContent.trim(), url: e.href}))"
    )


def main():
    rp = robotparser.RobotFileParser(f"{BASE}/robots.txt")
    rp.read()
    seen = load_seen()
    found = []

    with sync_playwright() as p:
        browser = p.chromium.launch()
        try:
            page = browser.new_page(user_agent=UA)
            page.route("**/*.{png,jpg,jpeg,gif,webp,woff,woff2}", lambda route: route.abort())
            for i, url in enumerate(PAGES):
                if not rp.can_fetch(UA, url):
                    log.warning("robots.txt で禁止されているため取得しません: %s", url)
                    continue
                if i:
                    time.sleep(INTERVAL_SEC)
                found += collect(page, url)
        finally:
            browser.close()

    if not found:
        raise RuntimeError("0件でした。セレクタかページ構造が変わった可能性があります")

    new = [x for x in found if x["url"] not in seen]
    for x in new:
        log.info("新着: %s %s", x["title"], x["url"])
    seen.update(x["url"] for x in found)
    save_seen(seen)
    log.info("取得 %d件 / 新着 %d件", len(found), len(new))


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        log.exception("収集に失敗しました")
        try:
            notify(f"収集に失敗: {e}")
        except Exception:
            log.exception("通知にも失敗しました")
        sys.exit(1)

ブロックごとに何をしているか

  • 定数の並び(BASE〜SEEN_FILE):取得先・セレクタ・待ち時間・保存先をファイルの冒頭にまとめています。サイトの構造が変わったら SELECTOR だけ直せば済みます。SEEN_FILE はスクリプトと同じ場所に置くので、cronから別のディレクトリで起動されても場所がずれません。
  • load_seen / save_seen:前回までに見たURLを読み書きします。保存はまず .tmp に書き、それから置き換えます。書き込みの途中で止まっても、seen.json が壊れて全件が「新着」扱いになることを防げます。
  • notify:失敗をSlackに知らせます。Webhook URLは環境変数から読むため、コードを人に見せても漏れません。未設定ならログに警告を出すだけにしています。通知の不調で本体の結果が分からなくなるのを避けるためです。
  • collect:render() の代わりになる部分です。goto でページを開き、wait_for_selector で一覧が描画されるまで待ちます。旧コードの sleep=2 は「2秒待てば表示されているだろう」という当て推量でした。新コードは「要素が出るまで待ち、15秒出なければ失敗」と明確に判定します。evaluate_all はブラウザの中でタイトルとURLをまとめて取り出す処理です。e.href は相対パスを絶対URLに直した値を返します。
  • main の with ブロック:最初に robots.txt を1回だけ読みます。ページごとに許可を確かめ、2ページ目以降は5秒空けて取得します。page.route で画像とフォントを読まないようにしています。finally があるので、途中で例外が出てもブラウザは必ず閉じます。
  • 0件チェックと新着の判定:0件なら例外にします。新着だけをログに出し、見たURLを保存します。
  • 最後の try / except:例外を握りつぶさない部分です。スタックトレース(どこで落ちたかの記録)を残し、通知を送り、sys.exit(1) で異常終了します。終了コードが1になるので、cronやsystemdの側でも失敗を検知できます。

以前の書き方と今の書き方

項目

以前(requests-html)

今(Playwright sync_api)

JavaScriptの実行

r.html.render(sleep=2)

page.goto() + page.wait_for_selector()

ブラウザの用意

初回実行時に pyppeteer が自動ダウンロード(ここで落ちる)

python -m playwright install chromium で事前に入れる

表示を待つ方法

決め打ちの秒数

要素が出るまで待ち、タイムアウトで失敗にする

要素の取得

r.html.find()

page.locator().evaluate_all()

後片付け

書かれないことが多い

with と finally で必ず閉じる

直った後のコードも、そのまま信用せず、前の節のgrepをもう一度かけてください。AIは指示の一部を落とすことがあります。特に「8. 例外を握りつぶさない」は、別の修正を頼んだときに元へ戻されやすい項目です。

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

Playwrightは本物のブラウザを動かします。そのため、1回のアクセスでも単純なHTTPリクエストより多くのファイルを取りに行きます。毎日動かすなら、次の点を守ってください。

  • robots.txt:コード内で can_fetch による確認を入れています。それとは別に、最初に一度ブラウザで https://example.com/robots.txt を開いてください。収集したいパスが禁止されていないかを目で確かめます。
  • 利用規約:「自動的な手段による取得」を禁じているサイトがあります。また、データの再配布や商用利用を禁じているサイトもあります。robots.txtで許可されていても、規約で禁止されていれば取得しません。公式のAPIやRSSがあれば、そちらを優先してください。
  • アクセス間隔と頻度:ページの間は数秒空けます。新着の確認であれば、実行は1日1〜数回で足ります。分単位の巡回は、相手にとっても自分にとっても割に合いません。
  • 素性を明かす:User-Agent に連絡先を入れておくと、問題があったときに相手が連絡できます。
  • 書かせてはいけないもの:ログインの突破、CAPTCHAの回避、アクセス制限を避けるためのIP切り替えなどです。AIに頼むと書いてしまうことがありますが、相手が拒否している取得はしません。

毎日止めずに回す置き場所:手元PC・共有サーバーのcron・VPSのsystemd timer

スクリプトが動いても、置き場所を間違えると「気づいたら1週間止まっていた」ということになります。Playwrightはブラウザ本体とOSのライブラリを必要とします。そのため、普通のrequestsのスクリプトより置ける場所が限られます。

置き場所

向いている条件

止まる条件

手元のPC(Windowsのタスクスケジューラ・Macのlaunchd)

試運転の期間。PCを毎日決まった時刻に開いている

スリープ中・ふたを閉じている・電源オフ。指定時刻に寝ていれば、その回は実行されないことがある

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

ブラウザを使わない、requestsだけのスクリプト

OSのライブラリを入れる sudo が使えず、Chromiumが起動しないことが多い。長時間のプロセスや多くのメモリを使う処理が、提供会社の規約で制限される場合がある

VPS(systemd timer または cron)

Playwrightを毎日確実に動かしたい

OSの更新を怠った・ディスクが一杯になった・契約が切れた。サーバーの管理は自分の責任になる

Playwrightを使った収集を毎日回すなら、現実的な選択肢はVPSです。手元のPCは、AIに直させたコードを試す場所と割り切るのが安全です。

cronで回す場合

SLACK_WEBHOOK_URL=(ここに自分のWebhook URL)
0 7 * * * cd /home/username/collector && .venv/bin/python collector.py >> collector.log 2>&1

注意点は二つあります。一つは、.venv/bin/python のように仮想環境のPythonを絶対パスかディレクトリ移動つきで指定することです。cronは環境変数のPATHが最小限なので、ターミナルでは動くのにcronでは動かない原因の多くがこれです。もう一つは、標準エラーまでログに残すことです。crontabはパーミッションで他人から見えない状態にしておきます。キーを書いたファイルを置く場合は chmod 600 にしてください。

systemd timer で回す場合

VPSなら、cronよりsystemd timerを勧めます。Persistent=true を付けると、サーバーが止まっていた時間帯の回を起動後に実行してくれます。また、サービス側に OnFailure= を書くと、終了コード1になったときに通知用のユニットを起動できます。コードの最後で sys.exit(1) にしておいたのは、ここで失敗を拾うためです。

ユニットファイルの書き方と、取りこぼしを防ぐ設定は systemd timerでcron取りこぼし対策|26年10月 で詳しく書いています。

  • 環境変数は EnvironmentFile=/home/username/collector/.env で渡します。この .env は600にしておきます。
  • ログは journalctl --user -u collector.service で確認できます。
  • Playwrightやブラウザを更新したら、まず手動で1回実行してください。動いたことを確かめてからタイマーに任せます。

関連する選択肢

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

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

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

まとめ:render() を Playwright に替え、点検し、VPSで回す

生成AIは2026年10月の今でも、動的サイトの収集に requests-html の r.html.render() を出してくることがあります。このコードは内部の pyppeteer がChromiumの取得で落ちます。加えて、アクセス間隔・例外処理・既読の管理を欠いたまま返ってくることがほとんどです。

直させるときは「Playwright の sync_api で」と書き方を名指ししてください。そのうえで、待機・間隔・robots.txt・既読管理・0件の扱い・例外・キーの置き場所を、箇条書きで全部指定します。

出てきたコードは、requests_html・except: pass・sleep の有無・キーの直書きをgrepで点検してください。読み切れなくても危うい箇所は見つけられます。

置き場所は、手元のPCをお試しまでに留めます。共有サーバーはブラウザが動かないことが多いため、毎日回すならVPSでsystemd timerを使うのが確実です。そして、失敗したら通知が届く形にしておけば、止まったまま何日も気づかない事態を避けられます。