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 |
| Chromium取得で落ちる。移設先で再現しない | Playwright の |
アクセス間隔 |
| 相手のサーバーに連続でリクエストが飛ぶ | ページ間に数秒の待ちを入れ、定数で管理する |
例外の握りつぶし |
| 失敗しても「正常終了」に見える | ログに残し、終了コード1で終わらせる |
キーの直書き |
| ファイルを共有・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の実行 |
|
|
ブラウザの用意 | 初回実行時に pyppeteer が自動ダウンロード(ここで落ちる) |
|
表示を待つ方法 | 決め打ちの秒数 | 要素が出るまで待ち、タイムアウトで失敗にする |
要素の取得 |
|
|
後片付け | 書かれないことが多い |
|
直った後のコードも、そのまま信用せず、前の節の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を使うのが確実です。そして、失敗したら通知が届く形にしておけば、止まったまま何日も気づかない事態を避けられます。