Ubuntu 24.04やDebian 12でpip install requestsを打つと、externally-managed-environmentで止められます。以前はsudo pip installやpip install --userで通していた手順が、そのままでは使えません。この記事では、python3 -m venvで作った仮想環境を使ってスクレイピングを書き、cron・systemd timerからその仮想環境の.venv/bin/pythonを直接指定して、毎日止めずに動かすまでの書き方を、動くコードと一緒に説明します。

externally-managed-environmentが出る理由と、今の正しい入れ方(venv)

何が変わったのか

Ubuntu 24.04やDebian 12では、OSに入っているPython(/usr/bin/python3)をOSのパッケージ管理(apt)の持ち物として扱います。aptが入れたライブラリとpipが入れたライブラリが同じ場所で混ざると、OSの更新やOS付属のツールが壊れることがあるためです。そのため、システムのPythonへ直接pip installしようとすると、pipが拒否してexternally-managed-environmentを表示します。

エラーを消すこと自体が目的ではありません。「システムのPythonとは別に、自分のスクリプト専用の環境を作る」が今の前提になった、と捉えると書き方が決まります。

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

やりたいこと

以前の書き方

今の書き方

ライブラリを入れる

sudo pip install requests

python3 -m venv .venvで専用環境を作り、.venv/bin/pip install requests

自分のユーザーだけに入れる

pip install --user requests

同上(--userもシステムのPythonに紐づくので同じく拒否される)

cronから実行する

python3 script.py

/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py(絶対パス)

依存を再現する

手で入れ直す

requirements.txtから.venvごと作り直す

なお、pipには--break-system-packagesというオプションがあり、付ければ通ります。ただし名前のとおりOS側のパッケージを壊しうる指定で、毎日動かすスクリプトの土台にするものではありません。この記事では使いません。

手順:作業ディレクトリと仮想環境を作る

python3 -V
sudo apt install python3-venv
mkdir -p /home/username/scraper
cd /home/username/scraper
python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4 pandas
.venv/bin/pip freeze > requirements.txt
  • 1行目:使うPythonのバージョンを確認します。Python 3.10は2026年10月でセキュリティ修正のサポートが終わるので、3.10が表示されたら3.11以降を使える環境かどうかも合わせて確認してください。
  • 2行目:環境によってはvenvの作成に必要な部品が別パッケージになっています。python3 -m venvが「ensurepipが無い」といった理由で失敗したときに入れます。ここだけはaptなのでsudoが要ります。
  • 4〜5行目:プロジェクト用のディレクトリを作り、その中に.venvという仮想環境(このディレクトリ専用のPythonとライブラリ置き場)を作ります。
  • 6行目:activateせず、.venv/bin/pipを直接呼んでいます。こうすると「今どの環境に入れているか」が行を見ただけで分かります。パッケージ名はbeautifulsoup4と正確に書きます(似た名前で古い系統が入ることがあります)。
  • 7行目:入ったバージョンをrequirements.txtに固定します。後述しますが、OSの更新で.venvを作り直す日が来るので、このファイルが復旧の手順書になります。

入ったバージョンは.venv/bin/pip show pandasのように確認できます。2026年10月時点ではpandas 3.1.0(10月22日リリース)、BeautifulSoup4 4.15.0が最新です。pandas 3系ではDataFrame.applymap()が削除されてDataFrame.map()に置き換わり、df["foo"][0] = 100のような連鎖代入は元のDataFrameに反映されないことがあるので、.loc[]で書きます。

cron・systemd timerから動くスクリプトの書き方(絶対パスとログ)

手元のターミナルで動いたスクリプトが、cronに入れた途端に動かなくなる原因の多くはカレントディレクトリと環境変数(特にPATH)が違うことです。cronはログインシェルの設定を読まないため、python3がどれを指すか、相対パスがどこを基準にするかが手元と変わります。スクリプト側でそれに左右されない書き方にしておきます。

import logging
import sys
import time
import urllib.robotparser
from pathlib import Path

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
OUT_DIR = BASE_DIR / "data"
LOG_DIR = BASE_DIR / "logs"

BASE_URL = "https://example.com"
TARGET_URL = f"{BASE_URL}/news/"
USER_AGENT = "news-collector/1.0 (+https://example.com/contact)"


