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) |
| Python 3.12以降の素の環境では import の時点で止まる |
(2) |
| ファイルを共有・バックアップした瞬間にキーが漏れる |
(3) |
| 応答が無いと永久に待つ。間隔なしの連続アクセスは相手に負荷をかける |
(4) |
| 失敗しても何も記録されず、終了コード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以降) | |
|---|---|---|
環境変数の真偽値 |
| 上記の |
設定ファイル(ini形式)の真偽値 | 値を文字列で取って |
|
戻り値 | 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 = 5import 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.targetsystemctl --user daemon-reload
systemctl --user enable --now collector.timer
journalctl --user -u collector.service -n 50 # 直近の実行ログPersistent=true を付けておくと、サーバーが止まっていた間の回を、起動後にまとめて実行します。スクリプトが終了コード1を返せば、systemd 側でも失敗として記録されます。前の節で except Exception: pass を外したのは、このためです。
サーバー移行・OS更新の前後に必ずやること
python3 -Vで、移行先のPythonバージョンを確認する- venv を作り直し、
pip install requests beautifulsoup4を入れ直す(venv は丸ごとコピーしない) .venv/bin/python -c "import collector"で import が通るか確かめる(__main__ガードがあるので処理は走らない)DRY_RUN=true .venv/bin/python collector.pyで1回通しで動かし、件数がログに出るか見る- 翌朝、ログか通知で、実際に定時に動いたことを確認する
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が変わっても朝のうちに気づけます。