VPSで毎朝動かしているスクレイピングのCSVが、なぜか前日の日付で保存されている。Python 3.12に上げたら datetime.utcnow() の非推奨警告がログに出るようになった。こうした悩みは、cronやsystemd timerで日本時間を指定する方法と、コード側で ZoneInfo("Asia/Tokyo") を使う方法を組み合わせれば解決します。この記事では、サーバーのタイムゾーンに左右されずに定期実行を続けるための書き方を、動くコードとあわせて説明します。
Python 3.12で非推奨の datetime.utcnow() を ZoneInfo("Asia/Tokyo") に置き換える
以前の書き方:utcnow() に9時間足していた
定期実行のスクリプトで、私も以前は次のように書いていました。
from datetime import datetime, timedelta
# 以前の書き方(Python 3.12以降は非推奨)
now = datetime.utcnow() + timedelta(hours=9)
filename = now.strftime("%Y%m%d") + ".csv"
この書き方には問題が2つあります。
utcnow()が返すのはnaive(ナイーブ)なdatetime、つまりタイムゾーン情報を持たない日時です。値そのものはUTCなのに、オブジェクトを見ても「どこの時刻か」が分かりません。手で9時間を足したあとも、そのまま「どこの時刻か分からない値」として扱われます。- Python 3.12から
utcnow()は非推奨になりました。実行すると次の警告が出ます。
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).
警告文にあるとおり、今後はaware(アウェア)なdatetimeを使う方針です。awareなdatetimeとは、タイムゾーン情報を持った日時のことです。
もう一つよく見るのが、引数なしの datetime.now() を使う書き方です。こちらはサーバーのタイムゾーン設定をそのまま使います。手元のMacで日本時間で動いていたものが、UTC設定のVPSに移したとたんに9時間ずれます。
今の書き方:datetime.now(ZoneInfo("Asia/Tokyo"))
from datetime import datetime, UTC
from zoneinfo import ZoneInfo
JST = ZoneInfo("Asia/Tokyo")
now_jst = datetime.now(JST) # 日本時間のaware datetime
now_utc = datetime.now(UTC) # utcnow() の置き換え先(UTCのaware datetime)
print(now_jst.isoformat(timespec="seconds")) # 例: 2026-10-24T09:00:00+09:00
print(now_jst.strftime("%Y%m%d")) # 例: 20261024
zoneinfoはPython 3.9から標準ライブラリに入っているモジュールで、IANAのタイムゾーン名(Asia/Tokyoなど)を使えます。datetime.now(JST)は、サーバーの設定が何であっても日本時間の現在時刻を返します。datetime.UTCはPython 3.11から使えます。3.10以前も対象にするならfrom datetime import timezoneを読み込み、timezone.utcを使います。isoformat()の結果には+09:00が付きます。あとでCSVを読み返したとき、どのタイムゾーンの時刻か迷いません。
実行環境と、tzdataが要るケース
項目 | 内容 |
|---|---|
Python | 3.12以降を想定( |
pip install |
|
tzdata | Linuxは通常OSのタイムゾーンデータを使うので不要です。Windowsや最小構成のコンテナでOSにデータが無い場合は |
使えるかどうかは次の1行で確かめられます。ZoneInfoNotFoundError が出たら、tzdataを入れてください。
python3 -c "from zoneinfo import ZoneInfo; from datetime import datetime; print(datetime.now(ZoneInfo('Asia/Tokyo')))"
UTCのVPSでも日本時間の日付でCSVを保存するスクレイピングのコード
冒頭の「前日の日付で保存される」現象は、次の条件で起きます。日本時間の朝8時30分に動くと、UTCのサーバーではまだ前日の23時30分です。そのため date.today() や引数なしの datetime.now() は前日の日付を返します。日付の境目をまたぐ時間帯に動かすジョブほど、このずれを踏みます。
次のスクリプトは、日本時間の日付でファイル名を決め、取得時刻もタイムゾーン付きで記録します。
#!/usr/bin/env python3
import csv
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
from bs4 import BeautifulSoup
JST = ZoneInfo("Asia/Tokyo")
BASE_DIR = Path(__file__).resolve().parent
OUT_DIR = BASE_DIR / "data"
URL = "https://example.com/news"
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0; +https://example.com/bot)"
}
def fetch(url):
res = requests.get(url, headers=HEADERS, timeout=30)
res.raise_for_status()
return res.text
def parse(html):
soup = BeautifulSoup(html, "html.parser")
rows = []
for a in soup.select("ul.news-list a"):
rows.append({"title": a.get_text(strip=True), "url": a.get("href", "")})
return rows
def main():
now = datetime.now(JST)
OUT_DIR.mkdir(exist_ok=True)
out = OUT_DIR / f"news_{now:%Y%m%d}.csv"
if out.exists():
print(f"{out.name} は取得済みのためスキップ")
return 0
rows = parse(fetch(URL))
fetched_at = now.isoformat(timespec="seconds")
tmp = out.with_suffix(".tmp")
with tmp.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["fetched_at", "title", "url"])
writer.writeheader()
for r in rows:
writer.writerow({"fetched_at": fetched_at, **r})
tmp.replace(out)
print(f"{len(rows)}件を {out} に保存")
return 0
if __name__ == "__main__":
sys.exit(main())
ブロックごとの説明
- 定数部分:
JSTをモジュールの先頭で1回だけ作ります。BASE_DIRは__file__から求めた絶対パスです。cronやsystemdから起動すると、作業ディレクトリ(カレントディレクトリ)がスクリプトの場所と同じとは限りません。相対パスでdata/と書くと、思わぬ場所に保存されます。 fetch():timeout=30を付けています。付けないと、相手が応答しないときに永遠に待ち、次の回の実行と重なることがあります。raise_for_status()は、4xx・5xxの応答を例外にして空のCSVを残さないための処理です。parse():セレクタul.news-list aは例です。取得先の実際のHTMLに合わせて書き換えてください。main()の日付決定:datetime.now(JST)を1回だけ呼び、ファイル名とfetched_atの両方に同じ値を使います。何度も呼ぶと、0時直前に動いたときにファイル名と中身の日付が食い違うことがあります。- 取得済みチェック:同じ日のファイルがあれば取りに行きません。後述する
Persistent=trueによる追いかけ実行や手動での再実行があっても、相手サイトへのアクセスは1日1回に収まります。 - 一時ファイルから置き換える処理:
.tmpに書いてからreplace()で差し替えます。途中で落ちても、中途半端なCSVを「取得済み」と誤って判定しません。
環境変数TZで済ませる方法との違い
systemdのユニットに Environment=TZ=Asia/Tokyo を書けば、引数なしの datetime.now() も日本時間を返します。ただしこの方法は、起動方法を変えたとたんに効かなくなります。cronへ移した場合や、手でSSHから実行した場合がそうです。日付の正しさがコードを見ただけで分かるように、私はコード側で ZoneInfo を明示する方を選んでいます。
systemd timerの OnCalendar=... Asia/Tokyo とcronのどちらで日本時間を指定するか
本題の問いへの答えは「両方で指定する。ただし決めていることが違う」です。
指定する場所 | 決まること | 指定しないと起きること |
|---|---|---|
コード( | ファイル名・記録する日時が何日扱いか | UTCのサーバーで前日の日付になる |
スケジューラ | 何時に起動するか | 日本時間の9時のつもりが18時に動く |
スケジューラ側だけ直しても、コードが引数なしの datetime.now() のままならファイル名はUTC基準です。逆にコードだけ直しても、起動時刻はずれたままです。
VPSなら systemd timer にタイムゾーンを書く
systemdの OnCalendar= は、末尾にタイムゾーン名を付けられます。サーバー全体のタイムゾーンを変えずに、このジョブだけ日本時間で起動できます。以下はユーザー単位のユニット(~/.config/systemd/user/)の例です。
scraper.service
[Unit]
Description=daily news scraper
[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
scraper.timer
[Unit]
Description=run scraper every morning (JST)
[Timer]
OnCalendar=*-*-* 09:00:00 Asia/Tokyo
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target
OnCalendar=*-*-* 09:00:00 Asia/Tokyo:毎日、日本時間の9時に起動します。Persistent=true:サーバーが止まっていて実行を逃した場合、次の起動時にまとめて1回実行します。前節の取得済みチェックがあるので、二重に取得はしません。RandomizedDelaySec=10min:起動を最大10分ずらし、ちょうど9時ちょうどにアクセスが集中するのを避けます。ExecStartには仮想環境のPythonを絶対パスで書きます。pip installしたパッケージが見つからない、という事故を防げます。
書いたら、まず時刻の解釈を確かめます。
username@myserver:~$ systemd-analyze calendar "*-*-* 09:00:00 Asia/Tokyo"
出力の「Next elapse」に次回の起動予定が表示されます。サーバーがUTCなら、そこにUTCで0時と表示されるはずです。古いsystemdではタイムゾーン付きの指定がエラーになる場合があります。そのときは OnCalendar=*-*-* 00:00:00 のようにUTCに換算して書いてください。日本は夏時間が無いので、差は常に9時間です。
systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
systemctl --user list-timers
loginctl enable-linger username # ログアウト後もuser timerを動かす
最後の enable-linger を忘れると、SSHを切った時点でユーザー単位のタイマーが止まります。
cronの場合:CRON_TZ は実装次第、TZ= は起動時刻を変えない
cronは実装によって挙動が分かれます。ここが一番つまずきやすい点です。
CRON_TZ=Asia/Tokyo:スケジュールそのものを日本時間で解釈させる指定です。ただし、対応している実装と対応していない実装があります。man 5 crontabにCRON_TZの記載があるかで確認してください。TZ=Asia/Tokyo:crontabに書いても、多くの実装ではジョブに渡す環境変数になるだけです。起動時刻はcronデーモン側のタイムゾーンで決まります。「TZを書いたのに9時間ずれる」原因はたいていこれです。
確実なのは、UTCに換算して書く方法です。UTCのサーバーで日本時間の9時に動かすなら、次のようになります。
# 日本時間 09:00 = UTC 00:00(日本は夏時間なし)
0 0 * * * /home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py >> /home/username/scraper/cron.log 2>&1
サーバーのタイムゾーンは timedatectl または date で確認できます。共有レンタルサーバーでは、コントロールパネルのcron設定がどのタイムゾーンで解釈されるかを、各社のマニュアルで確かめてください。cronでの定期実行の組み方そのものは、uv run --scriptでcron定期実行|26年10月で詳しく書いています。
launchd(Mac)の場合
launchdの StartCalendarInterval はMacのローカル時刻で解釈されます。Macを日本時間で使っているなら、スケジュール側の指定は不要です。それでもコード側の ZoneInfo は入れておきます。あとでVPSへ移したとき、そのまま動くからです。
pandasでUTCの記録と日本時間の記録を混ぜずに集計する
以前 utcnow() で記録していたCSVと、今回の +09:00 付きのCSVが混ざることがあります。そのまま文字列で並べ替えると、9時間ずれた順番になります。pandasではタイムゾーンをそろえてから扱います。
import pandas as pd
from pathlib import Path
files = sorted(Path("/home/username/scraper/data").glob("news_*.csv"))
df = pd.concat([pd.read_csv(f) for f in files], ignore_index=True)
# +09:00 などのオフセット付き文字列を、いったんUTCで揃えてから日本時間へ
df["fetched_at"] = pd.to_datetime(df["fetched_at"], utc=True).dt.tz_convert("Asia/Tokyo")
daily = df.groupby(df["fetched_at"].dt.date).size()
print(daily)
utc=True:オフセットの異なる値が混ざっていても、全部をUTCのaware datetimeとして読みます。.dt.tz_convert("Asia/Tokyo"):日本時間に変換します。.dt.dateで取り出す日付も日本時間の基準になります。- 過去にオフセットの無いUTCの値(
utcnow()の出力)で記録した列があれば、別に読み込みます。pd.to_datetime(...).dt.tz_localize("UTC").dt.tz_convert("Asia/Tokyo")で「これはUTCだった」と宣言してから変換してください。
pandasは2026年10月22日に3.1.0がリリースされています。3.1では select_dtypes() に "datetime64[ns]" を渡したときの選び方が変わっています。日時列をdtypeで選んでいるコードがあれば、pip show pandas でバージョンを確認してから動作を見直してください。表の読み込みまわりの書き換えはpandas read_html StringIO化|26年10月にもまとめています。
毎朝9時のスクレイピングで守る取得先への配慮(robots.txt・利用規約・アクセス間隔)
定期実行は、一度設定すると止まらずに相手のサーバーへアクセスし続けます。時刻を正しくするのと同じくらい、次の点を最初に確認してください。
- robots.txt:法的な拘束力はありませんが、尊重するのが誠実な振る舞いです。
https://example.com/robots.txtを開き、取得するパスが禁止されていないか確かめます。 - 利用規約:ログインして規約に同意するサイトでは、規約違反が契約違反になりえます。米国のhiQ v. LinkedInでは、公開データへのアクセス自体はCFAA違反ではないとされました。しかしhiQは利用規約違反で50万ドルの和解金とデータ削除に至っています。Amazonのように、規約で自動ツールによる収集を明確に禁じているサイトもあります。
- アクセス間隔:日本では情報解析目的の利用は著作権法第30条の4で広く認められています。一方で、サーバーに過度な負荷をかければ業務妨害罪に問われる可能性があります。この記事のコードは1日1回・1ページだけ取得し、取得済みなら取りに行きません。複数ページを辿るなら、1リクエストごとに数秒の間隔を空けてください。
- User-Agentを正直に名乗る:
MyScraper/1.0; +https://example.com/botのように、ボットであることと連絡先を示します。 - ブロックされたら止める:Cloudflareは2026年9月15日から、AIエージェントなどに分類されるボットへの既定の扱いを厳しくしています。403が返るようになったら、回避策を探すのではなく、取得をやめるか公式APIの有無を確認してください。
書いたスクレイピングを毎日動かし続けるには何が要るか
コードとスケジューラの日本時間をそろえたら、最後に「どこで動かすか」を決めます。それぞれに、止まる条件があります。
実行場所 | 日本時間の指定 | 止まる条件 |
|---|---|---|
手元のPC(launchd・タスクスケジューラ) | PCのローカル時刻でよい | 電源オフ・スリープ中は動かない。持ち歩くノートPCは起動時刻が読めない |
共有レンタルサーバーのcron | UTC換算か | プランの実行時間・プロセス制限で強制終了されうる。Chromeを使うSeleniumやPlaywrightは動かせないことが多い |
VPS(systemd timer) |
| OS更新後の再起動でタイマーを |
- requestsとBeautifulSoupだけのスクリプトなら、共有サーバーのcronで足ります。
- ブラウザを起動するスクリプトや、取りこぼしたら追いかけて実行してほしいジョブは、
Persistent=trueを使えるVPSのsystemd timerが向いています。ブラウザ側の設定変更はSelenium --headless=new移行|26年10月も参考にしてください。 - どこで動かす場合も、
systemctl --user list-timersやcron.logを定期的に見て、「動いたこと」と「ファイルが増えたこと」の両方を確かめます。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
まとめ:utcnow()の置き換えとcron・systemdの日本時間は2か所で指定する
Python 3.12で非推奨になった datetime.utcnow() は、datetime.now(UTC) か datetime.now(ZoneInfo("Asia/Tokyo")) に置き換えます。後者を1回だけ呼んで、ファイル名と記録時刻の両方に使えば、UTCのVPSでも日本時間の日付で保存されます。
起動時刻はスケジューラの役目です。systemd timerなら OnCalendar=*-*-* 09:00:00 Asia/Tokyo と書き、systemd-analyze calendar で確かめます。cronでは TZ= では起動時刻が変わらない実装が多いので、UTCに換算して書くのが確実です。
あわせて、取得済みチェックで1日1回のアクセスに抑え、robots.txtと利用規約を確認します。手元のPC・共有サーバー・VPSのそれぞれが止まる条件を把握したうえで、実行場所を選んでください。