def setup_logging():
    LOG_DIR.mkdir(exist_ok=True)
    logging.basicConfig(
        filename=LOG_DIR / "scrape.log",
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
    )


def allowed(url):
    rp = urllib.robotparser.RobotFileParser()
    rp.set_url(f"{BASE_URL}/robots.txt")
    rp.read()
    return rp.can_fetch(USER_AGENT, url)


def fetch(session, url):
    resp = session.get(url, timeout=20)
    resp.raise_for_status()
    time.sleep(3)
    return resp.text


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    rows = []
    for a in soup.select("ul.news-list li a"):
        rows.append({"title": a.get_text(strip=True), "url": a.get("href")})
    return rows


def main():
    setup_logging()
    logging.info("start python=%s", sys.executable)

    if not allowed(TARGET_URL):
        logging.warning("robots.txt で拒否されているので取得しない: %s", TARGET_URL)
        return 0

    with requests.Session() as session:
        session.headers["User-Agent"] = USER_AGENT
        html = fetch(session, TARGET_URL)

    rows = parse(html)
    if not rows:
        logging.error("0件。HTML構造が変わった可能性がある")
        return 1

    OUT_DIR.mkdir(exist_ok=True)
    df = pd.DataFrame(rows).drop_duplicates(subset="url")
    out = OUT_DIR / f"news_{time.strftime('%Y%m%d')}.csv"
    df.to_csv(out, index=False, encoding="utf-8-sig")
    logging.info("saved %d rows -> %s", len(df), out)
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception:
        logging.exception("failed")
        sys.exit(1)

ブロックごとの説明

  • BASE_DIR = Path(__file__).resolve().parent:スクリプト自身が置かれている場所を基準にします。cronがどのディレクトリから起動しても、data/とlogs/は常にスクリプトの隣に作られます。open("data.csv")のような相対パスは、cronではホームディレクトリ基準になって「ファイルが無い」「別の場所に書かれている」原因になります。
  • setup_logging():print()ではなくファイルへログを出します。定期実行では画面を誰も見ていないので、失敗の痕跡を残す場所が要ります。
  • logging.info("start python=%s", sys.executable):実際にどのPythonで動いたかを毎回記録します。ここに/home/username/scraper/.venv/bin/pythonと出ていれば、仮想環境が正しく使われています。/usr/bin/python3と出ていたら、cronやsystemdの指定が間違っています。
  • allowed():標準ライブラリのurllib.robotparserで、取得先のrobots.txtが今回のURLを許しているかを毎回確かめます。拒否されていれば取得せずに終わります。
  • fetch():timeout=20で応答が無いときに永遠に待たないようにし、raise_for_status()で4xx・5xxを例外にします。取得後に3秒空けているのは、ページを増やしたときに連続アクセスにならないようにするためです。
  • parse():ul.news-list li aは例です。取得先の実際のHTMLに合わせて書き換えてください。.stringではなく.get_text()を使うと、子タグが混ざっていても文字列をまとめて取れます。
  • 0件ならreturn 1:サイトの構造が変わるとセレクタが空振りし、エラーにならないまま空のCSVが毎日できます。0件を失敗として終了コード1で返すと、systemdやログで「失敗」として見えるようになります。
  • 最後のtry/except:想定外の例外もログに残してから終了コード1で抜けます。

まず手元で、cronと同じ書き方で実行して動くことを確かめます。

/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
tail -n 5 /home/username/scraper/logs/scrape.log

取得先が429(アクセス過多)を返すことがある場合の再試行の書き方は、method_whitelist削除と429再試行|26年10月にまとめています。

.venv/bin/pythonをcronとsystemd timerに直接指定する

activateは要らない

仮想環境の.venv/bin/pythonは、自分の隣にあるpyvenv.cfgを見て、.venvの中のライブラリを読みに行きます。このPythonを絶対パスで呼べば、それだけで仮想環境の中で動きます。source .venv/bin/activateは、対話シェルでpythonと打ったときにこのPythonを優先させるための仕組みで、定期実行では不要です。しかもcronは既定で/bin/shを使い、Ubuntu・Debianではsourceが使えないことがあるので、crontabにactivateを書くと別の理由で失敗します。

