停電や再起動、ノートPCのスリープが重なった日に限って、cronで回している日次スクレイピングが走っておらず、ログにも何も残っていない。cronは「その時刻に動いていなかった回」を後から実行しないので、こうした取りこぼしは起きるたびに黙って消えます。この記事では、Ubuntu 24.04(systemd 255)で systemd timer の OnCalendar・Persistent=true・RandomizedDelaySec を使い、Pythonの収集スクリプトを毎日取りこぼさず動かす書き方を、動くコードと一緒に示します。

cronが停電・再起動・スリープ中の回を飛ばす理由と、systemd timer の Persistent=true

cronは「その1分」に起きていなければ何もしない

cronのデーモンは毎分、時刻表(crontab)と現在時刻を照らし合わせ、一致した行だけを実行します。0 6 * * * と書いた場合、6:00ちょうどにマシンが電源断・再起動中・スリープ中だと、その回は実行されません。復帰後に「さっきの6:00の分」を思い出す仕組みもありません。失敗ではなくそもそも起動していないので、エラーログも残りません。

Ubuntu には遅れた日次処理を起動時に拾う anacron もあります。ただし扱うのは /etc/cron.daily などroot権限の仕組みで、粒度も「日単位」です。一般ユーザーのPythonスクリプトを決まった時刻に回し、遅れたら拾う、という用途には systemd timer の方が素直です。

以前の書き方と今の書き方

項目

以前:cron

今:systemd timer

時刻指定

0 6 * * *

OnCalendar=*-*-* 06:00:00

電源断・スリープ中の回

実行されない(記録も残らない)

Persistent=true で起動・復帰後に1回実行

複数台・複数ジョブの集中

同時刻に一斉起動

RandomizedDelaySec で起動時刻をばらす

ログ

自分でリダイレクトする

journalctl に自動で残る

多重起動

前回が終わっていなくても次が起動

同じサービスが動作中なら重ねて起動しない

Persistent=true が何を見ているか

Persistent=true を付けたタイマーは、最後に発火した時刻を「スタンプファイル」としてディスクに残します。ユーザー単位のタイマーなら ~/.local/share/systemd/timers/stamp-<タイマー名> です。タイマーが起動したとき(=OS起動時やスリープ復帰時)にこの時刻とスケジュールを比べ、本来発火すべき時刻を過ぎていればすぐに1回だけ実行します。

  • 3日間電源が落ちていても、復帰後に走るのは1回です(3回分まとめて走るわけではありません)
  • この振る舞いは OnCalendar で指定したタイマーにだけ効きます

「1回だけ」なので、スクリプト側は「今日の分を取ったか」を自分で判断できるように書いておく必要があります。次の節のコードはその前提で作っています。

取りこぼしても二重に取らないPython収集スクリプト(requests・BeautifulSoup)

実行環境

  • OS:Ubuntu 24.04(systemdのバージョンは systemctl --version で確認)
  • Python:OS標準の python3。python3 -V で確認してください。なお Python 3.9 は 2025年10月31日でサポートが終了しています。古い環境から移す場合はこの機会に上げておくのが安全です
  • パッケージ:requests・beautifulsoup4。2026年10月時点で requests は 2.34.2、BeautifulSoup は 4.15.0(2026年6月7日公開)が出ています。入ったバージョンは pip show requests beautifulsoup4 で確認できます
mkdir -p ~/collector && cd ~/collector
python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4

Ubuntu 24.04 はシステムのPythonに直接 pip install できない設定になっているため、仮想環境(venv:プロジェクト専用のPython環境)を作り、その中の python をフルパスで呼ぶ形にします。systemd からは source .venv/bin/activate を経由せずに済みます。なお pip install beautifulsoup(末尾の4なし)は旧版が入るので、必ず beautifulsoup4 と指定します。

コード全体(~/collector/collect.py)

