ChatGPTやClaude Codeに書かせた求人の新着監視スクリプトで、sqlite3 のdatetime非推奨の警告が出たり、同じ求人が何度も通知されたりしていませんか。原因はPython 3.12で非推奨になった「datetimeをそのまま渡す書き方」と、既読管理の作りの甘さです。この記事では、既読IDと取得時刻をISO文字列で保存し、INSERT OR IGNOREで重複を防ぐ形にAIへ書き直させる指示文と、直った後のコード、毎日止めずに回す置き場所までを順に示します。
Python 3.12でsqlite3のdatetime既定アダプタが非推奨に:AIが出す既読管理の危うい箇所
Python 3.12から、sqlite3モジュールの既定のアダプタとコンバータが非推奨になりました。アダプタとは「Pythonの値をSQLiteに保存できる形へ自動変換する仕組み」のことです。これまでは datetime.now() をそのまま INSERT に渡すと、sqlite3が勝手に文字列へ変換してくれていました。3.12以降はこの自動変換に頼る書き方で DeprecationWarning(将来のバージョンで使えなくなる、という予告の警告)が出ます。
DeprecationWarning: The default datetime adapter is deprecated as of Python 3.12; see the sqlite3 documentation for suggested replacement recipes読み出し側で detect_types=sqlite3.PARSE_DECLTYPES を指定し、列の型を timestamp にして自動で datetime に戻す書き方も、同じく既定コンバータに頼るので非推奨の対象です。生成AIは学習時点の書き方を出しやすいため、この2つはAIが書いたSQLite処理で今もよく見かけます。
AIが最初に出したコード(よくある形)
「求人サイトの新着をチェックして、新しいものだけ通知して」と頼むと、だいたい次のようなコードが出てきます。
import sqlite3
import requests
from datetime import datetime
from bs4 import BeautifulSoup
WEBHOOK = "https://hooks.example.com/services/XXXX"
conn = sqlite3.connect("jobs.db", detect_types=sqlite3.PARSE_DECLTYPES)
conn.execute("CREATE TABLE IF NOT EXISTS jobs (id TEXT, title TEXT, seen_at timestamp)")
def main():
try:
html = requests.get("https://example.com/jobs").text
soup = BeautifulSoup(html, "html.parser")
for card in soup.select(".job-card"):
job_id = card["data-id"]
title = card.select_one(".title").text
row = conn.execute("SELECT 1 FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
requests.post(WEBHOOK, json={"text": title})
conn.execute("INSERT INTO jobs VALUES (?, ?, ?)",
(job_id, title, datetime.now()))
conn.commit()
except Exception as e:
print(e)
main()手元で1回動かすと「動いた」ように見えます。ですが、毎日動かすと問題が表に出てきます。
読者が自分で確認できる4つの箇所
確認箇所 | 上のコードでの該当 | 何が起きるか | 見つけ方 |
|---|---|---|---|
古いAPI |
| Python 3.12以降で非推奨警告。将来のバージョンで動かなくなる |
|
アクセス間隔・タイムアウトが無い |
| 相手が応答しないと永久に待つ。次の回と重なって二重起動になる |
|
例外で黙って止まる |
| 失敗しても終了コードが0(正常)になり、cronやsystemdが失敗に気づかない |
|
APIキー・URLの直書き |
| ファイルを共有・公開した瞬間に通知先が漏れる |
|
重複通知が出る本当の理由は「警告」ではない
誤解しやすいのですが、非推奨の警告そのものが重複通知を起こしているわけではありません。重複の原因は既読管理の作りにあります。
- テーブルに主キー(PRIMARY KEY)が無い:同じ
idが何行でも入るので、DBが重複を止めてくれない - 「SELECTしてからINSERT」の2段構え:前の回が終わる前に次の回が起動すると、両方が「まだ無い」と判断して両方が通知する
commit()がループの最後に1回だけ:途中で1件でも例外が出ると、それまでに通知は送ったのに既読の記録は保存されない。次の回で全部もう一度通知される
3つ目が一番厄介です。except で握りつぶしているので、ログにはエラーが1行出るだけで、利用者からは「なぜか同じ求人が毎回来る」としか見えません。
ChatGPT・Claude Codeに直させる指示文:最初の依頼と書き直しの依頼
調査時点(2026年10月)では、ChatGPTの既定モデルは2026年5月に更新された「GPT-5.5 Instant」、Claude CodeはCLIのバージョン2.1.296(2026年10月9日リリース)で既定がClaude Sonnet 5.5、複雑な作業にはClaude Opus 5.5を使える構成です。どのモデルでも、指示が曖昧だと学習時点の古い書き方が出る点は変わりません。モデルの賢さより、指示に何を書くかが効きます。
最初の指示(上のコードが出てきた依頼)
Pythonで、求人サイト https://example.com/jobs の新着をチェックして、
新しい求人だけWebhookに通知するスクリプトを書いてください。
一度通知したものはSQLiteに保存して、二度と通知しないようにしてください。この依頼には「Pythonのバージョン」「失敗したときどうするか」「毎日自動で動かす前提」が書かれていません。AIはそこを好きに埋めるので、手元で1回動けばよい書き方になります。
直させる指示(そのまま使える形)
このスクリプトを、Python 3.12以上でサーバーのsystemd timerから
1日2回自動実行する前提で書き直してください。次を必ず守ってください。
1. sqlite3にdatetimeをそのまま渡さない(3.12で既定アダプタが非推奨)。
時刻は datetime.now(timezone.utc).isoformat(timespec="seconds") で
ISO 8601の文字列にしてTEXT列に保存する。detect_typesは使わない。
2. 既読テーブルは job_id を PRIMARY KEY にし、
INSERT OR IGNORE で入れて、cursor.rowcount が 1 のときだけ通知する。
「SELECTしてからINSERT」の形にしない。
3. 1件ごとにトランザクションを閉じる。通知に失敗したらその1件は
ロールバックして、次回もう一度通知されるようにする。
4. requestsの呼び出しにはすべて timeout を付ける。
User-Agentに連絡先を入れ、robots.txtで許可されているか確認する。
5. WebhookのURLはコードに書かず、環境変数 JOB_WEBHOOK_URL から読む。
6. except で握りつぶさない。失敗したらログに残して終了コード1で終わる。
求人が0件だったらページ構造の変更を疑ってエラーにする。
7. DBファイルのパスはスクリプトと同じフォルダに固定する
(cronやsystemdは作業ディレクトリが違うため)。
書き直したら python3 -W error::DeprecationWarning で実行し、
警告が出ないことを確認してから見せてください。指示に入れると効く3つの語
- 「Python 3.12以上」:バージョンを書かないと、AIは古いバージョンでも動く書き方(=非推奨の書き方)を選びがちです。
- 「自動実行する前提」:これだけで、タイムアウト・終了コード・絶対パスへの配慮が出てきやすくなります。
- 「rowcount が 1 のときだけ」:重複防止の仕組みを名指しすると、
SELECT→INSERTの2段構えに戻りません。
Claude Codeのようにターミナルでコードを実行できるエージェントなら、最後の行の「-W error::DeprecationWarning で実行して確認してから」が効きます。AIに自分で警告を踏ませ、直るまで繰り返させられるからです。ChatGPTのチャット画面で頼む場合は、実行は自分で行い、出た警告を貼り付けて直させてください。
ISO文字列とINSERT OR IGNOREで書き直した求人監視(Python 3.12以上)
実行環境
- Python 3.12以上(
python3 -Vで確認。3.11以前では警告は出ませんが、下のコードはそのまま動きます) pip install requests beautifulsoup4(sqlite3は標準ライブラリなので追加不要)- 入ったバージョンは
pip show requests beautifulsoup4で確認できます。このスクリプトは特定のバージョンの機能に依存していないので、バージョンの指定は不要です - 仮想環境(venv)に入れておくと、サーバーへ移すときに「手元では動いたのに」が減ります
python3 -m venv /home/username/job-watch/.venv
/home/username/job-watch/.venv/bin/pip install requests beautifulsoup4直った後のコード
import logging
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
from urllib import robotparser
import requests
from bs4 import BeautifulSoup
BASE = "https://example.com"
LIST_URL = f"{BASE}/jobs?sort=new"
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "jobs.db")
UA = "job-watch/1.0 (+https://example.com/contact)"
INTERVAL = 5 # リクエストの間に空ける秒数
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def open_db():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.execute("""
CREATE TABLE IF NOT EXISTS seen_jobs (
job_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
first_seen TEXT NOT NULL
)""")
return conn
def allowed(url):
rp = robotparser.RobotFileParser(f"{BASE}/robots.txt")
rp.read()
return rp.can_fetch(UA, url)
def fetch_jobs(session):
res = session.get(LIST_URL, timeout=(5, 20))
res.raise_for_status()
soup = BeautifulSoup(res.text, "html.parser")
jobs = []
for card in soup.select("article.job-card[data-id]"):
title_el = card.select_one(".title")
if title_el is None:
continue
jobs.append((card["data-id"].strip(), title_el.get_text(strip=True)))
if not jobs:
raise RuntimeError("求人が0件。ページ構造が変わった可能性がある")
return jobs
def notify(session, webhook, text):
res = session.post(webhook, json={"text": text}, timeout=(5, 20))
res.raise_for_status()
time.sleep(1)
def main():
webhook = os.environ.get("JOB_WEBHOOK_URL")
if not webhook:
logging.error("JOB_WEBHOOK_URL が設定されていない")
return 1
if not allowed(LIST_URL):
logging.error("robots.txt で取得が許可されていない")
return 1
time.sleep(INTERVAL)
session = requests.Session()
session.headers["User-Agent"] = UA
jobs = fetch_jobs(session)
conn = open_db()
new_count = 0
try:
for job_id, title in jobs:
with conn:
cur = conn.execute(
"INSERT OR IGNORE INTO seen_jobs (job_id, title, first_seen) "
"VALUES (?, ?, ?)",
(job_id, title, now_iso()),
)
if cur.rowcount == 1:
notify(session, webhook, f"新着: {title}\n{BASE}/jobs/{job_id}")
new_count += 1
finally:
conn.close()
logging.info("新着 %d 件 / 取得 %d 件", new_count, len(jobs))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
logging.exception("実行に失敗した")
sys.exit(1)ブロックごとの説明
- 冒頭の定数:
DB_PATHは__file__(このスクリプト自身の場所)から組み立てます。cronやsystemdは作業ディレクトリがホームとは限らないので、"jobs.db"のような相対パスだと別の場所に空のDBが作られ、全件が新着扱いになって一斉通知されます。これも重複通知の典型的な原因です。 now_iso():時刻をUTCのISO 8601文字列(例2026-10-14T00:00:00+00:00)にします。以前はdatetime.now()をそのまま渡していましたが、今は自分で文字列にしてから渡します。sqlite3の自動変換に頼らないので非推奨の警告は出ませんし、DBを直接開いたときも人間が読めます。読み戻すときはdatetime.fromisoformat(文字列)でdatetimeに戻せます。open_db():job_idをPRIMARY KEYにしたテーブルを作ります。これで同じIDの行は物理的に2つ入りません。timeout=30は、別の処理がDBを使っている間に最大30秒待つ設定です。allowed():標準ライブラリのurllib.robotparserで robots.txt を読み、一覧ページの取得が許可されているかを確かめます。fetch_jobs():timeout=(5, 20)は「接続に5秒、応答に20秒」まで待つ指定です。raise_for_status()で403や500を例外にし、求人が0件ならエラーにします。サイトの構造が変わるとセレクタが何も拾わなくなりますが、それを「新着なし」と区別できないと何週間も気づけません。セレクタ(article.job-cardなど)は実際のページに合わせて書き換えてください。- メインのループ:ここが重複防止の本体です。
with conn:の中でINSERT OR IGNOREを実行すると、既にあるIDは黙って無視され、cur.rowcountが0になります。新しく入ったときだけ1になるので、そのときだけ通知します。with conn:を抜けるときに成功ならコミット、例外ならロールバックされるので、通知に失敗した1件は既読にならず、次回もう一度通知されます。1件ごとに閉じているので、途中で落ちても通知済みの分は既読として残ります。 - 最後の
try/except:例外はログに残したうえで終了コード1で終えます。print(e)で握りつぶすと終了コードが0になり、systemdもcronも「成功」と記録します。
sqlite3公式のアダプタ登録で済ませる方法との違い
Pythonの公式ドキュメントには、sqlite3.register_adapter(datetime, ...) で自分用のアダプタを登録する置き換え例も載っています。既存コードの INSERT が何十か所もあるならこちらが早いですが、登録はプログラム全体に効く設定なので、別のファイルから読んだときに登録し忘れると挙動が変わります。新しく書くなら、上のように渡す直前に文字列へ変換する方が、どこで何が保存されるかが読んで分かります。
既存のjobs.dbから移すときの注意
テーブルを作り直すと、既読の記録が空になって初回の実行で全件が通知されます。初回の前に、古いテーブルから既読IDを移しておきます。これもAIに「既存の jobs テーブルから seen_jobs へ移す使い捨てスクリプトを別に書いて」と頼めば出てきますが、中身は次の程度です。
import sqlite3
conn = sqlite3.connect("/home/username/job-watch/jobs.db")
with conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS seen_jobs (
job_id TEXT PRIMARY KEY, title TEXT NOT NULL, first_seen TEXT NOT NULL)""")
conn.execute("""
INSERT OR IGNORE INTO seen_jobs (job_id, title, first_seen)
SELECT id, MIN(title), MIN(seen_at) FROM jobs GROUP BY id""")
conn.close()古い seen_at は旧アダプタが書いた「2026-10-01 08:00:00.123456」形式の現地時刻のまま入ります。時刻を使った処理をしないなら、そのままで構いません。GROUP BY id で、重複していた行は1つにまとまります。
直ったことを確かめる3つの確認
python3 -W error::DeprecationWarning job_watch.py:非推奨の書き方が残っていれば、ここでエラーとして止まります。- 2回続けて実行する:2回目のログが「新着 0 件」なら重複防止が効いています。
JOB_WEBHOOK_URLを空にして実行し、echo $?が1になること:失敗が終了コードに出ていれば、後で置くcronやsystemdが失敗を検知できます。
求人サイトへの配慮:robots.txt・利用規約・1日2回の取得間隔
AIはアクセスの礼儀を書いてくれないことが多いので、ここは自分で確かめる必要があります。
- 利用規約を先に読む:求人サイトの中には、自動取得を規約で禁止しているところがあります。禁止されているなら、スクリプトを書く前にそのサイトの公式の新着メール・RSS・APIが無いかを探してください。それで足りるなら、スクレイピングより壊れにくく確実です。
- robots.txt を毎回確認する:上のコードの
allowed()がこれです。許可されていなければ取得せずに終わります。 - 頻度は1日1〜2回で足りる:新着の監視は、数分おきに回しても得られる情報はほとんど変わりません。一覧ページ1枚だけを取得し、詳細ページを全件開くような書き方はしません。
- User-Agentに連絡先を入れる:相手のサーバー管理者が、どこからのアクセスか分かるようにします。
- ブロックされたら止める:403や429(アクセス過多)が返ったら、それは相手からの「やめてほしい」という合図です。上のコードは例外として終了します。ヘッダーの偽装やIPの切り替えでかわす方法をAIが提案してきても採用しないでください。
Webhookへの通知も相手のサービスに負荷がかかるので、notify() では1件ごとに1秒空けています。
毎日止めずに回す置き場所:手元PC・レンタルサーバーのcron・VPSのsystemd timer
コードが直っても、置き場所を間違えると「動いていると思っていたら止まっていた」になります。それぞれ止まる条件で選んでください。
置き場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(Windowsのタスクスケジューラ、macOSのlaunchd、Linuxのcron) | 試しに数日回すだけ。見逃しても困らない | スリープ・電源オフ・ノートPCを閉じている間は動かない。止まっていた時間帯の分が後から実行されるとは限らない |
共有レンタルサーバーのcron | 1回数十秒で終わる処理を決まった時刻に回したい。常駐プロセスは不要 | Pythonのバージョンが選べず3.12未満のことがある/pipで入れられないパッケージがある/実行時間や同時実行数に上限がある(契約しているサービスの仕様を確認)。失敗しても通知されない設定のことが多い |
VPSのsystemd timer | Pythonのバージョンを自分で選びたい。失敗をログで追いたい。止まっていた分を取り戻したい | VPS自体の停止・ディスクの空き不足。OSやPythonの更新を自分で行う必要があり、放置すると古い環境のまま止まる |
共有レンタルサーバーのcronに置く場合
Webhookのような秘密の値はcrontabに直接書かず、権限を絞ったファイルに置いて読み込みます(chmod 600 /home/username/job-watch/.env)。
# /home/username/job-watch/.env
JOB_WEBHOOK_URL=https://hooks.example.com/services/XXXX
# crontab -e(毎日8時と20時)
0 8,20 * * * cd /home/username/job-watch && set -a && . ./.env && set +a && ./.venv/bin/python job_watch.py >> job_watch.log 2>&1cronはログイン時の設定(.bashrc など)を読まないので、環境変数とPythonのパスは明示します。.env は .gitignore に入れ、コードと一緒に共有しないでください。失敗の検知は job_watch.log の「実行に失敗した」を見るか、サーバー側でcronの出力をメールで受け取る設定(MAILTO)が使えるかを確認します。
VPSのsystemd timerに置く場合
# ~/.config/systemd/user/job-watch.service
[Unit]
Description=求人の新着監視
[Service]
Type=oneshot
WorkingDirectory=/home/username/job-watch
EnvironmentFile=/home/username/job-watch/.env
ExecStart=/usr/bin/flock -n /home/username/job-watch/.lock /home/username/job-watch/.venv/bin/python job_watch.py
# ~/.config/systemd/user/job-watch.timer
[Timer]
OnCalendar=*-*-* 08,20:00:00
Persistent=true
[Install]
WantedBy=timers.targetflock -n:前の回がまだ動いていれば、次の回は起動せずに終わります。SQLiteの既読管理は、二重起動を防いでおくとさらに安全です。Persistent=true:VPSが止まっていて実行を逃した回を、起動後に1回だけ取り戻します。- 失敗の検知:スクリプトが終了コード1で終わるので、
systemctl --user status job-watch.serviceに失敗として残り、journalctl --user -u job-watch.serviceでログを追えます。 - ログアウト後も動かすには
loginctl enable-linger usernameが要ります。
有効化は systemctl --user daemon-reload のあと systemctl --user enable --now job-watch.timer、次回の実行予定は systemctl --user list-timers で確認できます。cronで時刻どおりに動かなかった経験があるなら、systemd timerでcron取りこぼし対策|26年10月で、取りこぼしが起きる条件と移し方を詳しく書いています。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
要点:datetimeはISO文字列、既読はPRIMARY KEY、置き場所は止まる条件で選ぶ
Python 3.12でsqlite3の既定のdatetimeアダプタが非推奨になり、生成AIが出しがちな「datetime.now() をそのまま渡す」「PARSE_DECLTYPES で戻す」書き方は警告の対象です。今は時刻を isoformat() でISO文字列にしてTEXT列へ保存します。重複通知の原因は警告ではなく既読管理の作りで、job_id を PRIMARY KEY にし、INSERT OR IGNORE の rowcount が1のときだけ通知し、1件ごとにトランザクションを閉じれば止まります。AIには「Python 3.12以上」「自動実行する前提」「rowcountが1のときだけ」と具体的に指示し、-W error::DeprecationWarning で実行させて確認します。置き場所は、スリープで止まる手元PC、環境の制約があるレンタルサーバーのcron、自分で保守するVPSのsystemd timerから、止まる条件で選んでください。