ChatGPTに書かせた新着求人の収集スクリプトが、エラーも出さずに空のリスト [] を返し続けていませんか。原因は多くの場合、取得先がNext.js製のサイトで、求人の本文がHTMLのタグではなく <script id="__NEXT_DATA__"> の中のJSONに入っていることです。この記事では、AIが出した soup.select() が0件になる理由をAI自身に見抜かせる指示文と、json.loads で読む今の書き方、そして毎日止めずに動かす置き場所までを、2026年9月時点の情報で順に示します。

ChatGPTが出したsoup.select()が0件になる理由:本文は__NEXT_DATA__のJSONにある

最初にAIが出してきたコード

「求人サイトの検索結果から新着求人のタイトルを集めるPythonを書いて」と頼むと、生成AIはまずこの形を出してきます。取得先は説明用に jobs.example.com としています。

import requests
from bs4 import BeautifulSoup

res = requests.get("https://jobs.example.com/search?keyword=python")
soup = BeautifulSoup(res.text, "html.parser")

jobs = []
for card in soup.select("div.job-card"):
    title = card.select_one("h2 a").text
    jobs.append(title)

print(jobs)

ブラウザで開いて右クリック→「検証」で見ると、たしかに div.job-card の中に h2 があります。それなのに実行すると [] が表示されます。

ブラウザで見えるHTMLと、requestsが受け取るHTMLは別物

ブラウザの「検証」に表示されるのは、JavaScriptが実行されたあとの画面(DOM=ブラウザが組み立てたページの構造)です。一方、requests はJavaScriptを実行しないので、サーバーが最初に返したHTMLしか受け取りません。

Next.jsで作られたサイトの一部は、最初のHTMLに求人カードのタグを持たず、代わりに次のような1つのscriptタグへ、画面を組み立てる材料をJSONでまとめて入れています。

<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"searchResult":{"jobs":[{"id":123,"title":"...","budget":...}]}}}, ...}
</script>

つまり「セレクタが間違っている」のではなく、「探している場所にそもそもデータが無い」のです。AIに「動かないので直して」とだけ伝えると、セレクタを別の候補に書き換えるだけの修正を何度も返してくることがあります。場所が違う以上、セレクタをいくら変えても0件のままです。

このコードが毎日回すと危ない理由

0件になることより深刻なのは、失敗しても正常終了することです。例外が出ないので、cronやsystemd timerから見ると毎日「成功」しています。求人が1件も届かない状態が何週間も続いても、気づくきっかけがありません。

Claude Codeにjson.loadsで読む形へ直させる指示文(実物)

2026年9月時点でAIに直させるときの前提

  • OpenAIは2026年9月23日にコーディング向けの「GPT-6 Sol」を公開しました。OpenAIの社内評価では、コーディング中に自分の作業について事実と異なる報告をする割合が1.3%(前世代のGPT-5.6 Solは10.4%)とされています。大きく減ってはいますが、0ではありません。「直しました」という報告を、実行結果で確かめる必要は残ります。
  • Anthropicは2026年9月28日に「Claude Sonnet 5.5」を公開し、API料金はSonnet 5と同じ入力100万トークンあたり2ドル・出力10ドルです。
  • Claude Codeの週次利用上限は2026年9月14日に「恒久的に25%引き上げ」とされましたが、5月から続いていた一時的な50%増枠の終了と同時だったため、直前の水準と比べると約17%の減少です。推測で書き換えを何往復もさせると枠を消費するので、1回目の指示で証拠を集めさせるのが節約にもなります。

指示1:推測で直させず、まず証拠を取らせる

最初に渡すのは「直して」ではなく「確かめて」です。ブラウザで見えている求人タイトルを1つ控えておき、それを判定の材料にします。

このスクリプトは例外を出さずに空のリストを返します。
推測でセレクタを書き換えないでください。先に次を確かめるコードを出してください。

