スクレイピングのスクリプトをサーバーへ移すと、venv の作り直しや activate 忘れで cron が止まる。そんな経験はないでしょうか。2026年10月現在、この問題は uv の uv run --script と PEP 723 のインライン依存宣言(# /// script)でかなり楽になります。依存ライブラリと Python バージョンをスクリプトの先頭に書いておけば、cron や systemd timer の ExecStart から1行で直接動かせます。この記事では、requests・BeautifulSoup・pandas を使う1ファイルのスクリプトを例に、書き方と定期実行の設定を紹介します。
venv+requirements.txt から PEP 723+uv run --script へ:何が変わったか
まず以前の書き方と今の書き方を比べます。以前は「スクリプト本体」「requirements.txt」「venv ディレクトリ」の3つを揃えて初めて動きました。今はスクリプト1ファイルに全部が入る形にできます。
項目 | 以前(venv+requirements.txt) | 今(PEP 723+uv run --script) |
|---|---|---|
依存の記録場所 | 別ファイルの requirements.txt | スクリプト先頭の |
Python バージョン | サーバーに入っているものに依存(README に書くだけ) |
|
サーバーへ移すとき | venv を作り直して | ファイルをコピーして uv を入れるだけ |
cron の書き方 |
|
|
止まる典型例 | venv のパス変更、OS 更新でシステムの Python が上がり venv が壊れる | uv 本体のパスを cron が見つけられない(後述) |
PEP 723 は「スクリプトの中に依存情報を書くための書式」を定めた Python の標準仕様です。uv 専用の記法ではありません。uv run --script はその書式を読み、スクリプト専用の隔離環境を自動で作って実行します。環境はキャッシュされるので、2回目以降はインストールし直しません。
Python バージョンの宣言は、今の時期は特に意味があります。調査時点の情報では、Python 3.10 は2026年10月で EOL(サポート終了)を迎えます。yt-dlp も 3.11 以上を推奨する方針に移っています。サーバーの Python が 3.10 のまま放置されていても、requires-python = ">=3.11" と書いておけば、uv が条件に合う Python を用意して動かします。
PEP 723 の # /// script ブロックで requests・BeautifulSoup・pandas を固定する
ここからが本題です。ニュース一覧ページから見出しとURLを集め、CSV に追記していくスクリプトを例にします。取得先は example.com に置き換えてあるので、実際に使うときは対象サイトに合わせてセレクタを書き換えてください。
実行環境
- uv(インストール後に
uv --versionで確認。後述の--script系サブコマンドがあるかはuv help run・uv lock --helpで確かめてください) - Python 3.11 以上(無ければ uv が取得します)
- 依存パッケージ:requests・beautifulsoup4・pandas(
pip installは不要です。uv が隔離環境に入れます)
スクリプト全体(collect_news.py)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests",
# "beautifulsoup4",
# "pandas",
# ]
#
# [tool.uv]
# exclude-newer = "2026-10-01T00:00:00Z"
# ///
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_PATH = "/news/"
MAX_PAGES = 3
INTERVAL = 3 # 秒。1リクエストごとに最低これだけ空ける
UA = "news-collector/1.0 (+https://example.com/about)"
OUT = Path(__file__).resolve().parent / "data" / "news.csv"
def load_robots():
rp = RobotFileParser(urljoin(BASE, "/robots.txt"))
rp.read()
return rp
def fetch(session, url):
res = session.get(url, timeout=10)
res.raise_for_status()
return res.text
def parse(html, page_url):
soup = BeautifulSoup(html, "html.parser")
rows = []
for item in soup.select("article"):
link = item.select_one("h2 a")
if link is None:
continue
stamp = item.select_one("time")
rows.append({
"title": link.get_text(strip=True),
"url": urljoin(page_url, link["href"]),
"published": stamp.get("datetime") if stamp else None,
})
return rows
def main():
rp = load_robots()
delay = max(INTERVAL, rp.crawl_delay(UA) or 0)
rows = []
with requests.Session() as session:
session.headers["User-Agent"] = UA
for page in range(1, MAX_PAGES + 1):
url = urljoin(BASE, f"{LIST_PATH}?page={page}")
if not rp.can_fetch(UA, url):
print(f"robots.txt で不許可のため中止: {url}", file=sys.stderr)
break
found = parse(fetch(session, url), url)
if not found:
break
rows.extend(found)
time.sleep(delay)
if not rows:
print("1件も取れなかった。HTML構造が変わった可能性がある", file=sys.stderr)
sys.exit(1)
new = pd.DataFrame(rows).assign(fetched_on=date.today().isoformat())
if OUT.exists():
new = pd.concat([pd.read_csv(OUT), new])
merged = new.drop_duplicates(subset="url", keep="first")
OUT.parent.mkdir(parents=True, exist_ok=True)
merged.to_csv(OUT, index=False, encoding="utf-8-sig")
print(f"{len(rows)}件取得、累計{len(merged)}件")
if __name__ == "__main__":
main()
先頭の # /// script ブロック:依存と Python バージョンの宣言
# /// script から # /// までが PEP 723 のブロックです。中身は TOML(pyproject.toml と同じ書式)を行頭の # でコメントにしたものです。
requires-python:動かせる Python の範囲。満たす Python が無ければ uv が取得します。dependencies:pip で入れるパッケージ名の一覧。BeautifulSoup は import 名がbs4でも、パッケージ名はbeautifulsoup4です。[tool.uv]のexclude-newer:指定日時より後に公開されたバージョンを無視して依存を解決させる設定です。バージョン番号を1つずつ書かなくても「2026年10月1日時点の最新」で固定できます。来月サーバーで初めて動かしても、手元で試したときと同じ組み合わせになりやすくなります。
1行目の #!/usr/bin/env -S uv run --script はシバン(実行するプログラムの指定)です。chmod +x collect_news.py しておけば ./collect_news.py で直接動きます。ただし env -S は古い環境の env には無いことがあります。また、シバン経由だと PATH 上の uv を探しに行くので、後述の cron では使いません。
robots.txt の読み込みと取得部分:load_robots()・fetch()
load_robots() は標準ライブラリの RobotFileParser で robots.txt を1回だけ読みます。ページごとに読み直すと、それだけで相手へのリクエストが増えるからです。main() では crawl_delay() を見て、robots.txt に Crawl-delay(待ち時間の希望)が書かれていればそちらに従います。書かれていなければ INTERVAL の3秒を使います。
fetch() は timeout=10 を必ず付けています。timeout を付けない requests は応答が無いと永遠に待つので、定期実行では「動いているのに終わらない」状態になります。翌日の実行と重なる原因にもなります。raise_for_status() は 4xx・5xx を例外にして、エラーページを本文として解析しないようにするためのものです。
解析部分:parse()
soup.select("article") で記事の塊を拾い、その中の h2 a(見出しリンク)と time を取り出します。urljoin() で相対パスの href を絶対URLに直しています。見出しリンクが無い塊は continue で飛ばします。広告枠などが article タグを使っていても落ちないようにするためです。
pandas での加工と保存:assign()・drop_duplicates()
取得した行を DataFrame にして、assign() で取得日の列を足します。列の追加を df["col"] = ... ではなく assign() で書くのは、pandas 3.0 の書き方の変化に合わせたものです。経緯はpandas 3.0連鎖代入→.loc/assignへ|26年10月で詳しく書きました。
既存の CSV があれば既存→今回の順に連結します。そのうえで drop_duplicates(subset="url", keep="first") をかけるので、同じ記事は最初に見つけた日の fetched_on が残ります。encoding="utf-8-sig" は、CSV を Excel で開いたときに文字化けさせないための指定です。
保存先の OUT は Path(__file__).resolve().parent を基準にしています。cron や systemd はカレントディレクトリがホームや / になるので、"data/news.csv" のような相対パスで書くと、手元では動くのに定期実行では別の場所へ書き込みます。
最後に、1件も取れなかったときは sys.exit(1) で終了コードを1にしています。サイトの構造が変わってセレクタが外れると、エラーは出ずに「0件で正常終了」になりがちです。終了コードで失敗を外に知らせておけば、journalctl や cron のログで気づけます。
依存の追加とロックファイル:uv add --script・uv lock --script
依存ブロックは手で書いてもよいですが、uv のコマンドで追記させることもできます。
# 依存ブロックへパッケージを追記する
uv add --script collect_news.py requests beautifulsoup4 pandas
# 手元で1回動かす(初回だけ隔離環境の作成が入る)
uv run --script collect_news.py
# 解決結果をロックファイルに固定する(collect_news.py.lock ができる)
uv lock --script collect_news.py
exclude-newer で日付固定するか、ロックファイルで完全に固定するかは好みです。ロックファイルを使う場合は、サーバーへスクリプトと一緒に .lock ファイルも送ってください。片方だけ送ると、手元で検証した組み合わせとは別のものが入ります。お使いの uv にこれらのサブコマンドがあるかは uv lock --help で確認できます。
cron・systemd timer の ExecStart から uv run --script を直接呼ぶ
スクリプト側の準備ができたら、定期実行の設定です。ここで止まる原因の大半は「手元のシェルでは見えていたものが、cron や systemd からは見えない」ことです。
uv は絶対パスで書く
uv のインストーラはホーム配下(多くは ~/.local/bin)に uv を置きます。対話シェルではこのパスが PATH に入っていますが、cron の PATH は最小限(/usr/bin:/bin 程度)なので uv: command not found で黙って失敗します。先に場所を確かめて、絶対パスで書きます。
command -v uv
# 例: /home/username/.local/bin/uv
cron の場合
# crontab -e で追記(毎朝7:00)
0 7 * * * /home/username/.local/bin/uv run --script /home/username/jobs/collect_news.py >> /home/username/jobs/collect.log 2>&1
cd も activate も要りません。2>&1 で標準エラーもログへまとめているので、先ほどの sys.exit(1) のメッセージもここに残ります。なお crontab 内で % は改行扱いになります。ログ名に date +%F などを入れたいときは \% とエスケープしてください。
systemd timer の場合
常時稼働の Linux なら、systemd timer の方が扱いやすいと私は考えています。ログが journalctl に残り、電源断の間に逃した回を起動後に実行させることもできるからです。ユーザー単位のユニットで書く例です。
# ~/.config/systemd/user/collect-news.service
[Unit]
Description=Collect news list
[Service]
Type=oneshot
ExecStart=/home/username/.local/bin/uv run --script /home/username/jobs/collect_news.py
# ~/.config/systemd/user/collect-news.timer
[Unit]
Description=Run collect-news daily
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now collect-news.timer
systemctl --user list-timers # 次回の実行予定を確認
journalctl --user -u collect-news.service # 実行ログを見る
# ログアウト中も user timer を動かすために一度だけ必要
sudo loginctl enable-linger username
ExecStart に書くのは uv の絶対パスとスクリプトの絶対パスだけです。以前のように /home/username/jobs/.venv/bin/python を指す必要はありません。Persistent=true は、サーバーが止まっていた時間帯の回を起動後に1回実行させる設定です。enable-linger を忘れると、ログアウトした時点で user timer が止まります。
初回だけは手で1回動かしておく
uv run --script は初回に次の処理をします。
- 条件に合う Python が無ければ取得する
- 依存を解決してパッケージをダウンロードする
- 隔離環境を作ってキャッシュ(既定はホーム配下の
~/.cache/uv)に置く
これを定期実行の初回に任せると、ネットワークの都合で失敗したり、想定より時間がかかったりします。設定したら同じユーザーで一度手動実行し、2回目が速く終わることを確かめておくと安心です。
- cron と手動実行を別ユーザーで行うと、キャッシュの場所が別になって初回処理がもう一度走ります。
uv cache cleanを実行した直後も同じで、次の回で取得し直します。- サーバー側で事前に Python だけ入れておきたいときは
uv python install 3.12、どの Python が使われるかはuv python findで確認できます。
取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔
毎日自動で動くということは、毎日相手のサーバーにアクセスするということです。書き方の工夫より先に、ここを押さえておく必要があります。
- robots.txt:RFC 9309 として仕様が明文化され、ワイルドカード(
*)や行末指定($)の解釈も定められています。ただし標準ライブラリのRobotFileParserはパターンの扱いが簡易的です。ルールが複雑なサイトでは、robots.txt を目視でも確認してください。最近は GPTBot・ClaudeBot・Google-Extended など AI クローラーごとにルールを分けるサイトが増えています。自分の User-Agent がどのグループに当たるかも見ておきましょう。 - 利用規約:「自動取得を禁止」と書かれていれば使いません。日本では、公開ページへのアクセス・サーバーに過度な負荷をかけないこと・規約の順守・個人情報保護法に沿った扱いを満たす限り、スクレイピング自体は違法ではないとされています。取得データに個人情報が含まれる場合は、取得方法と保管に個人情報保護法が適用されます。
- アクセス間隔:上の例では最低3秒空け、ページ数も
MAX_PAGES = 3で打ち切っています。過去には、図書館サーバーに負荷をかけたクローラーが業務妨害罪に問われた事例があります。間隔を空けることは法的な防衛策でもあります。 - User-Agent:何のための取得かが分かる名前と、問い合わせ先URLを入れています。ブラウザを装うことはしません。
- ブロックされたら止める:403 や CAPTCHA が返ってきたら、それが相手の意思表示です。プロキシや UA の切り替えで回避せず、取得をやめるか公式APIを探してください。
毎日動かし続ける場所:手元PC・共有サーバーの cron・VPS で止まる条件
uv run --script でサーバーへの持ち運びは楽になりましたが、「どこで動かすか」は別に決める必要があります。それぞれ止まる条件が違います。
実行場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(launchd・タスクスケジューラ) | 試運転、自分が毎日PCを開く時間に終わればよい処理 | スリープ・電源オフ・蓋を閉じた日。macOS の launchd はスリープ中の予定を逃し、復帰時に1回だけ実行する |
共有レンタルサーバーの cron | 1日数回・数分で終わる requests+BeautifulSoup の取得 | 実行時間・CPU 時間の制限に当たったとき。ホーム配下へのバイナリ設置(uv 本体)や外部への通信が規約で制限されている場合。事前にサーバー会社の仕様を確認する |
VPS・自宅の常時稼働機(systemd timer) | 毎日確実に動かしたい処理、Selenium・Playwright のようにブラウザを起動する処理 | 電源断・ディスク満杯・OS 更新後の再起動忘れ。timer が disabled に戻っていないか |
判断の目安は次のとおりです。
- 今回の例のようにrequests+BeautifulSoup で数分で終わる処理なら、cron が使える共有サーバーで足ります。uv を置けるかだけ先に確かめてください。
- JavaScript で描画されるページが相手で、ヘッドレスブラウザが必要になると、メモリと実行時間の面で共有サーバーは厳しくなります。VPS か常時稼働の機械を用意する段階です。ブラウザ側の書き方はPlaywright locator移行と自動待機|26年10月にまとめています。
- どこで動かす場合も、止まったことに気づける仕組みまでがセットです。終了コードが0以外のときや、CSV の件数が増えていないときに通知を飛ばしておくと、セレクタ切れを何週間も放置せずに済みます。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
uv run --script と PEP 723 で定期実行を止めないための要点
スクレイピングを毎日動かし続けるうえで、venv の作り直しや activate 忘れは、本来の処理と関係のない止まり方でした。PEP 723 の # /// script ブロックに requires-python と dependencies を書けば、スクリプト1ファイルが動作条件を持ち歩けます。さらに exclude-newer かロックファイルで固定すれば、サーバーへ移しても同じ組み合わせで動きます。
cron・systemd timer の ExecStart には、uv とスクリプトの絶対パスだけを書きます。ほかに押さえる点は3つです。保存先は __file__ 基準にすること、初回は手動で1回動かしてキャッシュを作ること、0件取得を終了コードで知らせること。加えて、robots.txt と Crawl-delay を読み、間隔を空けることも欠かせません。処理の重さに応じて共有サーバーか VPS かを選び、止まったら気づける形で運用してください。