cronの場合

crontab -eで次の1行を足します(毎朝7:00に実行)。

0 7 * * * /home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py >> /home/username/scraper/logs/cron.log 2>&1
  • Pythonもスクリプトも絶対パスにします。python3とだけ書くとcronのPATHで見つかったシステムのPythonが使われ、ModuleNotFoundErrorになります。
  • >> ... 2>&1で、スクリプトのログ設定より前に起きた失敗(パスの打ち間違いなど)もcron.logに残します。
  • crontabの中で%は改行の意味になるので、date +%Y%m%dのような記号をコマンドに書く場合は\%にします。上のスクリプトはファイル名の日付をPython側で作っているので、crontabには%が出てきません。

cronの実行時刻が日本時間にならない件と、スクリプト内の日時の扱いはutcnow非推奨とcronの日本時間指定|26年10月で扱っています。

systemd timerの場合

sudoを使わず、自分のユーザーの範囲で動かせる「ユーザーユニット」で書きます。ファイルは2つです。

/home/username/.config/systemd/user/scraper.service

[Unit]
Description=news scraper

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
TimeoutStartSec=30min

/home/username/.config/systemd/user/scraper.timer

[Unit]
Description=run news scraper every morning

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target
  • Type=oneshot:起動して処理が終われば終了するスクリプト向けの指定です。
  • WorkingDirectory:カレントディレクトリを固定します。スクリプトはBASE_DIR基準なので必須ではありませんが、他のツールを呼ぶときの保険になります。
  • Environment=PYTHONUNBUFFERED=1:print()の出力をすぐにjournal(systemdのログ)へ流します。必要な環境変数はここにEnvironment=で並べます。
  • ExecStart:cronと同じく.venv/bin/pythonを絶対パスで書きます。
  • TimeoutStartSec:取得先が応答しないまま固まったとき、この時間で打ち切ります。
  • Persistent=true:電源が落ちていて7:00を逃した場合、次に起動したときに1回分を実行します。cronにはこの仕組みがありません。
  • RandomizedDelaySec=5min:毎日ちょうど同じ秒にアクセスしないよう、最大5分ずらします。

有効化と確認のコマンドです。

systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
systemctl --user list-timers
systemctl --user start scraper.service
journalctl --user -u scraper.service -n 50
sudo loginctl enable-linger username
  • list-timersで次回の実行予定にscraper.timerが出ていれば登録できています。
  • start scraper.serviceで今すぐ1回動かし、journalctlで結果を見ます。終了コードが1なら失敗として記録されるので、前節の「0件なら1を返す」がここで効きます。
  • 最後のenable-lingerを忘れると、ログアウトした時点でユーザーユニットが止まります。常時動かすマシンでは必ず入れます(設定変更なのでsudoが要ります。戻すときはdisable-linger)。

SeleniumやPlaywrightを使う場合の追加点

  • どちらも.venv/bin/pip install selenium・.venv/bin/pip install playwrightと仮想環境に入れます。Selenium 4.37.0以降、Playwright 1.61以降はPython 3.10以上が必要です。Python 3.9の環境でPlaywrightを入れると古い1.60.0が入るので、pip showで確認してください(2026年10月時点の最新はSelenium 4.51.0、Playwright 1.64.1)。
  • SeleniumはSelenium Managerがドライバーを自動で取得・キャッシュするので、ドライバーのパスを書く必要はありません。
  • Playwrightのブラウザ本体は.venv/bin/python -m playwright install chromiumで入れます。ブラウザは実行するユーザーのホーム配下に置かれるので、入れたユーザーと定期実行するユーザーを揃えます(rootのcronから動かすと見つかりません)。

venvが壊れる3つの条件と、取得先サイトへの配慮

止まる条件と直し方

起きること

原因

直し方

突然.venv/bin/pythonが起動しない

OSのアップグレードでシステムのPythonのバージョンが変わり、.venvが参照していた本体が無くなった

rm -rf .venv→python3 -m venv .venv→.venv/bin/pip install -r requirements.txt

ディレクトリを移したら.venv/bin/pipが動かない

.venvの中には作成時の絶対パスが書き込まれている

