ChatGPTに求人の新着収集スクリプトを書かせて毎日動かしていたのに、ある日からCSVが増えなくなった。エラーも出ていない。このとき多いのは、AIが書いた DataFrame.append がpandas 2.0で削除されていて、その例外を except: pass が黙って握りつぶしている状態です。この記事では、AIに渡す指示文を実物で示します。pd.concat と to_csv(mode="a", header=False) で毎日重複なくCSVへ追記するコードに直させ、それをどこで動かし続ければよいかまでを順に説明します。
ChatGPTに「求人の新着をCSVに貯めて」と頼むと出てくる DataFrame.append のコード
最初の指示文
コードを書き慣れていない人が最初に出しがちな指示は、だいたい次のような形です。
求人サイト https://example.com/jobs の新着求人を毎日集めて、
jobs.csv に追記していくPythonスクリプトを書いてください。
タイトルとURLがあれば十分です。出てきたコード(問題を含む例)
この指示に対して、生成AIはいまでも次のようなコードを返すことがあります。学習データに古い書き方が大量に含まれているためです。
import requests
from bs4 import BeautifulSoup
import pandas as pd
WEBHOOK_URL = "https://hooks.example.com/services/XXXXXXXX" # 通知先を直書き
df = pd.read_csv("jobs.csv")
for page in range(1, 21):
try:
res = requests.get(f"https://example.com/jobs?page={page}")
soup = BeautifulSoup(res.text, "html.parser")
for card in soup.select(".job-card"):
a = card.select_one("a")
df = df.append({"title": a.text, "url": a["href"]}, ignore_index=True)
except:
pass
df.to_csv("jobs.csv", index=False)
requests.post(WEBHOOK_URL, json={"text": "完了"})一見すると動きそうですが、毎日動かす前提では次の問題が重なっています。
df.appendはpandas 2.0で削除済み。pandas 2.x以降では、ここでAttributeErrorが出ます。except: passがその例外を飲み込む。結果としてCSVは空のまま上書きされ、最後に「完了」が通知されます。止まっているのに成功に見えるので、何日も気づけません。timeoutが無い。相手のサーバーが応答しないと、そのまま永久に待ち続けます。- ページ間の待ち時間が無い。20ページを間を空けずに取りに行くので、相手のサーバーに負荷をかけます。
- 重複チェックが無い。動いたとしても、同じ求人が毎日追記され続けます。
- 初回は
jobs.csvが無いのでread_csvで落ちる。 - 通知先URLがコードに直書き。ファイルを共有した時点で漏れます。
AIが出したコードで、自分で探せる確認ポイント
コードを全部読めなくても、次の語をエディタの検索(またはターミナルの grep -n)で探せば危ない箇所に当たりをつけられます。
検索する語 | 何が危ないか | AIへの直させ方 |
|---|---|---|
| DataFrameに対して使っていれば、pandas 2.0以降は動かない(リストの | 「 |
| 失敗を握りつぶして、止まったことに気づけなくする | 「例外はログに残して終了コード1で終わってください」 |
|
| 「すべてのリクエストに |
| 見当たらなければ、連続アクセスで相手に負荷をかけている | 「ページ間に数秒の間隔を入れてください」 |
|
| 「新着分だけを追記してください」 |
| 認証情報や通知先の直書き | 「環境変数から読み込んでください」 |
| Seleniumの古い書き方。Selenium 4.x以降は | 「 |
requests の待ち時間の扱いは requests timeout忘れ|AIコード2026年10月 で詳しく扱っています。
pandas 2.0で DataFrame.append が消えた:以前の書き方と今の書き方
何が変わったのか
pandas 1.x系では、DataFrame.append を使っても警告(FutureWarning)が出るだけで動いていました。pandas 2.0でこのメソッド自体が削除されたため、いまは呼んだ時点で次の例外になります。
AttributeError: 'DataFrame' object has no attribute 'append'手元の環境がどちらなのかは、次のコマンドで確かめられます。AIに相談するときも、この出力を貼ると古い書き方を出されにくくなります。
python3 -V
pip show pandas新旧の対応
やりたいこと | 以前の書き方(動かない) | 今の書き方 |
|---|---|---|
行を足していく |
| 行は普通のリストに貯めて、最後に |
複数の表をつなぐ |
|
|
CSVに新着だけ足す | 全件を読み込み → append → | 新着分だけを |
1行だけ足す |
|
|
以前の append は、呼ぶたびに新しいDataFrameを丸ごと作り直していました。そのためループの中で使うと、件数が増えるほど極端に遅くなります。今の書き方は「行はリストに貯め、DataFrameにするのは最後に1回だけ」が基本です。
毎日のCSV追記では、さらに全件を読み直して上書きするのをやめ、新着分だけを末尾に足す形にします。こうすれば途中で落ちても既存の行は壊れません。
pd.concat と to_csv(mode="a") に直させる指示文と、直った後のコード
直させる指示文
「動かないので直して」だけでは、AIは別の箇所を書き換えたり、また append を出したりします。何をどう直すかを具体的に指定するのがコツです。
次のスクリプトを、毎日1回の定期実行で使えるように書き直してください。
環境は pandas 2.x 以降です(pip show pandas の結果を下に貼ります)。
1. DataFrame.append は使わず、行はリストに貯めて pd.concat で結合する
2. jobs.csv へは新着分だけを to_csv(mode="a", header=False) で追記する。
ファイルが無い初回だけヘッダーを書く
3. 既存の jobs.csv にある url と重複する行は追記しない
4. requests.Session を使い、すべてのリクエストに timeout を付ける
5. ページ間に5秒の間隔を入れ、取得するのは最大3ページまでにする
6. 実行前に robots.txt を確認し、許可されていなければ中止する
7. except: pass は使わない。例外は logging で記録し、終了コード1で終わる
8. 1件も取れなかったときは成功扱いにせず、終了コード1で終わる
9. 通知先URLは環境変数 NOTIFY_WEBHOOK_URL から読み、失敗時だけ通知する
10. CSVの列の順番は url, title, fetched_at に固定する
(ここに pip show pandas の出力と、元のコードを貼る)実行環境
- Python 3(
python3 -Vで確認。pandas 2.x以降が入る版を使う) pip install "pandas>=2" requests beautifulsoup4- pandasの下限を指定するのは、1.x系が入ると
pd.concatの版で書いた意味が薄れるうえ、古いコードとの差に気づきにくくなるためです。動作を確認できたらpip freeze > requirements.txtで版を固定してください。pandas 3.0で文字列の型の扱いが変わった点は pandas 3.0 str型の判定と版固定|2026年10月 にまとめています。
直った後のコード
取得先の https://example.com/jobs と .job-card は説明用の仮の値です。実際に使うサイトのURLと構造に合わせて置き換えてください。
import logging
import os
import sys
import time
from pathlib import Path
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
import pandas as pd
import requests
from bs4 import BeautifulSoup
BASE_URL = "https://example.com"
LIST_PATH = "/jobs?page={page}"
MAX_PAGES = 3
INTERVAL_SEC = 5
COLUMNS = ["url", "title", "fetched_at"]
CSV_PATH = Path(__file__).with_name("jobs.csv")
STAMP_PATH = Path(__file__).with_name("last_success.txt")
USER_AGENT = "jobwatch/1.0 (+https://example.com/contact)"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("jobwatch")
def allowed(session, path):
rp = RobotFileParser()
res = session.get(urljoin(BASE_URL, "/robots.txt"), timeout=(5, 20))
if res.status_code == 404:
return True
res.raise_for_status()
rp.parse(res.text.splitlines())
return rp.can_fetch(USER_AGENT, urljoin(BASE_URL, path))
def fetch_page(session, page):
res = session.get(urljoin(BASE_URL, LIST_PATH.format(page=page)), timeout=(5, 20))
res.raise_for_status()
soup = BeautifulSoup(res.text, "html.parser")
now = pd.Timestamp.now(tz="Asia/Tokyo").isoformat(timespec="seconds")
rows = []
for card in soup.select(".job-card"):
link = card.select_one("a")
if link is None or not link.get("href"):
continue
rows.append({
"url": urljoin(BASE_URL, link["href"]),
"title": link.get_text(strip=True),
"fetched_at": now,
})
return rows
def load_seen():
if not CSV_PATH.exists():
return set()
return set(pd.read_csv(CSV_PATH, usecols=["url"], dtype=str)["url"])
def notify(text):
url = os.environ.get("NOTIFY_WEBHOOK_URL")
if not url:
return
try:
requests.post(url, json={"text": text}, timeout=10)
except requests.RequestException:
log.exception("通知に失敗しました")
def main():
with requests.Session() as session:
session.headers["User-Agent"] = USER_AGENT
if not allowed(session, LIST_PATH.format(page=1)):
log.error("robots.txt で許可されていないため中止します")
return 1
frames = []
for page in range(1, MAX_PAGES + 1):
if page > 1:
time.sleep(INTERVAL_SEC)
rows = fetch_page(session, page)
if not rows:
break
frames.append(pd.DataFrame(rows, columns=COLUMNS))
if not frames:
log.error("1件も取得できませんでした。ページの構造が変わった可能性があります")
return 1
new = pd.concat(frames, ignore_index=True).drop_duplicates(subset="url")
new = new[~new["url"].isin(load_seen())]
write_header = not CSV_PATH.exists()
new[COLUMNS].to_csv(CSV_PATH, mode="a", header=write_header, index=False, encoding="utf-8")
STAMP_PATH.write_text(pd.Timestamp.now(tz="Asia/Tokyo").isoformat(timespec="seconds"))
log.info("新着 %d 件を追記しました", len(new))
return 0
if __name__ == "__main__":
try:
code = main()
except Exception:
log.exception("異常終了しました")
code = 1
if code != 0:
notify("jobwatch が失敗しました。ログを確認してください")
sys.exit(code)ブロックごとの説明
- 先頭の定数:取得先・ページ数の上限・待ち時間・CSVの列順をまとめています。
CSV_PATHはPath(__file__)基準なので、cronのようにカレントディレクトリが変わる実行方法でも、スクリプトと同じ場所のCSVに書き込みます。 allowed():robots.txt を取得して、一覧ページの取得が許可されているかを確かめます。robots.txt が存在しない(404)ときは許可とみなします。それ以外の失敗は例外にして、取得そのものを止めます。fetch_page():一覧ページを1枚取得し、求人カードからURLとタイトルを取り出して、普通のリストに貯めます。ここではまだDataFrameにしません。timeout=(5, 20)は「接続に5秒・応答に20秒」まで待つという意味です。load_seen():既存のCSVからurl列だけを読み込み、集合(set)にします。全列を読まないので、CSVが大きくなっても軽く済みます。main()の取得ループ:2ページ目以降は取得の前に5秒待ちます。カードが0件のページに来たらそこで打ち切ります。pd.concatの部分:ページごとのDataFrameを1回でまとめ、同じ実行の中での重複をdrop_duplicatesで落とします。続いて既存CSVにあるURLをisinで除き、新着だけを残します。to_csv(mode="a", ...):新着分だけをCSVの末尾に追記します。headerはファイルが無い初回だけTrueにします。毎回ヘッダーを書くと、CSVの途中にヘッダー行が何度も混ざります。new[COLUMNS]で列の順番を固定しているのは、あとからAIに列を足させたときに、既存の行と列がずれるのを防ぐためです。- 最後の
try:どこで落ちても、ログにトレースバック(どこで失敗したかの記録)を残し、終了コード1で終わって通知します。1件も取れなかった場合も失敗として扱います。サイトの構造が変わって0件になったときに「正常終了」と記録されるのを防ぐためです。 last_success.txt:成功した時刻を書き残します。後述する「止まっていないかの確認」に使います。
直った後のコードで、もう一度確かめること
- CSVを削除してから2回続けて実行し、2回目に「新着 0 件」になること(重複して追記されないこと)
BASE_URLを存在しないURLに書き換えて実行し、終了コードが1になること(echo $?で確認)- CSVのヘッダー行が先頭の1行だけであること
AIは「直しました」と言いながら一部を元に戻すことがあります。先ほどの確認表の語をもう一度検索してから使い始めてください。
求人サイトから毎日取得するときの robots.txt・利用規約・アクセス間隔
日本の法律でスクレイピング自体を一律に禁じるものはありません。ただし、対象・方法・目的の組み合わせによっては問題になります。始める前に、次の3点を必ず確認してください。
- 利用規約:求人サイトの多くは、自動プログラムによるデータの取得を規約で禁じています。禁止されていれば取得しないでください。違反すると損害賠償を請求されるおそれがあります。
- robots.txt:上のコードでは機械的に確認していますが、規約で禁止されていれば robots.txt が許可していても取得しないでください。
- APIやRSSの提供:公式に提供されていれば、そちらを使ってください。
アクセスの仕方でも、相手への配慮が要ります。
- アクセス間隔と量:ページ間に数秒空け、取得するページ数に上限を設け、実行は1日1〜2回にとどめます。過度なアクセスは、偽計業務妨害罪などの刑事事件に発展する可能性があります。
- 名乗る:User-Agent(アクセス元を名乗る文字列)には、自分のスクリプト名と連絡先を正直に入れます。AIに「ボット対策をかいくぐるように」「人間のアクセスを装うように」と頼むのはやめてください。ブロックされたら、それが相手の意思表示です。
- 保存するものを絞る:求人ページに担当者の氏名などの個人情報があっても、保存しないでください。公開されている情報でも、無断で集めてデータベース化すれば個人情報保護法に抵触する可能性があります。
- 再公開しない:取得した求人文をそのまま自分のサイトで公開したり販売したりすると、著作権侵害になりえます。
毎日止めずに動かす場所:手元のPC・共有サーバーのcron・VPSのsystemd timer
3つの選択肢と、それぞれが止まる条件
置き場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC | まず数日試したい。取得結果を自分でしか見ない | スリープ・電源オフ・ログアウト。ノートPCを閉じた日は丸ごと実行されない |
共有レンタルサーバーのcron | 1日1〜2回、数分で終わる処理。cronとPythonのパッケージ追加が使えるプラン | 実行時間の上限を超えたとき、プランでcronや |
VPS(systemd timer) | 実行時間が長い、Seleniumなどでブラウザを動かす、複数のスクリプトを順に動かす | OS更新後の再起動でタイマーが有効になっていない、ディスクが満杯、料金未払い。管理はすべて自分の責任になる |
この記事のスクリプトは数分で終わる軽い処理なので、cronが使える共有サーバーで足ります。ブラウザ操作が必要な取得(Selenium)に変えるなら、VPSのほうが無難です。
cronで動かす場合
15 7 * * * cd /home/username/jobwatch && /usr/bin/flock -n /tmp/jobwatch.lock /home/username/jobwatch/.venv/bin/python jobwatch.py >> /home/username/jobwatch/run.log 2>&1- Pythonはフルパスで書く:cronは環境変数
PATHがほぼ空の状態で動くので、python3と書くと仮想環境に入れたpandasが見えません。 flock -n:前回の実行がまだ終わっていなければ、今回の実行を飛ばします。2つの実行が同時にCSVへ追記して重複するのを防ぎます。flockが使えない共有サーバーもあるので、which flockで確認してください。- 通知先URL:コードに書かず、権限600の
.envファイルに置いてpython-dotenvで読み込むか、crontabの先頭でNOTIFY_WEBHOOK_URL=...と指定します。.envはgitに含めないでください。
VPSのsystemd timerで動かす場合
~/.config/systemd/user/jobwatch.service
[Unit]
Description=jobwatch
[Service]
Type=oneshot
WorkingDirectory=/home/username/jobwatch
EnvironmentFile=/home/username/jobwatch/.env
ExecStart=/home/username/jobwatch/.venv/bin/python jobwatch.py~/.config/systemd/user/jobwatch.timer
[Unit]
Description=jobwatch daily
[Timer]
OnCalendar=*-*-* 07:15:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --user daemon-reload
systemctl --user enable --now jobwatch.timer
loginctl enable-linger username
systemctl --user list-timersPersistent=true:サーバーが止まっていた時間帯の実行を、起動後に1回だけ取り戻します。enable-linger:これが無いと、ログアウトした時点でユーザー単位のタイマーが止まります。- 再起動後の確認:
list-timersに表示されているかを見てください。表示されていなくても何のエラーも出ず、黙って止まります。 - ログ:
journalctl --user -u jobwatch.serviceで確認できます。
「止まっていないか」を別の仕組みで確かめる
失敗時の通知は、スクリプトが起動しなかった日には届きません。タイマーが消えた日や、サーバーそのものが落ちた日がそれに当たります。そこで、成功時に書き出している last_success.txt の更新時刻を、別のタイマーや別のマシンから1日1回確認します。たとえば「26時間以上更新されていなければ通知する」といった確認です。スクリプトの停止を外から見つけるこの方法は、最近よく話題に上がる定番の対策です。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
append→concat・新着だけ追記・止まったら気づける仕組みの3点
AIが書く求人の新着収集で毎日の追記が止まる原因の多くは、pandas 2.0で削除された DataFrame.append と、それを隠す except: pass の組み合わせです。AIには、行をリストに貯めて pd.concat で一度に結合し、既存のURLを除いた新着だけを to_csv(mode="a", header=False) で追記するよう、具体的に指示してください。あわせて timeout・アクセス間隔・robots.txt の確認・失敗時に終了コード1で終わることも、指示文に入れます。動かす場所は、軽い処理ならcronが使える共有サーバー、ブラウザ操作が要るならVPSのsystemd timerが目安です。最後に、成功時刻のファイルを外から確かめる仕組みを入れておくと、止まったことに翌日には気づけます。