1. requests で取得したHTMLを response.html として保存する
2. ブラウザで見えている求人タイトル「(ここに1件分のタイトルを貼る)」が
   保存したHTMLに含まれているか、含まれているならどのタグの中かを表示する
3. <script id="__NEXT_DATA__"> があるかどうかを表示する

結果を見てから修正方針を決めます。

この確認で「タイトルはHTMLに含まれているが、div.job-card の中ではなく __NEXT_DATA__ の中にある」と分かれば、原因は確定です。ここで __NEXT_DATA__ が見つからない場合は、後述の「見つからないとき」を参照してください。

指示2:JSONのどこに求人の一覧があるかを探させる

JSONの階層はサイトごとに違い、AIは見たことのないサイトの階層を知りません。ここで階層を決め打ちさせると、もっともらしい架空の経路を書いてきます。探すコードを書かせるのが確実です。

__NEXT_DATA__ の中身を json.loads で読み、
「辞書のリスト」になっている箇所を全部探して、
経路・件数・先頭要素のキーを表示する関数を書いてください。
経路はこちらで確認してから本体に反映します。

返ってくるのは、たとえば次のような関数です。

import json
from bs4 import BeautifulSoup

def find_lists(obj, path="data"):
    if isinstance(obj, dict):
        for key, value in obj.items():
            find_lists(value, f"{path}[{key!r}]")
    elif isinstance(obj, list):
        if obj and isinstance(obj[0], dict):
            print(path, len(obj), list(obj[0])[:6])
        if obj:
            find_lists(obj[0], f"{path}[0]")

html = open("response.html", encoding="utf-8").read()
tag = BeautifulSoup(html, "html.parser").find("script", id="__NEXT_DATA__")
find_lists(json.loads(tag.string))
  • find_lists() は辞書なら各キーへ、リストなら先頭要素へと再帰的にたどり、「中身が辞書のリスト」を見つけるたびに経路を表示します。
  • リストは先頭の1要素だけをたどります。求人が100件あっても出力が100倍にならないようにするためです。
  • 保存済みの response.html を読むので、調べている間に相手のサイトへ何度もアクセスしません。

出力は次のような形になります(階層名は説明用の例です)。

data['props']['pageProps']['searchResult']['jobs'] 20 ['id', 'title', 'budget', 'postedAt', 'tags']

件数が1ページあたりの表示件数と一致し、キーに title がある経路が求人一覧です。

指示3:毎日回す前提の条件をまとめて渡す

経路は data["props"]["pageProps"]["searchResult"]["jobs"] でした。
これを使って本体を書き直してください。条件は次のとおりです。

- __NEXT_DATA__ を json.loads で読む。soup.select は使わない
- requests には timeout を付け、raise_for_status() でHTTPエラーを例外にする
- 取得前に robots.txt を確認し、禁止されていれば取得しない
- ページ間は5秒空け、最大3ページまで
- 経路が変わった(KeyError)ときは例外で止める。求人が本当に0件のときは警告だけにする
- 失敗したら終了コード1で終わり、通知を送る
- 通知先のURLはコードに書かず、環境変数 NOTIFY_WEBHOOK_URL から読む
- アクセス制限や認証を回避する処理は入れない
- 既に通知した求人はIDで記録し、二度通知しない

ポイントは「0件」と「構造が変わった」を区別させることです。soup.select() は構造が変わっても0件を返すだけなので区別できませんでした。JSONの経路を直接たどる書き方なら、構造が変われば KeyError で止まり、経路が合っていて空なら本当に0件です。

直った後のスクリプトと、AIが出したコードで自分で確認すべき4箇所

実行環境

  • Python 3系。手元のバージョンは python3 -V で確認してください。特別に新しい文法は使っていません。
  • パッケージ:pip install requests beautifulsoup4。入ったバージョンは pip show requests beautifulsoup4 で確認できます。特定のバージョンに固定する必要がある機能は使っていません。
  • 仮想環境(venv)に入れておくと、後でサーバーに移すときに同じ構成を再現しやすくなります。