#!/usr/bin/env python3
"""一覧ページの見出しを1日1回集めて CSV に追記する。"""
import csv
import datetime as dt
import sys
import time
from pathlib import Path
from urllib import robotparser

import requests
from bs4 import BeautifulSoup

BASE = "https://example.com"
TARGET = BASE + "/news/"
UA = "example-collector/1.0 (+https://example.com/bot.html)"
DATA_DIR = Path.home() / "collector" / "data"
RETRIES = 5


def fetch(url):
    """起動直後でネットワークが無い場合に備え、待ちながら再試行する。"""
    for attempt in range(1, RETRIES + 1):
        try:
            return requests.get(url, headers={"User-Agent": UA}, timeout=30)
        except (requests.ConnectionError, requests.Timeout) as e:
            print(f"接続失敗 {attempt}/{RETRIES}: {e}", file=sys.stderr)
            if attempt < RETRIES:
                time.sleep(60 * attempt)
    raise RuntimeError("ネットワークが戻らないまま再試行を使い切った")


def allowed(url):
    res = fetch(BASE + "/robots.txt")
    if res.status_code == 404:
        return True
    rp = robotparser.RobotFileParser()
    rp.parse(res.text.splitlines())
    return rp.can_fetch(UA, url)


def main():
    today = dt.date.today().isoformat()
    done = DATA_DIR / f"{today}.done"
    if done.exists():
        print(f"{today} は取得済み")
        return 0

    if not allowed(TARGET):
        print("robots.txt で許可されていない", file=sys.stderr)
        return 1
    time.sleep(1)

    res = fetch(TARGET)
    res.raise_for_status()
    soup = BeautifulSoup(res.text, "html.parser")
    rows = [(today, a.get_text(strip=True), a.get("href"))
            for a in soup.select("article h2 a")]
    if not rows:
        print("0件。ページ構造が変わった可能性", file=sys.stderr)
        return 1

    DATA_DIR.mkdir(parents=True, exist_ok=True)
    with open(DATA_DIR / "items.csv", "a", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(rows)
    done.touch()
    print(f"{today}: {len(rows)}件")
    return 0


if __name__ == "__main__":
    sys.exit(main())

ブロックごとの説明

  • 定数部:取得先は例として example.com にしています。UA は自分が何者かを示すUser-Agent(アクセス元の名乗り)で、連絡先URLを含めます
  • fetch():Persistent=true による実行は起動・スリープ復帰の直後に来るため、Wi-FiやLANがまだつながっていないことがよくあります。接続エラーとタイムアウトのときだけ 60秒→120秒→…と間隔を伸ばして最大5回試します。相手が返したHTTPエラー(4xx/5xx)は再試行せず、そのまま呼び出し側に返します。相手が拒否・障害中のときに叩き続けないためです
  • allowed():robots.txt を fetch() 経由で取り、標準ライブラリの robotparser で判定します。RobotFileParser.read() を直接使わないのは、起動直後の接続失敗を同じ再試行に乗せるためです
  • main() 冒頭の .done 判定:その日の取得が済んでいれば、ネットワークに一切触れずに終わります。手動で再実行したり、復帰後の実行と通常の6:00が同じ日に重なっても、同じ日のデータを二重に追記しません
  • 0件チェック:取得先のHTML構造が変わると、セレクタ(article h2 a)が何も拾わず、エラーにならないまま空のデータが積み上がります。0件は異常として終了コード1で終え、systemd側で「失敗」と記録させます
  • 終了コード:sys.exit(main()) で 0/1 をそのまま systemd に返します。これが無いと、失敗しても「成功」として記録されます

なお、このスクリプトが集めるのは「実行した日の一覧ページ」です。3日間止まっていた場合、復帰後に取れるのはその日の分だけで、止まっていた間の一覧は戻ってきません。過去分が取れる日付指定のURLがあるサイトなら、.done の無い日付をさかのぼって取る形に広げられます。

取得先サイトへの配慮(robots.txt・利用規約・アクセス間隔)

  • robots.txt と利用規約は実行前に人が読む:コード内の判定は保険です。利用規約でスクレイピングを禁じているサイト(主要なSNS・動画サイト、国内の大手ECサイトの多く)は対象にしないでください。公式APIがあるならそちらを使います
  • ログインが必要なページは対象にしない:非公開の情報とみなされ、不正アクセス禁止法に触れるおそれがあります
  • アクセス間隔は最低1秒:複数ページを取る場合は各リクエストの間に time.sleep(1) 以上を入れます。過去にはアクセス集中でサーバー障害を起こし、逮捕者が出た事例も報じられています
  • User-Agentは名乗る:JPCERT/CCが 2026年10月9日に出した注意喚起では、国内組織への不正アクセスで python-requests/2.34.2 などのUser-Agentが観測されたと報告されています。既定のままだと攻撃と同じ名乗りでアクセスすることになるため、用途と連絡先が分かる文字列にしておきます。これは身元を明かすための設定で、ブロックをすり抜ける目的で他のブラウザを装うのとは逆の考え方です
  • 止まっていた分を一度に取り返そうとしない:さかのぼり取得を足す場合も、1回の実行で取る日数に上限を設け、間隔を空けます

OnCalendar・Persistent=true・RandomizedDelaySec で書く systemd timer(Ubuntu 24.04)

サービスファイル(~/.config/systemd/user/collector.service)

[Unit]
Description=Daily scraping collector

[Service]
Type=oneshot
WorkingDirectory=%h/collector
ExecStart=%h/collector/.venv/bin/python %h/collector/collect.py
TimeoutStartSec=30min
  • Type=oneshot:1回走って終わる処理であることを示します。実行中は「動作中」とみなされるので、その間にタイマーが再度発火しても二重には起動しません
  • %h:ホームディレクトリ(例:/home/username)に置き換わります。ユーザー名をファイルに直書きせずに済みます
  • ExecStart はvenv内の python をフルパスで指定します。cronでよくある「手で実行すると動くのに定期実行だとモジュールが見つからない」という問題は、PATHや仮想環境が引き継がれないことが原因です。フルパスにすれば避けられます
  • TimeoutStartSec=30min:再試行の待ち時間(合計10分)を含めても収まる上限です。ハングしたまま翌日まで居座るのを防ぎます

タイマーファイル(~/.config/systemd/user/collector.timer)

[Unit]
Description=Run collector daily

[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target
  • OnCalendar=*-*-* 06:00:00:「毎年・毎月・毎日の 6:00:00」。cronの 0 6 * * * にあたります
  • Persistent=true:前節のスタンプファイルを使い、止まっていた間に過ぎた回を起動後に1回実行します
  • RandomizedDelaySec=15min:発火時刻を0〜15分の範囲でランダムに遅らせます。同じ6:00に他のジョブやほかのマシンからのアクセスが集中するのを避けられ、取得先から見ても毎日ぴったり同じ秒にアクセスが来なくなります。遅らせてよい幅は、データを使う時刻から逆算して決めます

時刻の書き方が意図どおりかは、登録前に確かめられます。

systemd-analyze calendar '*-*-* 06:00:00'
systemd-analyze --user verify ~/.config/systemd/user/collector.*

1行目は「次はいつ発火するか」を表示し、2行目はユニットファイルの書き間違いを検出します。

有効化と、ログアウト後も動かすための設定

systemctl --user daemon-reload
systemctl --user enable --now collector.timer
sudo loginctl enable-linger username
systemctl --user list-timers
  1. daemon-reload で新しいユニットファイルを読み込ませます(編集のたびに必要)
  2. enable --now で、今すぐ有効にし、次回起動時にも自動で有効になるよう登録します。.service ではなく .timer の方を有効にします
  3. enable-linger:ユーザー単位のタイマーは、既定ではそのユーザーがログインしている間しか動きません。再起動後にログインしないまま運用するサーバーでは必須です。ここを忘れると、Persistent=true を書いていても再起動後に一切動きません
  4. list-timers で、NEXT(次回)と LAST(前回)の欄に予定と実績が出ていることを確認します

取りこぼしが拾われたかの確かめ方

journalctl --user -u collector.service --since today
ls -l --time-style=full-iso ~/.local/share/systemd/timers/

再起動のあと、1行目のログに起動直後の時刻で「接続失敗 1/5」→「◯件」と出ていれば、復帰後の実行と再試行の両方が働いています。2行目はスタンプファイルの更新時刻で、最後に発火した時刻が分かります。手で試すときは systemctl --user start collector.service でタイマーを待たずに実行できます。

失敗(終了コード1)を検知して通知を飛ばす部分は、systemd OnFailureでSlack通知|26年10月 の書き方をそのまま組み合わせられます。Seleniumでブラウザを使う収集の場合は、タイムアウトで止められたときにブラウザが残らないよう Selenium with文とsystemdで後始末|26年10月 の形にしておくと、翌日の実行がメモリ不足で落ちるのを防げます。

手元PC・共有サーバーのcron・VPS:毎日動かし続けられる条件と止まる条件

Persistent=true は「止まっていた分を後で拾う」仕組みで、マシンが動いていなければ何も起きません。どこに置くかで、止まる条件が変わります。

置き場所

使えるスケジューラ

止まる条件

向いている用途

手元のLinux PC

systemd timer

電源断・スリープの間は動かない。復帰後に1回拾うが、何日も開かなければその間のデータは取れない

実行日の一覧だけ取れればよい、多少遅れてもよい収集

手元のMac

launchd(StartCalendarInterval)

スリープ中に過ぎた回は復帰時に1回まとめて実行されるが、電源が切れていた間の回は実行されない

毎日PCを開く人の個人用途

共有レンタルサーバー

cron(ユーザー単位のsystemdは通常使えない)

サーバー自体は止まりにくいが、メンテナンスで落ちていた回は拾われない。プランごとの実行時間・プロセス数の制限で途中終了しうる

requests+BeautifulSoupで数分で終わる軽い収集

VPS

systemd timer

事業者側のメンテナンス再起動・障害。Persistent=true と enable-linger があれば再起動後に拾う

Selenium・Playwrightなど常駐ブラウザが要る収集、毎日確実に回したい処理

選び方の目安

  • cronしか無い共有サーバー:時刻を1日2回(例:6:00と18:00)に増やし、スクリプトの .done 判定で「先に成功した方だけが取る」形にすると、1回落ちても同じ日のうちに拾えます。スクリプト側を冪等(何回呼んでも結果が同じ)にしておくのはこのためです
  • ブラウザを使う収集:JavaScriptで描画されるページはSeleniumやPlaywrightが必要で、メモリと実行時間を食います。共有サーバーの制限に当たりやすいため、VPSか常時稼働の手元マシンに置きます
  • 止まる条件をゼロにはできない:どこに置いても、失敗したら気づける仕組み(終了コードを正しく返す・OnFailureで通知する)が要ります。黙って止まるのを防ぐのが、取りこぼし対策の最後の一段です

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

cronの取りこぼしをsystemd timerで防ぐための要点

cronは指定時刻にマシンが動いていなければその回を実行せず、記録も残しません。Ubuntu 24.04 では systemd timer に移し、OnCalendar で時刻を、Persistent=true で止まっていた間の回を起動後に1回だけ実行させ、RandomizedDelaySec で起動時刻をばらします。ユーザー単位なら loginctl enable-linger を忘れないこと。Pythonスクリプト側は、復帰直後のネットワーク不通に備えた再試行、日付ごとの .done による二重取得防止、0件を異常として返す終了コードの3点を入れておきます。取得先の robots.txt・利用規約を確認し、1秒以上の間隔と名乗ったUser-Agentで、相手に負荷をかけずに続けることが、毎日動かし続ける前提になります。