ChatGPTやClaude Codeに書かせた収集スクリプトが、手元では動いたのにサーバーのcronで朝になると止まっている。ログには ModuleNotFoundError: No module named 'distutils'。原因は、Python 3.12で標準ライブラリから削除された distutils の strtobool を、生成AIがいまも設定の読み込みに使ってくることです。この記事では、AIへの指示文の実物を使って、標準ライブラリだけの書き方に直させます。そのうえで、サーバーを移しても毎日止まらずに動かす方法まで説明します。

AIが今も書く from distutils.util import strtobool がPython 3.12で止まる理由

distutils は3.12で標準ライブラリから消えた

distutils は、もともとパッケージを作るための標準モジュールでした。非推奨の期間を経て、Python 3.12で標準ライブラリから削除されています。その中にあった strtobool() は、"yes" や "false" のような文字列を真偽値に変換する小さな関数です。「環境変数 DRY_RUN=true を読む」といった設定処理にとても便利なので、古いネット記事に大量に残っています。生成AIはそれを学習しているため、いまでも平気で次の1行を出してきます。

from distutils.util import strtobool

「手元では動くのに、サーバーでは止まる」が起きる仕組み

やっかいなのは、同じPython 3.12でも動く環境と動かない環境があることです。

  • setuptools が入っている環境:setuptools は互換用の distutils を同梱しています。環境によっては import distutils が通ってしまうため、手元では問題が見えません。
  • Python 3.12で新しく作った venv:3.12からは、python3 -m venv で作った仮想環境に setuptools が最初から入らなくなりました。サーバーで venv を作り直した瞬間に import が失敗します。
  • OSのアップグレード:サーバーのOSを上げると、システムのPythonが3.12以降に変わります。それまで毎日動いていたスクリプトが、ある朝から止まります。

自分の環境で distutils が見えているかは、次のコマンドで確認できます。

python3 -V
python3 -c "import distutils; print(distutils.__file__)"

2行目が通り、表示されたパスに setuptools が含まれていれば、「たまたま動いている」状態です。ModuleNotFoundError が出れば、そのスクリプトはこの環境では動きません。

AIのコードが古くなるのは distutils だけではない

調査では「半年前の記事のコードがそのまま動かない」という声が現場でよく聞かれるとされています。原因は、AIの学習データが最新の仕様変更に追いついていないことです。AI関連のAPI自体も例外ではありません。2026年10月21日に公開されたClaude APIのバージョン「2026-10-21」では、Messages APIのリクエストに temperature・top_p・top_k を含めると400エラーになる破壊的変更が入りました。同じく標準ライブラリから消えたモジュールの例は、imghdr削除をPillowで直すAI指示文|26年10月 でも扱っています。AIに書かせたコードは「書かれた時点の常識」で固まっている、という前提で読む必要があります。

ChatGPT・Claude Codeへの指示文の実物:最初の指示から直った後のコードまで

手順1:よくある最初の指示

多くの人が最初に送るのは、次のような指示だと思います。

example.com/news/ の新着記事のタイトルとURLを取得して、CSVに追記するPythonスクリプトを書いてください。
毎日cronで動かします。DRY_RUN という環境変数が true のときはCSVに書き込まないようにしてください。

手順2:出てきたコードと、その問題点

この指示に対しては、たとえば次のようなコードが返ってきます。動くことは動きますが、毎日動かすには危うい箇所がいくつもあります。

import os
import csv
import requests
from bs4 import BeautifulSoup
from distutils.util import strtobool   # (1)

API_KEY = "sk-xxxxxxxxxxxx"             # (2)
DRY_RUN = strtobool(os.getenv("DRY_RUN", "false"))

for page in range(1, 50):
    try:
        res = requests.get(f"https://example.com/news/?page={page}")  # (3)
        soup = BeautifulSoup(res.text, "html.parser")
        for a in soup.select("ul.news-list a"):
            if not DRY_RUN:
                with open("news.csv", "a") as f:
                    csv.writer(f).writerow([a.text, a["href"]])
    except Exception:
        pass                                # (4)

見るべき箇所は次の5つです。AIのコードを受け取ったら、まずこの5点を自分で探してください。

番号

見つけ方

何がまずいか

(1)

distutils という語を検索する

Python 3.12以降の素の環境では import の時点で止まる

(2)

"sk-、KEY =、TOKEN = の後ろに文字列が直書きされていないか

ファイルを共有・バックアップした瞬間にキーが漏れる