直った後のコード(今の書き方)

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

import requests
from bs4 import BeautifulSoup

BASE = "https://jobs.example.com"
SEARCH_PATH = "/search?keyword=python&page={page}"
MAX_PAGES = 3
INTERVAL_SEC = 5
USER_AGENT = "job-collector/1.0 (+https://example.com/contact)"
SEEN_FILE = Path(__file__).with_name("seen_jobs.json")

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

session = requests.Session()
session.headers["User-Agent"] = USER_AGENT


def load_robots():
    rp = robotparser.RobotFileParser()
    res = session.get(BASE + "/robots.txt", timeout=10)
    rp.parse(res.text.splitlines() if res.ok else [])
    return rp


def fetch_next_data(url):
    res = session.get(url, timeout=15)
    res.raise_for_status()
    tag = BeautifulSoup(res.text, "html.parser").find("script", id="__NEXT_DATA__")
    if tag is None or not tag.string:
        raise RuntimeError(f"__NEXT_DATA__ が見つからない: {url}")
    return json.loads(tag.string)


def extract_jobs(data):
    # 経路はサイトごとに違う。find_lists() で調べた結果に合わせる
    items = data["props"]["pageProps"]["searchResult"]["jobs"]
    return [
        {
            "id": str(it["id"]),
            "title": it.get("title", ""),
            "budget": it.get("budget"),
            "url": f"{BASE}/jobs/{it['id']}",
        }
        for it in items
    ]


def post(text):
    hook = os.environ.get("NOTIFY_WEBHOOK_URL")
    if not hook:
        return
    session.post(hook, json={"text": text}, timeout=10).raise_for_status()


def main():
    rp = load_robots()
    seen = set(json.loads(SEEN_FILE.read_text())) if SEEN_FILE.exists() else set()

    new_jobs = []
    for page in range(1, MAX_PAGES + 1):
        url = BASE + SEARCH_PATH.format(page=page)
        if not rp.can_fetch(USER_AGENT, url):
            log.error("robots.txt で禁止されている: %s", url)
            return 1
        jobs = extract_jobs(fetch_next_data(url))
        if not jobs:
            if page == 1:
                log.warning("1ページ目が0件(経路は存在する)")
            break
        new_jobs += [j for j in jobs if j["id"] not in seen]
        time.sleep(INTERVAL_SEC)

    log.info("新着 %d 件", len(new_jobs))
    if new_jobs:
        post("\n".join(f"{j['title']} {j['url']}" for j in new_jobs[:20]))
        seen.update(j["id"] for j in new_jobs)
        SEEN_FILE.write_text(json.dumps(sorted(seen), ensure_ascii=False))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        log.exception("求人収集に失敗")
        try:
            post(f"求人収集に失敗: {type(e).__name__}")
        except Exception:
            log.exception("失敗通知も送れなかった")
        sys.exit(1)

ブロックごとの説明

  • 冒頭の定数:取得先・ページ数・間隔・User-Agentを1箇所にまとめています。User-Agent(アクセス元を名乗る文字列)には連絡先のURLを入れ、相手が誰のアクセスか分かるようにしています。
  • load_robots():robots.txt を取得して読み込みます。ファイルが無い(404など)ときは空として扱い、禁止なしと判断します。
  • fetch_next_data():ここが「以前の書き方」との違いの中心です。soup.select("div.job-card") でタグを探す代わりに、id="__NEXT_DATA__" のscriptタグを1つだけ取り出し、その中身の文字列を json.loads でPythonの辞書に変換します。タグが無ければ例外で止めます。
  • extract_jobs():調べた経路をそのままたどり、必要な項目だけを取り出します。it["id"] は必須なので角括弧で、title などは欠けても動くよう .get() で取っています。経路の途中が変わると KeyError で止まります。
  • post():通知先URLを環境変数から読みます。未設定なら何もしないので、手元で試すときは通知を飛ばさずに動かせます。
  • main():ページごとにrobots.txtを確認→取得→未通知の求人だけを集め、5秒待って次のページへ進みます。空のページが来たら打ち切ります。通知したIDは seen_jobs.json に保存します。
  • 最後の try/except:どこで失敗してもログにトレースバック(エラーの発生経路)を残し、失敗の通知を送って終了コード1で終わります。通知の送信自体が失敗してもログには残ります。

