ChatGPTやClaude Codeに書かせた帳票・CSVの自動ダウンロードが、Pythonを新しくした途端に ModuleNotFoundError: No module named 'cgi' で止まった。そんな相談が増えています。原因は、Python 3.13で cgi モジュールが削除されたことです。生成AIはいまも cgi.parse_header() でContent-Dispositionからファイル名を取るコードを出します。この記事では、AIへの最初の指示から、問題点の見つけ方、email.message.Message へ直させる指示文、直った後のコード、毎日止めずに動かす置き場所までを順に示します。

Python 3.13で消えたcgi.parse_header()を、AIがいまも書く理由

cgi は、Webサーバーから呼ばれる古い仕組み(CGI)用の標準モジュールです。Python 3.11で非推奨になり、Python 3.13で標準ライブラリから削除されました。2026年10月9日にはPython 3.15も正式公開されています。新しくPCを買った人やサーバーのPythonが更新された人ほど、この問題に当たります。

生成AIは、学習した大量の過去コードをもとに書きます。「HTTPのヘッダーからファイル名を取る」という処理は、長いあいだ cgi.parse_header() で書かれてきました。そのため、いまでもこの書き方が出てきます。2026年9月から10月にかけてClaude Opus 5.5(9月22日)やGPT-6.1 Sol(9月29日)などの新しいモデルが出ていますが、新しいモデルだから古い書き方をしないとは限りません。出てきたコードを自分の目で確かめる前提で進めます。

最初にAIへ渡しがちな指示文

コードを書き慣れていない人がよく出す指示は、次のようなものです。

毎朝、https://example.com/reports/daily.csv から日次のCSVをダウンロードして、
サーバーが付けてくるファイル名のまま保存するPythonスクリプトを書いてください。
APIトークンが必要です。

これに対して、次のようなコードが返ってくることがあります(要点だけ抜き出しています)。

import cgi
import requests

API_TOKEN = "ここにトークンを貼る"
URL = "https://example.com/reports/daily.csv"

def download():
    try:
        r = requests.get(URL, headers={"Authorization": f"Bearer {API_TOKEN}"})
        value, params = cgi.parse_header(r.headers["Content-Disposition"])
        filename = params["filename"]
        with open(filename, "wb") as f:
            f.write(r.content)
    except Exception:
        pass

download()

Python 3.12以前ならこれで動きます。しかしPython 3.13以降では、1行目の import cgi の時点で止まります。

ModuleNotFoundError: No module named 'cgi'

AIが出したコードで、自分で確認すべき4か所

止まった原因は cgi だけですが、このコードには毎日動かすうえで危ない箇所がほかにもあります。コードを読み慣れていなくても、次の語を探せば見つけられます。

探す語

何が危ないか

見つけたらどうするか

import cgi

Python 3.13以降では存在しないモジュール

email.message に置き換えさせる

except Exception: の直後の pass

失敗しても何も出さずに終わる。毎日「成功」に見えたまま、ファイルが1つも増えない

失敗を記録し、終了コードを0以外にさせる

