cronで毎朝動かしていたスクレイピングが、ある日から黙って止まっている。原因を調べると、venvの中のPythonがOS更新で消えていた、`source .venv/bin/activate` がcronでは効いていなかった、requirements.txtが手元とサーバーで違っていた。どれもよくある話です。今は、スクリプト冒頭に PEP 723 のインラインメタデータ(`# /// script`)で依存を書き、uv run --script で起動する形にすると、1ファイルだけで依存とPythonのバージョンが揃います。この記事では、requests・beautifulsoup4・pandas を使う収集スクリプトをこの形で書き、cron と systemd timer から毎日動かし続けるところまでを示します。

以前の「venv+requirements.txt+activate」と、今の「PEP 723+uv run --script」

まず書き方の違いを並べます。主役は右側の今の書き方です。

項目

以前の書き方

今の書き方

依存の宣言

別ファイルの requirements.txt

スクリプト冒頭の `# /// script` ブロック

環境の作成

`python3 -m venv .venv` → `pip install -r requirements.txt` を手作業で

`uv run --script` が初回実行時に自動で作り、キャッシュする

Pythonのバージョン

venvを作ったときのOSのPythonに固定(OS更新で参照先が消えうる)

`requires-python` で宣言。条件に合うPythonが無ければuvが用意する

cronからの起動

`cd ... && source .venv/bin/activate && python main.py`

`uv run --script /path/to/news.py` の1行

サーバーへ持っていくもの

スクリプト+requirements.txt+venvの作り直し

スクリプト1ファイル(固定したいならロックファイルも)

なぜ以前の書き方は定期実行で止まりやすいのか

  • cronのシェルは sh:多くのLinuxでcronは `/bin/sh` でコマンドを実行します。`source` はbashの組み込みコマンドなので、shでは使えず、activateされないまま別のPythonで動いて `ModuleNotFoundError` になる、という止まり方をします。
  • venvは元のPythonを参照している:venv(仮想環境=プロジェクト専用のパッケージ置き場)の中の `python` は、作成時に使ったPython本体を指しています。OSのアップグレードでそのPythonが入れ替わると、venvごと動かなくなります。
  • 依存が2か所に分かれる:スクリプトを直したのにrequirements.txtを直し忘れる、手元とサーバーで入っているバージョンが違う、が起きます。

PEP 723 は「スクリプト自身に依存を書く」ための書式で、uvはそれを読んで実行用の環境を自動で組み立てます。依存とコードが同じファイルにあるので、ずれようがありません。

PEP 723 の `# /// script` で requests・beautifulsoup4・pandas を宣言する

実行環境

  • uv:Linux・macOSでは `curl -LsSf https://astral.sh/uv/install.sh | sh` で `~/.local/bin/uv` に入ります。入ったかは `uv --version` で確認します。
  • Python 3.11以上:pandas 3.0系(2026年7月22日に3.0.6)がPython 3.11以上を要求するためです。手元にPython 3.11以上が無くても、uvが条件に合うPythonを取得します。
  • パッケージ:requests・beautifulsoup4・pandas。`pip install` はしません。uvがスクリプトの宣言を読んで入れます。

実際に入ったバージョンは、スクリプトの中で `print(pd.__version__)` するか、`uv run --script` 実行時のログで確認できます。調査時点では requests 2.32.4・beautifulsoup4 4.15.0(2026年6月7日)・pandas 3.0.6 が確認されていますが、実行日によって変わるので数値を決め打ちしないでください。

スクリプト全体(news.py)

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "requests",
#     "beautifulsoup4",
#     "pandas>=3.0",
# ]
# ///
import sys
import time
from datetime import date
from pathlib import Path
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE = "https://example.com"
LIST_URL = f"{BASE}/news/"
PAGES = 3
INTERVAL_SEC = 5
UA = "my-news-scraper/1.0 (+https://example.com/contact)"
OUT_DIR = Path(__file__).resolve().parent / "data"


def load_robots(session):
    rp = RobotFileParser()
    res = session.get(urljoin(BASE, "/robots.txt"), timeout=20)
    rp.parse(res.text.splitlines() if res.ok else [])
    return rp


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    rows = []
    for item in soup.select("ul.news-list li"):
        link = item.select_one("a")
        if link is None:
            continue
        posted = item.select_one("time")
        rows.append({
            "title": link.get_text(strip=True),
            "url": urljoin(BASE, link.get("href", "")),
            "posted": posted.get("datetime") if posted else None,
        })
    return rows


def main():
    session = requests.Session()
    session.headers["User-Agent"] = UA
    robots = load_robots(session)

    rows = []
    for page in range(1, PAGES + 1):
        url = f"{LIST_URL}?page={page}"
        if not robots.can_fetch(UA, url):
            print(f"robots.txt で禁止: {url}", file=sys.stderr)
            return 2
        res = session.get(url, timeout=20)
        res.raise_for_status()
        rows.extend(parse(res.text))
        time.sleep(INTERVAL_SEC)

    if not rows:
        print("0件でした。HTML構造が変わった可能性があります", file=sys.stderr)
        return 1

    df = pd.DataFrame(rows).drop_duplicates(subset="url")
    OUT_DIR.mkdir(exist_ok=True)
    out = OUT_DIR / f"news_{date.today():%Y%m%d}.csv"
    df.to_csv(out, index=False, encoding="utf-8-sig")
    print(f"{len(df)}件を保存: {out}")
    return 0


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