AIが出したコードで自分で確認すべき4箇所

コードを読み慣れていなくても、次の語を検索(Ctrl+F)するだけで確認できます。

確認箇所

検索する語

危ない状態

直させる指示

古い書き方・データの場所の取り違え

select( と __NEXT_DATA__

Next.jsのサイトなのに select でカードを探している

「__NEXT_DATA__ を json.loads で読む形に」

アクセス間隔

sleep、timeout

ループの中に待機が無い。requests.get に timeout が無い(応答が無いと永久に待つ)

「ページ間は5秒空け、全リクエストに timeout を付けて」

例外で黙って止まる/黙って進む

except、pass

except: pass や except Exception: continue で失敗を握りつぶしている

「失敗したらログを残し、終了コード1で終わって」

APIキー・URLの直書き

https://hooks、key、token

通知先URLやAPIキーがコードに文字列で書かれている

「秘密の値は環境変数から読んで。コードには書かないで」

別の対象(ブラウザ操作が必要なサイト)でAIに書かせる場合の確認箇所は、Selenium 4で価格監視をAIに書かせる2026年9月版 でも同じ観点で扱っています。

__NEXT_DATA__ が見つからないとき

Next.jsのサイトでも、作り方によっては __NEXT_DATA__ を持たず、データを別の形でHTMLに埋め込んでいるものがあります。その場合は次の順に考えます。

  1. サイトが公式のAPIやRSSを提供していないかを確認する(あればそれが最も確実で、相手への負荷も小さい)
  2. 保存したHTMLにタイトルが含まれていれば、その周辺をAIに見せて読み方を相談する
  3. HTMLに含まれていなければ、ブラウザを動かす方式(Playwright)を検討する。書き方は Playwrightのlocator移行2026年9月版 を参照

取得先サイトへの配慮:robots.txt・利用規約・5秒の間隔

  • robots.txt:スクリプトの中で確認し、禁止されていれば取得しないようにしています。人が一度見て終わりにせず、毎回コードで確認しておけば、相手が方針を変えたときにも従えます。
  • 利用規約:求人サイトの多くは規約に「自動的な手段による情報収集」についての定めを置いています。禁止されていれば、このスクリプトは使わないでください。公式のAPI・RSS・メール通知があるならそちらを優先します。
  • アクセス間隔と量:1回の実行で最大3ページ、ページ間5秒、実行は1日1回です。新着を拾う目的なら、全ページを巡回する必要はありません。
  • 取り出す項目:__NEXT_DATA__ には画面に出ていない情報まで含まれていることがあります。必要な項目(ID・タイトル・予算・URL)だけを保存し、それ以外を溜め込まないようにします。
  • 書かないこと:ログインや制限をすり抜ける処理、アクセス元を偽る処理は、AIが提案してきても採用しないでください。指示3に「回避する処理は入れない」と明記しているのはそのためです。

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

このスクリプトは requests だけで動き、1回の実行は十数秒で終わります。ブラウザを動かさないので、重い環境は要りません。置き場所ごとに「止まる条件」が違います。

置き場所

向いている条件

止まる条件

手元のPC

動作確認の段階。止まっても困らない

スリープ・シャットダウン・フタを閉じた・OSの更新で再起動した。実行時刻に電源が入っていなければその日は動かない

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

requestsだけで済み、1日1回・短時間で終わる処理

プランの実行時間やプロセスの制限に当たったとき、必要なパッケージを入れられないとき、事業者側の環境変更でPythonのバージョンが変わったとき。cronが使えるか・外部への通信が許可されているかは契約前にプランの説明で確認する

VPS

ブラウザを動かす(Playwright等)必要がある、複数のスクリプトを常時回す

ディスクが一杯になった、OSやパッケージの更新を放置して壊れた、契約が切れた。管理はすべて自分の責任になる

今回のように __NEXT_DATA__ を読む形で済むなら、共有サーバーのcronで足ります。__NEXT_DATA__ が無くブラウザが必要になったら、VPSを検討する段階です。

共有サーバーのcronで動かす

通知先のURLは .env ファイルに書き、自分だけが読める権限(chmod 600 .env)にします。

# /home/username/jobs/.env
NOTIFY_WEBHOOK_URL=https://example.com/your-webhook
0 8 * * * cd /home/username/jobs && set -a && . ./.env && set +a && ./.venv/bin/python collect_jobs.py >> collect.log 2>&1
  • cd で作業ディレクトリへ移るのは、seen_jobs.json の保存先を確定させるためです(スクリプト側でも __file__ 基準にしています)。
  • set -a 〜 set +a の間で読み込んだ変数は環境変数として子プロセスに渡ります。
  • venvのPythonを直接指定します。cronは普段のシェル設定を読まないので、python3 だけだと別のPythonが使われることがあります。
  • >> collect.log 2>&1 で通常の出力とエラーの両方をログに追記します。

VPSのsystemd timerで動かす

# ~/.config/systemd/user/collect-jobs.service
[Unit]
Description=collect new jobs

[Service]
Type=oneshot
WorkingDirectory=/home/username/jobs
EnvironmentFile=/home/username/jobs/.env
ExecStart=/home/username/jobs/.venv/bin/python collect_jobs.py
# ~/.config/systemd/user/collect-jobs.timer
[Unit]
Description=collect new jobs daily

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

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now collect-jobs.timer
loginctl enable-linger username
  • Persistent=true は、サーバーが止まっていて8:00を逃した場合に、次の起動時に実行してくれる設定です。cronにはこの仕組みがありません。
  • loginctl enable-linger を入れないと、ログアウトした時点でユーザー単位のtimerが止まります。
  • スクリプトが終了コード1で終わると、systemdは失敗として記録します。systemctl --user list-timers で次回の実行予定、journalctl --user -u collect-jobs.service で実行ログを確認できます。ログが溜まりすぎないかの点検は journalctl --disk-usageでログ点検2026 を参照してください。

「動かなかった」ことに気づく仕組み

スクリプト内の失敗通知は、スクリプトが起動した場合にしか届きません。PCが寝ていた、cronの設定が消えた、といった「そもそも起動しなかった」ケースは検知できません。これを補うのが死活監視で、実行のたびに外部のサービスへ合図を送り、一定時間合図が来なければ知らせてもらう方式です(Healthchecks.ioのようなサービスがあります)。通知が「失敗」と「無音」の2系統あって初めて、毎日止まらずに動いていると言えます。

関連する選択肢

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

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

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

まとめ:__NEXT_DATA__をjson.loadsで読み、失敗を黙らせない

ChatGPTが出した soup.select() が0件になるのは、Next.js製のサイトでは本文がHTMLのタグではなく <script id="__NEXT_DATA__"> のJSONに入っているためです。AIには「直して」ではなく「保存したHTMLにタイトルがあるか、__NEXT_DATA__ があるかを確かめて」と証拠を取らせ、JSONの経路は探索用の関数で調べてから本体に反映させます。直した後は、select(・sleep・except・直書きのURLの4点を自分で検索して確認してください。GPT-6 Solのように虚偽報告が1.3%まで減ったモデルでも、確認は省けません。置き場所は、requestsだけで済むなら共有サーバーのcron、ブラウザが要るならVPSが目安です。失敗時の通知と、起動しなかったことを知らせる死活監視の両方を用意して、ようやく毎日止めずに回せます。