(3)

requests.get( に timeout= があるか、ループ内に time.sleep があるか

応答が無いと永久に待つ。間隔なしの連続アクセスは相手に負荷をかける

(4)

except Exception: の直後が pass だけになっていないか

失敗しても何も記録されず、終了コード0で「成功」扱いになる

—

0件のときの扱いがあるか

サイトの構造が変わって1件も取れなくても気づけない

(4)は特に深刻です。cronやsystemdは終了コードで成否を判断します。そのため、中で全部握りつぶすと、何週間も空のCSVを作り続けていても誰も気づきません。

手順3:直させる指示

「エラーが出たので直して」とだけ送ると、AIは pip install setuptools を勧めることがあります。これでも動く場合はありますが、setuptools の同梱品にたまたま頼る状態が続くだけで、次の環境移行でまた止まります。何を使ってはいけないか、どう振る舞ってほしいかを具体的に書くのがコツです。

次の条件でスクリプトを書き直してください。

1. distutils は Python 3.12 で削除されたので使わないでください。setuptools にも頼らないでください。
   環境変数の真偽値は、標準ライブラリだけで書いた小さな関数で読んでください。
   true/false/yes/no/on/off/1/0 を大文字小文字を問わず受け付け、それ以外の値なら ValueError で止めてください。
2. APIキーやWebhookのURLはコードに書かず、環境変数から読んでください。
3. requests には timeout を付け、ページごとに最低3秒は間隔を空けてください。
4. except Exception: pass は使わないでください。失敗したら logging で内容を残し、終了コード1で終わってください。
5. 1件も取れなかったときも異常として終了コード1にしてください。
6. import しただけでは処理が走らないよう、if __name__ == "__main__": で囲んでください。
7. Python 3.12 以降で、追加パッケージは requests と beautifulsoup4 だけにしてください。

6番目の指示は地味ですが効きます。このガードがあれば、python3 -c "import collector" で「import が通るか」だけを安全に確かめられます。

手順4:直った後のコード

実行環境:Python 3.12以降。追加パッケージは次の2つです(pip install requests beautifulsoup4)。バージョンは pip show requests beautifulsoup4 で確認できます。

#!/usr/bin/env python3
"""example.com の新着一覧を取得して CSV に追記する。"""
import csv
import logging
import os
import sys
import time
from pathlib import Path

import requests
from bs4 import BeautifulSoup

TRUE_WORDS = {"1", "true", "yes", "on", "y", "t"}
FALSE_WORDS = {"0", "false", "no", "off", "n", "f"}


def env_bool(name: str, default: bool = False) -> bool:
    """環境変数を真偽値として読む(distutils.util.strtobool の代わり)。"""
    raw = os.environ.get(name)
    if raw is None or raw.strip() == "":
        return default
    value = raw.strip().lower()
    if value in TRUE_WORDS:
        return True
    if value in FALSE_WORDS:
        return False
    raise ValueError(f"{name} の値を真偽値として読めません: {raw!r}")


BASE_URL = "https://example.com/news/"
DRY_RUN = env_bool("DRY_RUN")
MAX_PAGES = int(os.environ.get("MAX_PAGES", "5"))
INTERVAL = max(float(os.environ.get("FETCH_INTERVAL", "5")), 3.0)
OUT_FILE = Path(os.environ.get("OUT_FILE", "/home/username/collector/news.csv"))
NOTIFY_URL = os.environ.get("NOTIFY_WEBHOOK_URL")  # 未設定なら通知しない


def fetch(session: requests.Session, url: str) -> str:
    resp = session.get(url, timeout=30)
    resp.raise_for_status()
    return resp.text


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


def notify(text: str) -> None:
    if not NOTIFY_URL:
        return
    try:
        requests.post(NOTIFY_URL, json={"text": text}, timeout=10)
    except requests.RequestException:
        logging.exception("通知の送信に失敗しました")


def main() -> int:
    session = requests.Session()
    session.headers["User-Agent"] = "news-collector/1.0 (+https://example.com/contact)"

    rows = []
    for page in range(1, MAX_PAGES + 1):
        url = f"{BASE_URL}?page={page}"
        found = parse(fetch(session, url))
        logging.info("%s: %d件", url, len(found))
        if not found:
            break
        rows.extend(found)
        time.sleep(INTERVAL)

    if not rows:
        logging.error("1件も取得できませんでした。ページの構造が変わった可能性があります")
        return 1

    if DRY_RUN:
        logging.info("DRY_RUN のため書き込みません(%d件)", len(rows))
        return 0

    OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
    with OUT_FILE.open("a", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "url"])
        writer.writerows(rows)
    logging.info("%d件を %s に追記しました", len(rows), OUT_FILE)
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(message)s")
    try:
        sys.exit(main())
    except Exception as e:
        logging.exception("収集に失敗しました")
        notify(f"news-collector が失敗しました: {type(e).__name__}")
        sys.exit(1)