移動先で作り直す(.venvはコピーして持ち運ぶものではない)

ライブラリ更新後にスクリプトが壊れた

pandas 3系のような破壊的変更を含む更新

requirements.txtでバージョンを固定し、更新は手元で試してから反映する

.venvは「いつでも捨てて作り直せるもの」として扱い、守るべきはscrape.pyとrequirements.txtだけ、という形にしておくと復旧が数分で済みます。

取得先サイトへの配慮

  • robots.txt:法的な拘束力はありませんが、スクレイピングの善意・悪意を判断する材料として考慮される場合があります。上のコードのように実行のたびに確認し、拒否されていれば取得しません。
  • 利用規約:スクレイピングを明示的に禁止しているサイトや、ログインが必要なページを機械的に取得すると、契約違反になる可能性があります。定期実行に入れる前に規約を読みます。認証を突破して取得する行為は不正アクセス禁止法に触れます。
  • アクセス間隔:1日1回・数ページであっても、ページ間は数秒空けます。毎日動かすものは「1回の負荷」より「毎日積み重なる負荷」で考えます。
  • 連絡先付きのUser-Agent:何のための取得か、問題があればどこへ連絡すればよいかが相手に分かるようにします。
  • ボット対策に止められたら、やめる:Cloudflareなどのチャレンジページが返ってきた場合、相手は機械的な取得を望んでいません。回避を試みるのではなく、公式APIやデータ提供の有無を確認するか、取得をやめます。

手元のPC・共有サーバーのcron・VPS:毎日動かし続けるならどこか

スクリプトとvenvの書き方が整っても、動かす場所が止まれば止まります。それぞれが止まる条件を並べます。

実行場所

使う仕組み

止まる条件

向いている用途

手元のPC(Mac)

launchd

スリープ中・フタを閉じている間は実行されない。復帰時にまとめて動くので時刻が読めない

試作、毎日でなくてよい処理

手元のPC(Linux)

systemd timer

電源断・スリープ。Persistent=trueで逃した回は起動時に拾える

常時電源を入れておけるなら日次処理まで

共有レンタルサーバー

cron(管理画面から登録が多い)

提供されているPythonのバージョンが古い、実行時間の上限、ブラウザを動かせない、といった制限に当たる

requests+BeautifulSoupで数ページを取る処理

VPS

cron・systemd timer

自分でOS更新や障害対応をしないと止まる。OSのアップグレードでvenvの作り直しが要る

Selenium・Playwrightを使う処理、常駐が要る処理

  • requests+BeautifulSoupで1日1回なら、共有サーバーのcronで足りることが多いです。ただしpython3 -m venvが使えるか、Pythonが3.10以上かを先に確認します(Python 3.10は2026年10月でサポート終了なので、3.11以降が望ましいです)。
  • SeleniumやPlaywrightでブラウザを動かすなら、ブラウザを入れられる環境、つまりVPSか常時稼働のLinuxマシンが必要です。
  • どこで動かす場合も、.venv/bin/pythonの絶対パス指定・ログのファイル出力・0件を失敗として返す、の3つは同じです。この3つが揃っていれば、場所を移すときもrequirements.txtから作り直すだけで済みます。

関連する選択肢

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

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

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

まとめ:externally-managed-environmentはvenvと絶対パスで片づく

Ubuntu 24.04・Debian 12でpip installがexternally-managed-environmentで止まるのは、システムのPythonをOSの持ち物として守るためです。今の書き方は、python3 -m venv .venvでスクリプト専用の環境を作り、.venv/bin/pipで入れてrequirements.txtに固定することです。定期実行ではactivateせず、cronにもsystemd timerにも/home/username/scraper/.venv/bin/pythonを絶対パスで書けば、その環境のライブラリで動きます。スクリプト側はPath(__file__)基準のパスとファイルへのログ、0件を失敗として返す作りにし、sys.executableをログに残して正しいPythonで動いたかを確かめます。OSの更新で.venvが壊れたら作り直せばよく、守るのはスクリプトとrequirements.txtだけです。robots.txt・利用規約・アクセス間隔に配慮したうえで、処理の重さに合わせて共有サーバーのcronかVPSかを選んでください。