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か所

箇所

何が危ういか

毎日動かすと起きること

datetime.utcnow()

Python 3.12で非推奨。返り値はタイムゾーン情報を持たない「naive(素の)日時」で、中身はUTC

日本時間より9時間遅い時刻が、UTCだという印も無いまま記録される

now.strftime("%Y-%m-%d")

UTCの日付を「その日の日付」として使っている

日本時間の0:00〜8:59に実行すると、前日の日付で記録される

except: pass

どんな失敗も握りつぶし、None を返す

サイトの構造が変わっても空欄が記録され続け、誰も気づかない。Slack通知も一度も飛ばない

ループに待ち時間が無い・timeout も無い

連続でアクセスする。相手が応答しないと永遠に待つ

URLが増えると相手に負荷をかける。ハングするとcronの次の回と重なる

WEBHOOK = "https://..."

通知先URL(実質的にAPIキー)をコードに直接書いている

コードを共有・公開した時点で、第三者がその通知先に投稿できるようになる

1つ目の utcnow() は、Python 3.12以降で実行すると警告が出ます。警告はふつう画面に出ないことが多いので、次のように「警告をエラー扱いにして」実行すると確実に気づけます。

python3 -W error::DeprecationWarning pricewatch.py
DeprecationWarning: 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が出しがち)

今

現在時刻

datetime.utcnow()

datetime.now(ZoneInfo("Asia/Tokyo"))(UTCで欲しければ datetime.now(datetime.UTC)。UTC は from datetime import UTC でも読み込める)

日本時間にする

utcnow() + timedelta(hours=9)

タイムゾーン付きで取得し、足し算はしない

その日の日付

strftime("%Y-%m-%d") をCSVに追記

日本時間の date() を主キーにして上書き

失敗時

except: pass

ログに残し、通知し、終了コード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点は必ず確認してください。

  1. 利用規約:自動取得を禁止しているサイトでは使いません。公式のAPIやデータ提供があるなら、そちらを使います。
  2. robots.txt:クローラーに対して、どのパスへのアクセスを控えてほしいかを示すファイルです。Pythonでは標準ライブラリで確認できます。
  3. アクセス間隔:価格の定点観測なら、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が古く zoneinfo や新しいライブラリが使えない。ブラウザ(Playwright)は使えないことが多い

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>&1

cronの実装によっては 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.py
  • systemd-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)が確実です。