ブロックごとの説明

  • env_bool():strtobool() の置き換えです。受け付ける語を集合で持ち、小文字にしてから照合します。読めない値は ValueError で止めます。これは元の strtobool() と同じ挙動です。DRY_RUN=ture のような打ち間違いを黙って False として扱うと、本番に書き込んでしまうので、止める方を選んでいます。違いは2点です。未設定と空文字は既定値になります。戻り値は strtobool() の 1/0 ではなく True/False です。
  • 設定の読み込み:取得間隔は環境変数で変えられます。ただし max(..., 3.0) で、3秒未満には下げられないようにしています。WebhookのURLのような秘密の値は、コードではなく環境変数から読みます。
  • fetch():timeout=30 で、応答の無いサーバーを永久に待たないようにしています。raise_for_status() は、404や503を「成功」として読み進めないための1行です。
  • parse():取り出す処理だけを分けています。サイトの構造が変わったときに直すのはここだけです。
  • main():0件なら終了コード1を返します。これで「動いているのに何も取れていない」状態を、cronやsystemdの失敗として表に出せます。
  • 末尾の try/except:例外の内容を logging.exception でログに残し、通知して、終了コード1で抜けます。握りつぶさず、記録してから落とす形です。通知文には例外の型名だけを載せ、URLや値は載せません。

strtoboolを使わない設定の書き方:環境変数と configparser の使い分け

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

以前(3.11まで)

今(3.12以降)

環境変数の真偽値

strtobool(os.getenv("DRY_RUN", "false"))

上記の env_bool("DRY_RUN") のような自前の関数

設定ファイル(ini形式)の真偽値

値を文字列で取って strtobool() に渡す

configparser の getboolean()

戻り値

1 / 0(int)

True / False(bool)

設定ファイルを使うなら configparser.getboolean()

設定を settings.ini にまとめたい場合は、標準ライブラリの configparser だけで済みます。getboolean() は 1/yes/true/on と 0/no/false/off を受け付け、それ以外では ValueError を出します。

[collector]
dry_run = no
max_pages = 5
import configparser

config = configparser.ConfigParser()
config.read("/home/username/collector/settings.ini", encoding="utf-8")

DRY_RUN = config.getboolean("collector", "dry_run", fallback=False)
MAX_PAGES = config.getint("collector", "max_pages", fallback=5)

注意点が1つあります。strtobool() が受け付けていた1文字の t・f・y・n は、getboolean() では受け付けられません。古い設定ファイルに dry_run = y と書いてあると、移行後に止まります。値を yes に書き換えておきましょう。なお、APIキーのような秘密の値はこのファイルにも書かず、環境変数で渡す方が安全です。

既存のスクリプトに distutils が残っていないか一括で探す

grep -rn "distutils" /home/username/collector/ --include="*.py"

AIに書かせたファイルが何本もあるなら、まずこれで洗い出します。見つかったファイルは、前の節の指示文と一緒に1本ずつAIへ渡して直させてください。

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

AIは「取れること」を優先してコードを書くので、相手への配慮は指示しない限り入りません。毎日動かすものほど、次の3点を先に確認してください。

  • robots.txt:https://example.com/robots.txt を開いて、取りたいパスが Disallow になっていないか確かめます。標準ライブラリの urllib.robotparser を使えば、スクリプトの中でも判定できます。
  • 利用規約:自動取得の禁止や、取得したデータの再配布の禁止が書かれていないか読みます。公式APIがあるなら、そちらを使うのが筋です。
  • アクセス間隔:上のコードでは、ページごとに最低3秒空けています。1日1回、数ページで済む設計にすれば、相手の負担はほとんどありません。並列化して速くする必要は、日次の収集にはまずありません。

403やCAPTCHAが出始めたら、それは相手からの「来ないでほしい」という合図です。ヘッダーを偽装したりIPアドレスを変えたりして回避するのではなく、取得をやめるか、公式の手段を探してください。