ブロックごとの説明

  1. 1行目のシバン(`#!`):`chmod +x news.py` しておけば `./news.py` だけで uv 経由で起動できます。`env -S` は「後ろの文字列を空白で区切って複数の引数として渡す」指定です。cron・systemdからは後述のとおり uv を明示して呼ぶので、これは手で試すときの便利機能です。
  2. `# /// script` 〜 `# ///`:PEP 723 のインラインメタデータです。中身はTOML(設定ファイルの書式)をコメントとして書いたもので、`requires-python` が使うPythonの条件、`dependencies` が入れるパッケージです。pandasだけ `>=3.0` としているのは、3.0で非推奨機能が削除され挙動(Copy-on-Write=コピーとビューの扱いの統一)が変わったため、2系で動く前提のコードと混ざらないようにするためです。
  3. 定数:取得先、ページ数、アクセス間隔、User-Agent(自分が何者かを相手に示す文字列)、出力先をまとめています。出力先を `Path(__file__)` 基準にしているのは、cronやsystemdから起動したときのカレントディレクトリに左右されないためです。
  4. `load_robots()`:robots.txtを、本体と同じ requests のセッション・同じUser-Agentで取得し、標準ライブラリの `RobotFileParser` に読ませます。
  5. `parse()`:BeautifulSoupの `select()`/`select_one()`(CSSセレクタで要素を取る書き方)で、一覧の各行からタイトル・URL・日付を取り出します。相対リンクは `urljoin()` で絶対URLにします。
  6. `main()`:ページごとにrobots.txtの許可を確かめてから取得し、1ページごとに5秒空けます。0件なら終了コード1で終わるのが定期実行では重要です。HTML構造が変わって0件になっても正常終了してしまうと、何日も空のCSVを作り続けたことに気づけません。
  7. pandasで重複を落としてCSV保存:`drop_duplicates(subset="url")` で同じ記事を1行にし、Excelで開いても文字化けしないよう `utf-8-sig` で保存します。

pandas 3.0での列の書き換え方については pandas 3.0 連鎖代入は.locで書く2026年9月版 にまとめています。また、requestsで取ったHTMLに目的のデータが無い(JavaScriptで後から描画されている)サイトでは `select()` が0件になります。その場合の取り方は __NEXT_DATA__で0件解決|AIコード2026年9月版 を参照してください。

依存の追加と、バージョンの固定

  • 依存を足す:`uv add --script news.py lxml` のように実行すると、`dependencies` に追記されます。ブロックを手で編集してもかまいません。
  • バージョンを固定する:`uv lock --script news.py` を実行すると、スクリプトの隣にロックファイル(解決済みの正確なバージョン一覧)ができ、以後の実行はそれに従います。毎日同じ結果を出したい定期実行では固定しておくのが安全です。お使いのuvがこの指定に対応しているかは `uv help lock` で `--script` が出るかで確かめてください。
  • 更新するとき:ロックを作り直してから、手元で1回実行して結果を確認し、その後サーバーへ配ります。

cron・systemd timer から `uv run --script` で毎日動かす書き方

共通の注意:uv は絶対パスで呼ぶ

cronもsystemdも、ログインしたときのシェルとは違う最小限の `PATH` で動きます。`~/.local/bin` は含まれないことが多いので、`uv` だけ書くと「command not found」で止まります。必ず `/home/username/.local/bin/uv` のように絶対パスで書きます(場所は `command -v uv` で確認できます)。

また、初回だけは手でサーバー上で実行しておきます。初回はuvがパッケージ(場合によってはPython本体)をダウンロードするため、その時刻にネットワークが不調だと定期実行の初回で失敗します。2回目以降はキャッシュ(`~/.cache/uv`)から起動するので速くなります。

cd /home/username/scraper
/home/username/.local/bin/uv run --script news.py

cron で動かす

30 6 * * * /home/username/.local/bin/uv run --script /home/username/scraper/news.py >> /home/username/scraper/news.log 2>&1

`crontab -e` で1行足すだけです。`cd` も `source` も要りません。標準出力とエラーをログファイルに追記しておかないと、失敗の理由が残りません。

systemd timer で動かす(Linuxで常時動くマシンならこちらを推奨)

`~/.config/systemd/user/news.service`:

[Unit]
Description=example.com news scraper

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/.local/bin/uv run --script /home/username/scraper/news.py

`~/.config/systemd/user/news.timer`:

[Unit]
Description=Run news scraper daily

