ChatGPTやClaude Codeに「毎日の価格を記録するPythonスクリプト」を書かせると、動きはします。ところがサーバーで毎日回すと、記録時刻が9時間ずれる、同じ日の価格が2行に割れるといった問題が後から出てきます。原因の多くは、AIが出しがちな datetime.utcnow() です。これはPython 3.12で非推奨になりました。この記事では、datetime.now(ZoneInfo("Asia/Tokyo")) に直させるAIへの指示文を実物で示します。あわせて、cron・VPS・自宅PCのどこで動かせば時刻がずれず、止まらないかまで解説します。
ChatGPTに書かせた価格の定点観測スクリプトが datetime.utcnow() で9時間ずれる仕組み
最初にAIへ出した指示
コードを書き慣れていない人が最初に出しがちな指示は、だいたい次のような形です。
Pythonで、次の2つの商品ページから毎日価格を取得してCSVに記録するスクリプトを書いてください。
https://example.com/item/1
https://example.com/item/2
価格は class="price" の要素に入っています。日付と時刻も一緒に記録してください。
取得に失敗したらSlackに通知してください。AIが出してきたコード(よくある形)
モデルによって細部は違います。ただ、次のような形で返ってくることが少なくありません。
import csv
import requests
from bs4 import BeautifulSoup
from datetime import datetime
URLS = ["https://example.com/item/1", "https://example.com/item/2"]
WEBHOOK = "https://hooks.slack.com/services/XXXX/XXXX/XXXX"
def get_price(url):
try:
html = requests.get(url).text
soup = BeautifulSoup(html, "html.parser")
return soup.select_one(".price").text
except:
pass
now = datetime.utcnow()
with open("prices.csv", "a", newline="") as f:
w = csv.writer(f)
for url in URLS:
w.writerow([now.strftime("%Y-%m-%d"), now.strftime("%H:%M"), url, get_price(url)])手元で1回実行すると、CSVに2行が入ります。だから「できた」と思ってしまいます。しかし、毎日動かす前提で見ると問題が5つあります。
このコードで自分で確認すべき5か所
箇所 | 何が危ういか | 毎日動かすと起きること |
|---|---|---|
| Python 3.12で非推奨。返り値はタイムゾーン情報を持たない「naive(素の)日時」で、中身はUTC | 日本時間より9時間遅い時刻が、UTCだという印も無いまま記録される |
| UTCの日付を「その日の日付」として使っている | 日本時間の0:00〜8:59に実行すると、前日の日付で記録される |
| どんな失敗も握りつぶし、 | サイトの構造が変わっても空欄が記録され続け、誰も気づかない。Slack通知も一度も飛ばない |
ループに待ち時間が無い・ | 連続でアクセスする。相手が応答しないと永遠に待つ | URLが増えると相手に負荷をかける。ハングするとcronの次の回と重なる |
| 通知先URL(実質的にAPIキー)をコードに直接書いている | コードを共有・公開した時点で、第三者がその通知先に投稿できるようになる |
1つ目の utcnow() は、Python 3.12以降で実行すると警告が出ます。警告はふつう画面に出ないことが多いので、次のように「警告をエラー扱いにして」実行すると確実に気づけます。
python3 -W error::DeprecationWarning pricewatch.pyDeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).「将来のバージョンで削除予定」と書かれています。いまは動いても、Pythonを上げた日に止まるコードだということです。同じ理由で datetime.utcfromtimestamp() も3.12で非推奨になっています。AIのコードにこの名前があれば同じ扱いで直します。
「同じ日の価格が2行に割れる」のはなぜか
たとえば、毎朝8:30(日本時間)に実行していたとします。ある日、1回目が失敗したので9:30に手で再実行しました。utcnow() で日付を作っていると、記録は次のようになります。
実行時刻(日本時間) | UTC | 記録される日付 |
|---|---|---|
10月3日 8:30 | 10月2日 23:30 | 2026-10-02 |
10月3日 9:30 | 10月3日 0:30 | 2026-10-03 |
日本時間では同じ10月3日の価格です。それなのに、日付をまたいで2行に分かれます。グラフにすると、ある日は値が2つ、別の日は0個という歯抜けになります。さらに追記型のCSVなので、同じ日に2回実行すれば同じ日付の行が素直に2行増えます。「1日1行」を保証する仕組みがどこにもありません。
AIに datetime.now(ZoneInfo("Asia/Tokyo")) へ直させる指示文と、直った後のコード
直させる指示(そのまま貼れる形)
「時刻がずれるので直して」だけだと、AIは + timedelta(hours=9) を足すだけの修正をしがちです。これだと、naiveな日時に9時間を足しただけで、タイムゾーンの印は付きません。何をどう直すかを具体的に書くのがコツです。
先ほどのスクリプトを、毎日サーバーで自動実行する前提で次のとおり直してください。
1. datetime.utcnow() は Python 3.12 で非推奨なので使わない。
from zoneinfo import ZoneInfo で JST = ZoneInfo("Asia/Tokyo") を定義し、
現在時刻は datetime.now(JST) で取る。timedelta で9時間足す方式は使わない。
2. 「その日の日付」は日本時間の日付にする。
同じ日付・同じURLは1行だけにし、同じ日に再実行したら上書きする(SQLiteの主キーで保証)。
3. 記録する時刻は +09:00 付きのISO 8601形式にする。
4. except: pass は禁止。失敗したURLはログに理由を出し、最後にまとめて通知し、終了コード1で終わる。
5. requests には timeout を付け、raise_for_status() でHTTPエラーを検出する。
価格の要素が見つからないときもエラー扱いにする。
6. URLごとに5秒空けてアクセスする。User-Agent には連絡先URLを入れる。
7. SlackのWebhook URLはコードに書かず、環境変数 SLACK_WEBHOOK_URL から読む。
8. 変更点を箇条書きで説明してから、全体のコードを出してください。番号付きで条件を並べておくと、出てきたコードを1項目ずつ照合できます。照合は「直ったかどうか」を自分で判断するための手順です。
直った後のコード
import logging
import os
import sqlite3
import sys
import time
from datetime import datetime
from zoneinfo import ZoneInfo
import requests
from bs4 import BeautifulSoup
JST = ZoneInfo("Asia/Tokyo")
URLS = ["https://example.com/item/1", "https://example.com/item/2"]
DB = "/home/username/pricewatch/prices.db"
INTERVAL = 5 # 秒。相手のサーバーへの配慮
HEADERS = {"User-Agent": "pricewatch/1.0 (+https://example.com/contact)"}
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
def get_price(session, url):
r = session.get(url, headers=HEADERS, timeout=20)
r.raise_for_status()
el = BeautifulSoup(r.text, "html.parser").select_one(".price")
if el is None:
raise ValueError(f"価格の要素が見つからない: {url}")
return el.get_text(strip=True)
def notify(text):
hook = os.environ.get("SLACK_WEBHOOK_URL")
if not hook:
logging.warning("SLACK_WEBHOOK_URL が未設定のため通知しない")
return
requests.post(hook, json={"text": text}, timeout=10)
def main():
now = datetime.now(JST)
day = now.date().isoformat()
con = sqlite3.connect(DB)
con.execute("""CREATE TABLE IF NOT EXISTS prices (
day TEXT, url TEXT, price TEXT, fetched_at TEXT,
PRIMARY KEY (day, url))""")
failed = []
with requests.Session() as s:
for i, url in enumerate(URLS):
if i:
time.sleep(INTERVAL)
try:
price = get_price(s, url)
except Exception as e:
logging.error("取得失敗 %s: %s", url, e)
failed.append(url)
continue
con.execute("INSERT OR REPLACE INTO prices VALUES (?, ?, ?, ?)",
(day, url, price, now.isoformat(timespec="seconds")))
con.commit()
con.close()
if failed:
notify(f"価格取得に失敗: {len(failed)}件({day})")
sys.exit(1)
logging.info("%s 分を %d 件記録", day, len(URLS))
if __name__ == "__main__":
main()ブロックごとの説明
- 冒頭の定数:
JST = ZoneInfo("Asia/Tokyo")が今回の要です。ZoneInfoは標準ライブラリのzoneinfoにあり、追加のインストールは要りません。ただし、OS側にタイムゾーンのデータベースが無い環境(Windowsなど)ではZoneInfoNotFoundErrorになります。その場合はpip install tzdataでデータを補います。 get_price():timeout=20で「相手が応答しないまま永遠に待つ」状態を防ぎます。raise_for_status()は404や503を例外に変えます。select_one()がNoneを返したとき(=サイトのHTMLが変わったとき)も、ここで例外にします。旧コードではこの状況が黙って空欄になっていました。notify():Webhook URLはos.environ.get()で環境変数から読みます。未設定でも落ちずに警告だけ出すので、手元での試運転にも使えます。main()の前半:datetime.now(JST)は「日本時間で、+09:00の印が付いた日時」を返します。now.date()は日本時間の日付なので、早朝に実行しても前日にはなりません。SQLiteの表はPRIMARY KEY (day, url)にしてあり、「1日1URL1行」をデータベース側で保証します。- ループ:2件目以降は
time.sleep(INTERVAL)で5秒空けます。1件の失敗で全体を止めず、失敗したURLをfailedに貯めて次へ進みます。INSERT OR REPLACEは、同じ日・同じURLの行があれば上書きします。8:30の失敗を9:30に再実行しても、行は1つのままです。 - 最後:失敗があれば通知して
sys.exit(1)で終わります。終了コードが0以外なら、cronやsystemdの側でも「失敗した」と記録されます。ここが「黙って止まる」を防ぐ最後の砦です。
なお、logging が出すログの時刻は、サーバーのタイムゾーン設定に従います。データベースに入る fetched_at は常に+09:00付きなので、照合するときは fetched_at を正としてください。
以前の書き方と今の書き方
目的 | 以前(AIが出しがち) | 今 |
|---|---|---|
現在時刻 |
|
|
日本時間にする |
| タイムゾーン付きで取得し、足し算はしない |
その日の日付 |
| 日本時間の |
失敗時 |
| ログに残し、通知し、終了コード1 |
秘密の値 | コードに直書き | 環境変数から読む |
AIが出したコードを自分で点検するコマンド
コードを読み慣れていなくても、危うい書き方は文字列で探せます。直させた後に、次の4つを流してみてください。何も表示されなければ合格です。
grep -nE "utcnow|utcfromtimestamp" *.py # 非推奨の時刻API
grep -nE "except:|except Exception:\s*pass" *.py # 失敗の握りつぶし
grep -nE "hooks\.slack\.com|sk-|api_key\s*=" *.py # 秘密の値の直書き
grep -nE "requests\.(get|post)\(" *.py | grep -v timeout # timeout の付け忘れ最後の行で何か表示されたら、その呼び出しに timeout が付いていません(session.get の形は拾わないので、それも目で確認します)。time.sleep がループの中にあるかも併せて見てください。
別のモデルにもレビューさせる
調査では、あるモデルで生成し、別のモデルでレビューさせる「デュアルモデル検証」が、誤りの検出に有効とされています。モデル自体も入れ替わりが速く、ChatGPT・CodexからはGPT-5.5が2026年10月14日で廃止されます。Anthropicは9月28日にClaude Sonnet 5.5を発表しました。「前に動いたプロンプトが、別のモデルでも同じコードを返す」保証はありません。直させた後は次のように頼み、上の表と照らし合わせると確実です。
次のPythonスクリプトを、毎日cronで無人実行する前提でレビューしてください。
特に (1) 非推奨のAPI、(2) タイムゾーンの扱い、(3) 失敗が黙って無視される箇所、
(4) 秘密の値の直書き、(5) アクセス間隔、の5点を、該当行番号つきで指摘してください。
問題が無い項目は「問題なし」と書いてください。取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔
毎日動かすスクリプトは、相手のサーバーに毎日アクセスします。定点観測を始める前に、次の3点は必ず確認してください。
- 利用規約:自動取得を禁止しているサイトでは使いません。公式のAPIやデータ提供があるなら、そちらを使います。
- robots.txt:クローラーに対して、どのパスへのアクセスを控えてほしいかを示すファイルです。Pythonでは標準ライブラリで確認できます。
- アクセス間隔:価格の定点観測なら、1日1回・1件ごとに数秒空ければ十分です。取得件数を増やすなら、間隔も延ばします。
from urllib.robotparser import RobotFileParser
rp = RobotFileParser("https://example.com/robots.txt")
rp.read()
print(rp.can_fetch("pricewatch", "https://example.com/item/1")) # False なら取得しない
print(rp.crawl_delay("pricewatch")) # 指定があればその秒数以上空けるAIに「ブロックされた」と相談すると、User-Agentの偽装・IPの切り替え・CAPTCHAの突破といった提案が返ってくることがあります。これらは相手が明確に拒否している状況で、拒否をすり抜ける手口です。採用しないでください。ブロックされたら、それが相手の答えです。
調査でも、2026年のWebサイトはJavaScriptで内容を後から描画するものが増えています。requests と BeautifulSoup では空のデータしか取れないケースが多い、とされています。価格がJavaScriptで描かれるページなら、Playwrightのようなブラウザ自動化が必要です。その場合の定期実行は Playwright定期実行をsystemdで 2026年10月 にまとめてあります。ブラウザを起動する分だけ相手への負荷も増えるので、間隔はむしろ長めに取ってください。
cron・VPS・自宅PCのどこで動かせば時刻がずれず止まらないか
コード側を ZoneInfo("Asia/Tokyo") に直すと、記録される時刻は、どのサーバーで動かしても日本時間で正しくなります。残る問題は「何時に起動されるか」です。起動時刻は、cronやsystemdが従うサーバーのタイムゾーンで決まります。まず、動かす先で次を実行して確認します。
date
timedatectl # systemd のある Linux なら Time zone の行を見るUTC と出たら、そのサーバーの「9:00」は日本時間の18:00です。
3つの置き場所と、それぞれが止まる条件
置き場所 | 起動時刻の決まり方 | 止まる条件 | 向いているケース |
|---|---|---|---|
手元のPC(cron・タスクスケジューラ) | PCのタイムゾーン(日本設定ならそのまま) | 電源オフ・スリープ中。ノートPCを閉じている日は丸ごと飛ぶ。OS更新後の再起動待ちでも止まる | 試運転。数日抜けても困らない観測 |
共有レンタルサーバーのcron | サーバーのタイムゾーン(UTCのことがある) | サービス側の実行時間・同時実行の制限で強制終了。対応Pythonが古く | requests で数件を1日1回、数十秒で終わる処理 |
VPS・常時稼働の自宅PC(systemd timer) | timer の書き方次第でタイムゾーンを指定できる | 契約切れ・ディスク満杯・停電(自宅の場合)。Pythonを上げたときの仮想環境の作り直し忘れ | 件数が多い、ブラウザが要る、毎日確実に回したい |
共有サーバーのcron(サーバーがUTCの場合)
日本時間9:00に動かしたいなら、UTCの0:00を指定します。
# 分 時 日 月 曜日 ※サーバーがUTCなら、日本時間9:00 = UTC 0:00
0 0 * * * cd /home/username/pricewatch && ./.venv/bin/python pricewatch.py >> pricewatch.log 2>&1cronの実装によっては CRON_TZ=Asia/Tokyo の行でタイムゾーンを指定できます。ただし、使えるかは環境次第です。man 5 crontab で確認できなければ、上のように換算して書くのが確実です。また、cronはSlackの環境変数を引き継がないことが多い点に注意してください。SLACK_WEBHOOK_URL は crontab の先頭に書くか、権限を絞ったファイル(chmod 600)から読み込みます。末尾の >> pricewatch.log 2>&1 を省くと、エラーがどこにも残りません。
VPS・自宅PCの systemd timer
systemd timer は、OnCalendar の末尾にタイムゾーンを書けます。サーバーがUTCのままでも、日本時間9:00を直接指定できます。
# /home/username/.config/systemd/user/pricewatch.timer
[Timer]
OnCalendar=*-*-* 09:00:00 Asia/Tokyo
Persistent=true
[Install]
WantedBy=timers.target# /home/username/.config/systemd/user/pricewatch.service
[Service]
Type=oneshot
WorkingDirectory=/home/username/pricewatch
EnvironmentFile=/home/username/pricewatch/.env
ExecStart=/home/username/pricewatch/.venv/bin/python pricewatch.pysystemd-analyze calendar "*-*-* 09:00:00 Asia/Tokyo"を実行すると、次回の起動時刻が表示されます。書き方が通るかを先に確かめられます。Persistent=trueを付けると、電源が落ちていた間の回を、起動後にまとめて1回実行します。日付は実行時点の日本時間で決まるので、復帰が翌日にずれ込むとその日の分として記録されます。EnvironmentFileにWebhook URLを書き、ファイルはchmod 600にします。コードにもcrontabにも秘密の値が残りません。- スクリプトが
sys.exit(1)で終わると、systemctl --user status pricewatch.serviceに失敗として残ります。journalctl --user -u pricewatch.serviceで理由も追えます。ログアウトしても動かすにはloginctl enable-linger usernameも必要です。
Pythonのバージョンを上げると、utcnow() のような非推奨APIが表に出てきます。仮想環境の作り直しもこのタイミングで必要になります。サーバー上の環境管理は PEP723とuv run --script 2026年10月版 の方法にすると、依存パッケージをスクリプト1枚に閉じ込められます。
実行環境の確認
- Python:
python3 -Vで確認。utcnow()の非推奨警告は3.12以降で出るので、3.12以降で動作確認しておくと、将来の削除に先回りできます - パッケージ:
pip install requests beautifulsoup4(WindowsやタイムゾーンDBの無い環境ではtzdataも)。入ったバージョンはpip show requests beautifulsoup4で控えておく - SQLite:Python標準の
sqlite3を使うので追加インストールは不要
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
まとめ:AIに書かせた定点観測は「時刻・1日1行・黙って止まらない」の3点で直させる
生成AIが書く価格の定点観測スクリプトは、手元で1回動かす分には問題が見えません。しかし、datetime.utcnow() はPython 3.12で非推奨です。サーバーで毎日回すと、記録が9時間ずれます。早朝の実行や再実行では、同じ日の価格が2行に割れます。AIには「datetime.now(ZoneInfo("Asia/Tokyo")) で取る」「日本時間の日付を主キーにして上書き」「except: pass 禁止・終了コード1」「timeoutと待ち時間」「秘密の値は環境変数」を番号付きで指示します。直った後は grep で自分で点検してください。動かす場所は、数件ならcronをUTC換算で、毎日確実に回すならVPSか常時稼働PCのsystemd timer(Asia/Tokyo 指定と Persistent=true)が確実です。