毎日止めずに動かす場所:手元のPC・共有サーバーのcron・VPSが止まる条件

どこで動かすかの比較

実行場所

向いている条件

止まる条件

手元のPC

試運転。1日1回、PCを開いているときに動けば十分なもの

スリープ中・電源オフの間は実行されない。OSの更新でPythonが入れ替わることもある

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

1回数分で終わる requests + BeautifulSoup 程度の処理

Pythonのバージョンは事業者が決めるため、事業者側の更新で distutils のような変化をそのまま受ける。長時間の処理やブラウザ自動操作は制限されることが多い

VPS

常駐、ブラウザ自動操作、複数スクリプトの並行運用

自分でOSを上げると、システムのPythonが変わり、venv が古いインタープリタを指したまま動かなくなる。管理を怠れば止まる

この記事のスクリプトは数分で終わるので、cronが使える共有サーバーで十分です。ブラウザ自動操作を伴ったり、1日に何度も回したりするならVPSを検討します。どちらを選んでも、Pythonのバージョンが変わる日が必ず来る前提で備えておくことが大切です。

cronで動かすとき

cronはログイン時のシェル設定を読まないので、環境変数はcrontab側で渡します。秘密の値はcrontabに書かず、権限を絞ったファイルに置いて読み込ませます。

# crontab -e
0 7 * * * cd /home/username/collector && set -a && . ./.env && set +a && /home/username/collector/.venv/bin/python collector.py >> /home/username/collector/run.log 2>&1
# /home/username/collector/.env(chmod 600 にしておく)
DRY_RUN=false
FETCH_INTERVAL=5
NOTIFY_WEBHOOK_URL=https://example.com/your-webhook

ポイントは、python3 ではなく venv の中のpythonをフルパスで指定することです。システムのPythonが入れ替わっても、どのインタープリタで動いているかが明確になります。Pythonのバージョンごと固定したいなら、uv run --scriptでcron定期実行|26年10月 の方法も使えます。

VPSで systemd timer を使うとき

# ~/.config/systemd/user/collector.service
[Unit]
Description=news collector

[Service]
Type=oneshot
WorkingDirectory=/home/username/collector
EnvironmentFile=/home/username/collector/.env
ExecStart=/home/username/collector/.venv/bin/python collector.py
# ~/.config/systemd/user/collector.timer
[Unit]
Description=run news collector daily

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

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now collector.timer
journalctl --user -u collector.service -n 50   # 直近の実行ログ

Persistent=true を付けておくと、サーバーが止まっていた間の回を、起動後にまとめて実行します。スクリプトが終了コード1を返せば、systemd 側でも失敗として記録されます。前の節で except Exception: pass を外したのは、このためです。

サーバー移行・OS更新の前後に必ずやること

  1. python3 -V で、移行先のPythonバージョンを確認する
  2. venv を作り直し、pip install requests beautifulsoup4 を入れ直す(venv は丸ごとコピーしない)
  3. .venv/bin/python -c "import collector" で import が通るか確かめる(__main__ ガードがあるので処理は走らない)
  4. DRY_RUN=true .venv/bin/python collector.py で1回通しで動かし、件数がログに出るか見る
  5. 翌朝、ログか通知で、実際に定時に動いたことを確認する

3番目で ModuleNotFoundError が出たら、移行前に見つかってよかった、ということです。エラー全文と、前の節の指示文をAIに渡して直させてください。

関連する選択肢

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

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

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

まとめ:distutils削除をきっかけに、AI製スクリプトを「止まったら気づける形」にする

ChatGPTやClaude Codeは、Python 3.12で削除された from distutils.util import strtobool をいまも書いてきます。setuptools が入った環境ではたまたま動くので、サーバー移行やOS更新のタイミングで突然止まります。直すときは、エラーだけを渡すのではなく、条件を具体的に指示してください。「distutils も setuptools も使わず標準ライブラリで真偽値を読む」「秘密の値は環境変数」「timeout と最低3秒の間隔」「except: pass 禁止、失敗と0件は終了コード1」と書けば、AIは毎日動かせる形に書き直します。設定ファイルを使うなら configparser.getboolean() が使えます。動かす場所は、数分の処理なら共有サーバーのcron、常駐や重い処理ならVPSです。どこで動かしても、venv のpythonをフルパスで指定してください。移行のたびに、import の確認と DRY_RUN での試運転を手順として残しておけば、次にPythonが変わっても朝のうちに気づけます。