ChatGPTやClaude Codeに「商品画像を毎日保存するPythonスクリプト」を頼むと、今でも import imghdr を含むコードが出てくることがあります。imghdr はPython 3.13で標準ライブラリから削除されました。そのため、新しいPythonを入れたVPSやuvで作った環境では ModuleNotFoundError になり、1枚も保存されずに止まります。この記事では、AIに「Python 3.13で動く形」に書き直させる指示文を実物で載せます。あわせて、Pillowによる形式判定、重複保存の防止、毎日止めずに動かす置き場所までを順に説明します。

AIが今も書く import imghdr が Python 3.13 で止まる理由

何が起きているか

imghdr は「ファイルの先頭を見て、JPEGかPNGかを当てる」ための標準モジュールでした。古いモジュールを整理する方針(PEP 594)によって、Python 3.11で非推奨(将来消すという予告)になり、3.13で削除されています。3.12までは警告が出るだけで動くため、手元では動いていたのにサーバーに置いたら動かない、という形で発覚します。

ModuleNotFoundError: No module named 'imghdr'

このエラーは、スクリプトが冒頭の1行目で止まったことを意味します。同じ方針で消えたモジュールには cgi もあり、こちらは cgi.parse_header削除をAIで直す|26年10月 で扱っています。

なぜAIは古い書き方を出すのか

2026年9月以降も、OpenAIのGPT-6 Astra(9月3日)、AnthropicのFable 5.1(9月1日)・Sonnet 5.5(9月28日)など新しいモデルが相次いで出ています。それでも、生成されるコードは学習元にある大量の古いコード例に引っ張られます。「画像の形式判定」という定番処理では、長年使われてきた imghdr.what() が出やすいと考えておくのが安全です。モデルが新しいかどうかより、出てきたコードを自分で点検できるかが大事です。

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

目的

以前(AIがよく出す)

今(3.13で動く)

形式の判定

imghdr.what(path)

Image.open(io.BytesIO(data)).format(Pillow)

壊れた画像の検出

していない

im.verify() で読めないものを弾く

Pillowを入れたくない場合

—

先頭バイト(マジックナンバー)を自前で照合

ファイル名

img_0.jpg のような連番

中身のSHA-256(ハッシュ値)+拡張子

判定できなかったとき

img_3.None ができる

保存せずログに残す

手元のPythonが何版かは python3 -V で確認できます。サーバーと手元で版が違うことはよくあるので、両方で確かめてください。

ChatGPT・Claude Codeへの指示文:最初の指示→出てきたコードの問題点→直させる指示

最初の指示(よくある頼み方)

https://example.com/items にある商品画像を毎日ダウンロードする
Pythonスクリプトを書いてください。

出てきがちなコード

import imghdr
import os
import requests
from bs4 import BeautifulSoup

API_KEY = "sk-xxxxxxxx"
url = "https://example.com/items"
html = requests.get(url).text
soup = BeautifulSoup(html, "html.parser")

for i, img in enumerate(soup.select("img")):
    try:
        data = requests.get(img["src"]).content
        with open(f"img_{i}.tmp", "wb") as f:
            f.write(data)
        ext = imghdr.what(f"img_{i}.tmp")
        os.rename(f"img_{i}.tmp", f"img_{i}.{ext}")
    except:
        pass

一見すると動きそうです。ただし、毎日回すと次の問題が出ます。

読者が自分で見つけられる確認箇所

探す文字列

何が危ないか

import imghdr

Python 3.13以降で起動できない(古いAPI)

except: の直後の pass

失敗しても何も記録されず、黙って0枚で終わる。止まったことに何日も気づけない