requests.get( に timeout= が無い

相手が応答しないと、いつまでも待ち続ける

タイムアウトを指定させる

API_TOKEN = "

トークンの直書き。ファイルを共有した時点で漏れる

環境変数から読ませる

特に注意したいのは2つ目です。今回は import cgi がファイルの先頭にあるので、すぐにエラーになって気づけます。AIが import cgi を try の中に書いていたら、ModuleNotFoundError も except Exception: pass に握りつぶされます。cronの記録上は毎日正常終了なのに、帳票は1枚も保存されていない、という止まり方になります。

email.message.Messageで書き直させる指示文と、直ったコード

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

以前(Python 3.12まで)

今(Python 3.13以降も動く)

使うモジュール

cgi

email.message(標準ライブラリ。追加インストール不要)

ファイル名の取り方

cgi.parse_header(値) の戻り値から params["filename"]

Message() にヘッダーを入れて get_filename()

日本語ファイル名(filename*=UTF-8''...)

自分でデコードが必要

get_filename() がデコードまで行う

古いPythonでの動作

3.13以降で動かない

3.12以前でもそのまま動く

email.message はメールのヘッダーを扱うための標準モジュールです。HTTPのContent-Dispositionはメールのヘッダーと同じ書式なので、そのまま解析できます。cgi の公式ドキュメントでも、parse_header() の代わりとして email.message が案内されていました。古いPythonでも動くので、手元とサーバーのバージョンが違っても困りません。

AIに直させる指示文

「エラーが出ました」とだけ伝えると、AIは cgi を別の方法で入れ直す回避策を出すことがあります。何に置き換えるかと、ついでに直す箇所を指定するのがコツです。

このスクリプトを Python 3.13 以降で動くように書き直してください。
条件は次のとおりです。

1. cgi モジュールは Python 3.13 で削除されたので使わないこと。
   Content-Disposition からのファイル名取得は email.message.Message の
   get_filename() で行うこと。filename*=UTF-8''... 形式の日本語名にも対応すること。
2. ヘッダーにファイル名が無いときは daily_YYYYMMDD.csv で保存すること。
   ファイル名に含まれるディレクトリ部分(../ など)は捨てること。
3. APIトークンはコードに書かず、環境変数 REPORT_API_TOKEN から読むこと。
   無ければエラーメッセージを出して終了すること。
4. requests.get には timeout を付けること。失敗したら60秒空けて最大3回まで再試行し、
   それでも駄目なら終了コード1で終わること。
5. except Exception: pass のように失敗を黙って無視する書き方はしないこと。
6. CSVではなくHTML(ログイン画面など)が返ってきたら、保存せずにエラーで終わること。
7. 保存したファイルのパスとサイズを最後に表示すること。

直った後のコード

実行環境はPython 3.13以降を想定しています(3.12以前でも動きます)。必要なパッケージは requests だけです。仮想環境を作って入れておきます。

python3 -V
python3 -m venv /home/username/venv
/home/username/venv/bin/pip install requests
/home/username/venv/bin/pip show requests

pip show で、入ったバージョンを控えておくと、後で動かなくなったときに比べられます。

import os
import sys
import time
from email.message import Message
from pathlib import Path

import requests

URL = "https://example.com/reports/daily.csv"
SAVE_DIR = Path("/home/username/reports")
TIMEOUT = 30
RETRIES = 3


def filename_from_headers(headers, fallback):
    value = headers.get("Content-Disposition")
    if not value:
        return fallback

    msg = Message()
    msg["Content-Disposition"] = value
    name = msg.get_filename()
    if not name:
        return fallback

    # UTF-8のままヘッダーに入れてくるサイトは文字化けするので戻す
    try:
        name = name.encode("latin-1").decode("utf-8")
    except UnicodeError:
        pass

    name = Path(name).name
    if name in ("", ".", ".."):
        return fallback
    return name


def fetch(token):
    headers = {"Authorization": f"Bearer {token}"}
    for attempt in range(1, RETRIES + 1):
        try:
            r = requests.get(URL, headers=headers, timeout=TIMEOUT)
            r.raise_for_status()
            return r
        except requests.RequestException as e:
            print(f"取得失敗({attempt}回目): {e}", file=sys.stderr)
            if attempt == RETRIES:
                sys.exit(1)
            time.sleep(60)


def main():
    token = os.environ.get("REPORT_API_TOKEN")
    if not token:
        sys.exit("環境変数 REPORT_API_TOKEN が設定されていません")

    r = fetch(token)

    ctype = r.headers.get("Content-Type", "")
    if "text/html" in ctype:
        sys.exit(f"CSVではなくHTMLが返りました(ログイン切れの可能性): {ctype}")

    fallback = f"daily_{time.strftime('%Y%m%d')}.csv"
    name = filename_from_headers(r.headers, fallback)

    SAVE_DIR.mkdir(parents=True, exist_ok=True)
    path = SAVE_DIR / name
    path.write_bytes(r.content)

    size = path.stat().st_size
    if size == 0:
        sys.exit(f"空のファイルを受け取りました: {path}")
    print(f"保存しました: {path}({size} bytes)")


if __name__ == "__main__":
    main()

各ブロックが何をしているか

  • filename_from_headers():cgi.parse_header() の置き換えです。空の Message() にContent-Dispositionの値を入れ、get_filename() で filename= を取り出します。filename*=UTF-8''%E6%97%A5%E5%A0%B1.csv のように日本語をエンコードした形式でも、デコードした名前を返します。
  • 文字化けの戻し:サイトによっては、規格に沿わずUTF-8の日本語をそのままヘッダーに入れてきます。requests はヘッダーをlatin-1(欧文用の文字コード)として読むので、ファイル名が化けます。encode("latin-1").decode("utf-8") で元に戻します。戻せないもの(正しくデコード済みの名前など)はそのまま使います。
  • Path(name).name:ファイル名に ../ などのディレクトリ部分が含まれていても、名前の部分だけを残します。保存先の外へ書き込まれるのを防ぎます。
  • fetch():timeout=30 で、応答が無いまま待ち続けるのを防ぎます。raise_for_status() は、401や500などのエラー応答を例外に変えます。失敗したら60秒空けて再試行し、3回失敗したら終了コード1で終わります。この終了コードで、cronやsystemdの側から失敗に気づけます。
  • main():トークンは環境変数から読みます。HTMLが返ってきたときは、ログイン画面やエラーページをCSVとして保存してしまわないように、保存せずに終わります。最後にパスとサイズを表示するので、ログを見れば毎日増えているか確かめられます。

なお、トークンが失効したときの401は、再試行しても直りません。3回分の待ち時間(約2分)がかかってから終わるだけなので、実害はありません。気になる場合は「401と403のときは再試行せず即終了して」とAIに追加で頼めば直ります。

帳票・CSVの取得先に負荷をかけないための決まりごと

自動ダウンロードは、相手のサーバーに毎日アクセスする処理です。動くかどうかとは別に、次の点を確認してください。

  • 利用規約を読む:会員向けの帳票やCSVは、自動取得を禁じているサービスがあります。CSVのエクスポート用APIが公開されているなら、そちらを使うのが筋です。
  • robots.txtを見る:https://example.com/robots.txt で、自動アクセスを断っているパスが無いか確かめます。
  • 回数と間隔を決める:日次の帳票なら1日1回で足ります。今回の再試行も60秒空けて最大3回までにしています。AIに「速くして」と頼むと、並列で何十回も取りに行くコードが出ることがあるので採用しないでください。
  • 制限を回避しない:ログイン必須の画面をすり抜ける、アクセス制限を回避するためにアクセス元を偽る、といったコードはAIが出してきても使いません。HTMLが返ったら止める作りにしているのは、このためでもあります。

日次のCSV自動取得はどこに置けば止まらないか

コードを直しても、動かす場所が止まれば帳票は取れません。今回の件そのものも、「実行環境のPythonが更新された」ことが引き金です。置き場所ごとに、止まる条件を整理します。

置き場所

向いている条件

止まる条件

手元のPC(タスクスケジューラ・cron)

まず試したい。取得し損ねても翌日手で取れば済む

スリープ中・電源オフ・ノートPCを閉じている。OS更新でPythonが入れ替わる

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

1日1回、数十秒で終わる取得。常駐が要らない

サーバー会社がPythonを更新したとき(今回の cgi と同じ形で止まる)。実行時間・プロセス数の制限に当たったとき。pip install できないプランでは動かせない

VPS(cron・systemd timer)

取得後に加工・通知まで行う。ブラウザ操作など重い処理を含む

OSやPythonを自分で更新したとき。ディスクが一杯になったとき。契約・支払いが切れたとき

日次の帳票を1本取るだけなら、共有レンタルサーバーのcronで足ります。ただし共有サーバーは、Pythonのバージョンを自分で決められません。まず python3 -V で確かめ、今回のように標準ライブラリから消える部品を使っていないかを見ておきます。uv でPythonのバージョンをスクリプトに固定して動かす方法は、uv run --scriptでcron定期実行|26年10月にまとめています。

cronに登録する例

トークンはコードにもcrontabにも直接書かず、本人だけが読める別ファイルに置きます。

# /home/username/.report_env(chmod 600 にする)
export REPORT_API_TOKEN="ここにトークン"
chmod 600 /home/username/.report_env
crontab -e
0 7 * * * . /home/username/.report_env; /home/username/venv/bin/python /home/username/fetch_report.py >> /home/username/logs/fetch.log 2>&1

毎朝7時に実行し、表示と失敗の記録を fetch.log に追記します。/home/username/logs/ は先に作っておきます。VPSでsystemd timerを使う場合は、サービスファイルの EnvironmentFile= に同じファイルを指定すれば環境変数を渡せます。

止まったことに気づく仕組みを足す

今回のコードは、失敗すると終了コード1で終わります。しかしログを見に行かなければ、止まったことには気づけません。毎日動かすなら次のどれかを足しておきます。

  • 失敗したときだけメールやチャットに通知する(Gmailでの通知はGmail smtplib通知をAIで書き直す|26年10月で扱っています)
  • 保存先に「今日の日付のファイル」があるかを別のcronで確かめる
  • 週に1回、fetch.log の末尾を見る習慣にする

「成功したのにファイルが増えていない」は、終了コードだけでは見抜けません。保存先を外から確かめる2つ目の方法が、いちばん確実です。

関連する選択肢

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

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

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

cgi.parse_header()をAIに直させるときの要点

Python 3.13で cgi モジュールが削除され、生成AIがいまも書く cgi.parse_header() によるファイル名取得は ModuleNotFoundError で止まります。直し方は、標準ライブラリの email.message.Message にContent-Dispositionを入れて get_filename() を使うことです。日本語の filename* にも対応し、古いPythonでもそのまま動きます。AIには「cgiは使わない」「何に置き換えるか」に加え、タイムアウト・再試行・環境変数のトークン・黙って無視しない例外処理まで、まとめて指示すると一度で直ります。出てきたコードは import cgi・except Exception: pass・timeout= の有無・トークンの直書きの4か所を自分で確かめてください。動かす場所は、1日1回の取得なら共有サーバーのcronで足ります。ただしPythonの更新で止まりうるので、python3 -V の確認と、保存先を外から確かめる仕組みを必ず添えておきます。