[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now news.timer
loginctl enable-linger username
systemctl --user list-timers
journalctl --user -u news.service
  • `Type=oneshot`:1回動いて終わる処理であることを示します。
  • `Persistent=true`:マシンが止まっていて予定時刻を逃した分を、起動後に1回実行します。cronには無い利点です。
  • `RandomizedDelaySec=300`:毎回ぴったり同じ秒にアクセスしないよう、最大5分ずらします。
  • `loginctl enable-linger`:これが無いと、ログアウトした時点でユーザー単位のtimerが止まります。
  • 終了コード:スクリプトが0件で `1` を返すと、`systemctl --user status news.service` に failed と出ます。ログは `journalctl` で見られるので、ログファイルの管理も要りません。

macOS の launchd で動かす場合

plistの `ProgramArguments` に `/Users/username/.local/bin/uv`・`run`・`--script`・スクリプトの絶対パスを1要素ずつ並べます。考え方はcronと同じで、uvを絶対パスで呼ぶことが要点です。ただし後述のとおり、Macはスリープ中に動きません。

robots.txt・利用規約・アクセス間隔:毎日動かすからこそ守ること

定期実行は、1回の手作業と違って「毎日・何か月も」相手のサーバーにアクセスし続けます。次の点は書き方の一部として組み込んでください。

  • robots.txtを毎回確認する:上のスクリプトは実行のたびに取得し直しているので、相手が方針を変えたらその日から止まります。
  • 利用規約を読む:robots.txtで許可されていても、規約で自動取得を禁じているサイトはあります。公式APIがあるならそちらを使います。
  • アクセス間隔を空ける:ページごとに数秒空け、取得するページ数も必要な分だけにします。並列化して速くする必要はありません。毎日1回なら数十秒かかっても困りません。
  • User-Agentで身元を示す:問い合わせ先URLを入れておくと、相手が困ったときに連絡できます。
  • ブロックされたら止まる:403やCAPTCHA(人間かどうかの確認画面)が出たら、それは「来ないでほしい」という意思表示です。回避する工夫ではなく、取得をやめるか、相手に許可を求めてください。
  • 収集データの使い方:取得したデータの利用方法によっては著作権法(複製権・公衆送信権など)に触れることがあります。社内での集計にとどめるのか、公開するのかで判断が変わります。

毎日動かし続けるのは手元のPC・共有レンタルサーバー・VPSのどれか

スクリプトが1ファイルにまとまっても、「どこで動かすか」を間違えると止まります。それぞれが止まる条件を並べます。

実行場所

向いている条件

止まる条件

手元のPC(cron・launchd)

試運転、取りこぼしても困らない収集

スリープ・フタを閉じる・電源オフ。launchdはスリープ中の時刻を飛ばし、復帰時にまとめて動くことがある

共有レンタルサーバーのcron

requestsとBeautifulSoupで取れる静的なページ、数分で終わる処理

ホームディレクトリにuvを置けない・外部へのダウンロードが制限されているプラン、実行時間の上限、ブラウザ(Selenium・Playwright)の依存ライブラリを入れられない場合。契約前にプランの仕様を確認する

VPS・自宅の常時稼働マシン(systemd timer)

毎日確実に動かしたい、Playwright等のブラウザが要る、複数の処理を並べたい

契約・電源の停止、再起動後にtimerが有効になっていない、`enable-linger` 忘れ。OS更新そのものではuv側の環境は壊れにくい

判断の目安

  • requests+BeautifulSoupだけで取れて、1日1回・数分で終わる:共有サーバーのcronで足ります。uvを置けるかだけ先に確かめます。
  • JavaScriptで描画されるページで、Playwright・Seleniumが要る:ブラウザ本体と依存ライブラリを入れられる VPS か常時稼働マシンにします。2026年時点では動的なサイトが増えており、requestsだけで取れないケースは珍しくありません。
  • 止まったら業務に影響する:systemd timerで `Persistent=true` と終了コードによる失敗検知を組み合わせ、`journalctl` を見る習慣をつけます。

関連する選択肢

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

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

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

まとめ:PEP 723+uv run --scriptで「1ファイルで動き続ける」形にする

定期実行のスクレイピングが止まる原因の多くは、コードではなく環境にあります。cronのshで `source` が効かない、venvが参照するPythonがOS更新で消える、requirements.txtとスクリプトがずれる。PEP 723 の `# /// script` で `requires-python` と `dependencies`(requests・beautifulsoup4・pandas>=3.0)をスクリプト自身に書き、`uv run --script` で起動すれば、依存とPythonの条件が1ファイルに収まり、cronもsystemd timerも1行で呼べます。uvは絶対パスで書き、初回は手で実行してキャッシュを作り、必要なら `uv lock --script` で固定します。0件なら異常終了させて、構造変更に気づける形にしておきます。そのうえで、静的ページなら共有サーバーのcron、ブラウザが要るならVPSのsystemd timer、と止まる条件から実行場所を選び、robots.txtとアクセス間隔を守って長く動かしてください。