requests.get( に timeout= が無い

相手が応答しないと永久に待ち続け、次の日の実行と重なる

time.sleep が無い

画像を立て続けに取りに行き、相手のサーバーに負荷をかける(アクセス間隔が無い)

"sk- などの文字列や API_KEY =

キーの直書き。コードを共有した時点で漏れる。この用途では不要なのに付いてくることもある

img_{i} のような連番の名前

翌日の実行で前日の画像を上書きする。同じ画像も毎日別名で溜まる

img["src"] をそのまま使っている

/images/a.jpg のような相対パスだと取得に失敗する

このほか、imghdr.what() は判定できないと None を返すため、img_3.None という名前のファイルができます。拡張子の誤判定もここで起きます。

直させる指示(そのまま貼れる形)

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

1. imghdr は Python 3.13 で削除されたので使わないでください。
   画像の形式は Pillow の Image.open(...).format で判定し、
   verify() で壊れた画像を弾いてください。判定できない画像は保存せずログに残してください。
2. 保存ファイル名は画像の中身の SHA-256 +拡張子にしてください。
   同じ名前のファイルがすでにあれば保存しないでください(重複保存の防止)。
3. requests.Session を使い、すべての通信に timeout=30 を付け、
   1件取得するごとに3秒空けてください。User-Agent に連絡先URLを入れてください。
4. 最初に robots.txt を取得し、禁止されているURLは取得しないでください。
5. except: pass は使わないでください。失敗は logging で記録し、
   画像が1枚も見つからないとき・全件失敗したときは終了コード1で終わってください。
6. APIキーやパスワードはコードに書かず、必要なら環境変数から読んでください。
7. 一時ファイルに書いてから名前を変え、途中で止まっても壊れたファイルを残さないでください。
8. img の src が相対パスでも動くように urljoin で絶対URLにしてください。

ポイントは、「なぜ使わないか」(3.13で削除された)を一緒に書くことです。理由を書かずに「imghdrを使わないで」とだけ頼むと、別の古い書き方に置き換えられることがあります。もう一つは、終了コードの条件まで指定することです。これが無いと、AIは失敗を握りつぶす書き方に戻りがちです。

直った後のコード:Pillowで形式判定・SHA-256で重複保存を防ぐ

実行環境

  • Python 3.13 以降を想定(3.12以前でもそのまま動きます)
  • pip install requests beautifulsoup4 pillow
  • 入ったバージョンは pip show pillow で確認できます。動いた組み合わせは pip freeze > requirements.txt で記録してください。サーバーに同じ版を入れられるので、「手元では動いたのに」を防げます。版番号を固定する理由はこの再現性のためです

コード全体

#!/usr/bin/env python3
"""商品画像を毎日取得し、中身で形式を判定して、重複を省いて保存する"""
import hashlib
import io
import logging
import sys
import time
from pathlib import Path
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup
from PIL import Image, UnidentifiedImageError

START_URL = "https://example.com/items"
SAVE_DIR = Path("/home/username/images")
USER_AGENT = "image-collector/1.0 (+https://example.com/contact)"
INTERVAL = 3  # 1件ごとに空ける秒数
EXT = {"JPEG": "jpg", "PNG": "png", "GIF": "gif", "WEBP": "webp"}

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("images")


def load_robots(session):
    robots = RobotFileParser()
    r = session.get(urljoin(START_URL, "/robots.txt"), timeout=30)
    robots.parse(r.text.splitlines() if r.status_code == 200 else [])
    return robots


def detect_ext(data):
    """中身を見て拡張子を返す。画像として読めなければ None"""
    try:
        with Image.open(io.BytesIO(data)) as im:
            fmt = im.format
            im.verify()
    except (UnidentifiedImageError, OSError, SyntaxError):
        return None
    return EXT.get(fmt)


def main():
    SAVE_DIR.mkdir(parents=True, exist_ok=True)
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT

    robots = load_robots(session)
    if not robots.can_fetch(USER_AGENT, START_URL):
        log.error("robots.txt で禁止されています: %s", START_URL)
        return 1

    resp = session.get(START_URL, timeout=30)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")
    urls = sorted({urljoin(START_URL, img["src"]) for img in soup.select("img[src]")})
    if not urls:
        log.error("画像が1枚も見つかりません。ページの構造が変わった可能性があります")
        return 1

    saved = exists = failed = 0
    for url in urls:
        if not robots.can_fetch(USER_AGENT, url):
            continue
        time.sleep(INTERVAL)
        try:
            r = session.get(url, timeout=30)
            r.raise_for_status()
        except requests.RequestException as e:
            log.warning("取得失敗 %s: %s", url, e)
            failed += 1
            continue

        ext = detect_ext(r.content)
        if ext is None:
            log.warning("画像として読めません: %s", url)
            failed += 1
            continue

        digest = hashlib.sha256(r.content).hexdigest()
        path = SAVE_DIR / f"{digest}.{ext}"
        if path.exists():
            exists += 1
            continue
        tmp = SAVE_DIR / f"{digest}.part"
        tmp.write_bytes(r.content)
        tmp.replace(path)
        saved += 1

    log.info("新規 %d / 保存済み %d / 失敗 %d(対象 %d)", saved, exists, failed, len(urls))
    return 1 if failed == len(urls) else 0


if __name__ == "__main__":
    sys.exit(main())

ブロックごとの説明

  • 定数の部分:取得先・保存先・アクセス間隔を冒頭にまとめています。EXT はPillowが返す形式名(JPEG など)を拡張子に対応させる表です。表に無い形式は保存しないので、想定外の拡張子は生まれません
  • load_robots():robots.txt(サイトが「ここは機械で取らないで」と示すファイル)を読みます。User-Agentとtimeoutを効かせるため、同じセッションで取得してから parse() に渡しています。robots.txtが無い(200以外)場合は制限なしとして扱います
  • detect_ext():imghdr の代わりになる部分です。ファイルに書く前のバイト列を io.BytesIO でファイルのように見せ、Pillowで開いて形式名を取ります。続く verify() が途中で切れた画像などを検出します。verify() の後は画像を使えないため、形式名は先に取り出しています
  • main() の前半:一覧ページから img の src を集め、urljoin で相対パスを絶対URLにします。集合({...})にしているので、同じURLを2回取りに行きません。1枚も無ければ「ページの構造が変わった」と見なして終了コード1で終わります。毎日回すスクリプトで一番気づきにくい止まり方がここです
  • ループ部分:1件ごとに3秒待ってから取得します。通信の失敗はログに残して次へ進みます。except: pass と違い、何が起きたかが残ります
  • 保存部分:中身のSHA-256(内容から計算する指紋のような値)をファイル名にしています。同じ画像なら同じ名前になるため、既にあれば保存しません。これで重複保存と、翌日の上書きの両方を防げます。.part に書いてから replace() で名前を変えるので、途中で止まっても中途半端な .jpg は残りません
  • 最後の行:件数をログに出し、全件失敗なら終了コード1を返します。cronやsystemdはこの終了コードで成功・失敗を判断します

Pillowを入れたくない場合:先頭バイトで判定する

SIGNATURES = [
    (b"\xff\xd8\xff", "jpg"),
    (b"\x89PNG\r\n\x1a\n", "png"),
    (b"GIF87a", "gif"),
    (b"GIF89a", "gif"),
]


def sniff(data):
    for sig, ext in SIGNATURES:
        if data.startswith(sig):
            return ext
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return "webp"
    return None

画像ファイルは形式ごとに決まった数バイトで始まります(マジックナンバー)。imghdr が内部でしていたのも基本的にこれと同じです。依存が増えない代わりに、先頭しか見ないので途中で切れたファイルは通してしまいます。毎日溜め続ける用途ならPillowの verify() を使うほうが安全です。

なお、サーバーが返す Content-Type ヘッダーで判定する書き方もAIはよく出します。ただ、WebPを image/jpeg として返すような設定のサイトもあるため、中身で判定するほうが拡張子の誤判定を防げます。

取得先への配慮:robots.txt・利用規約・アクセス間隔・画像の扱い

  • 利用規約を先に読む:robots.txtで許可されていても、利用規約で自動取得を禁止しているサイトは多くあります。違反するとIPアドレスのブロックや損害賠償請求のリスクがあります。規約で禁止されていれば、コードを書く前にやめるか、公式のAPI・データ提供の有無を確認してください
  • アクセス間隔を空ける:上のコードは1件ごとに3秒待ちます。相手のサーバーに過負荷をかけると、偽計業務妨害罪などの刑事事件に発展する可能性があります。並列化して速くする改造はしないでください
  • User-Agentに連絡先を入れる:相手が問題に気づいたとき、ブロックする前に連絡できるようにするためです。ブラウザのふりをするUser-Agentに変えるのは、制限を回避する手口に当たるのでやめてください
  • ログインが必要なページは対象にしない:不正アクセス禁止法の問題になります
  • 画像は著作物:日本の著作権法第30条の4は、情報解析が目的の収集を例外的に認めています。ただし、取得した画像を自分のサイトなどで再公開することは許されていません。社内での価格・掲載状況の確認など、用途を限ってください

AIへの指示文に「robots.txtを確認し、間隔を空ける」と入れておくと、こうした処理がコードに残りやすくなります。

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

コードが直っても、置き場所の条件で止まります。とくに今回の imghdr は、実行するPythonが知らないうちに新しくなったときに壊れる種類の問題です。どこで動かすかを、Pythonの版を自分で決められるかという観点で選んでください。

置き場所

向いている条件

止まる条件

手元のPC

動作確認の段階。数日試すだけ

スリープ・電源断・OS更新後の再起動。HomebrewなどでPythonが更新されると、仮想環境(venv)ごと動かなくなることがある

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

1日1回・数分で終わり、画像の量も少ない

Pythonの版を事業者が決める。版が3.13以降になれば imghdr を含むコードはその日から止まる。実行時間・プロセス数・ディスク容量の上限はプランごとに違うので、事業者の説明を確認する。容量が埋まると保存が失敗する

VPS

毎日の実行を長く続ける。画像が増え続ける

Pythonの版を自分で選べる反面、OSを上げると python3 の版も変わる。ディスクが埋まる、設定ミスでタイマーが外れる、といった管理も自分の責任になる

AWS LambdaやGoogle Cloud Runのようなサーバーレス(サーバー管理が不要な実行環境)も選べます。Lambdaは毎月100万リクエストの無料枠があり、1回の実行は最大15分です。ただし、実行のたびに保存先が消える前提なので、画像は別のストレージに置く設計が必要になります。画像を手元に溜める用途なら、まずはcronかVPSのほうが単純です。

cronで動かす(共有サーバー・VPS共通)

0 6 * * * /home/username/imgbot/.venv/bin/python /home/username/imgbot/fetch_images.py >> /home/username/imgbot/fetch.log 2>&1
  • python3 ではなく、仮想環境の中のPythonをフルパスで書く。cronはログイン時と違う環境で動くため、どのPythonが使われるか分からなくなります
  • >> ... 2>&1 で、通常の出力とエラーの両方をログに残します
  • Pythonの版ごと固定したいなら、uvで版を指定して動かす方法もあります。手順は uv run --scriptでcron定期実行|26年10月 にまとめています

VPSならsystemd timerで動かす

# /home/username/.config/systemd/user/imgbot.service
[Unit]
Description=商品画像の日次取得

[Service]
Type=oneshot
WorkingDirectory=/home/username/imgbot
ExecStart=/home/username/imgbot/.venv/bin/python fetch_images.py

# /home/username/.config/systemd/user/imgbot.timer
[Unit]
Description=商品画像の日次取得(毎朝6時)

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

[Install]
WantedBy=timers.target

Persistent=true を付けると、サーバーが止まっていた時間帯の実行を起動後に1回行います。有効化は systemctl --user enable --now imgbot.timer、結果は journalctl --user -u imgbot.service で確認できます。ログアウト後も動かすには loginctl enable-linger username が要ります。

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

  • 終了コードを正しく返す:上のコードは「画像0枚」「全件失敗」で1を返します。systemctl --user status imgbot.service で失敗として見えます
  • 件数をログで見る:「新規 0 / 保存済み 0」が何日も続いたら、ページの構造が変わったサインです
  • ディスクの残りを見る:画像は毎日増えます。df -h /home/username で定期的に確認してください
  • 通知にキーを使うなら環境変数で渡す:SlackのWebhook URLなどはコードに書かず、systemdなら EnvironmentFile= で読み込み、Pythonでは os.environ.get() で受け取ります

OSやPythonを上げる前に、新しい版で python -W error::DeprecationWarning fetch_images.py を試してください。非推奨の警告が出るとエラーとして止まるので、次に消えるものを先に見つけられます。

関連する選択肢

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

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

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

要点:AIのコードは7箇所を見てから毎日回す

ChatGPTやClaude Codeが画像収集で書く import imghdr は、Python 3.13で削除されたため ModuleNotFoundError で止まります。直させるときは「3.13で削除されたので使わない」と理由を添えてください。そのうえで、Pillowの Image.open().format と verify() で中身から形式を判定させ、ファイル名を中身のSHA-256にして重複保存と上書きを防ぎます。AIのコードでは、imghdr・except: pass・timeout無し・sleep無し・キーの直書き・連番の名前・相対パスの7箇所を自分で探してください。robots.txt・利用規約・3秒間隔を守り、画像を再公開しないことも前提です。毎日動かすなら、Pythonの版を自分で固定できるVPSか、仮想環境のPythonをフルパスで指定したcronに置きます。終了コードと件数のログで、止まったことに気づける形にしてください。