大規模言語モデルにファイル名変更ソフトを書かせた

ChatGPT 5 Plusに

JPEGファイルのファイル名を"yyyy-mm-dd HH.MM.SS.jpg" のように変更するスクリプトが欲しいです。日付はJPEGファイルの中に書き込まれているEXIF情報を参照してください。また、同一日時のファイルができてしまう場合は、末尾に通し番号をつけるようにしてください。

というプロンプトを突っ込んだら、

以下のPythonスクリプトで対応できます。EXIFのDateTimeOriginalDateTimeDateTimeDigitizedの順に参照し、YYYY-MM-DD HH.MM.SS.jpgへ改名します。重複時は _01, _02, … を付与します。EXIFが無い場合はファイルの更新時刻を使用します

と言いながら以下のようなPythonプログラムを返してきました。

ディレクトリ内にある全てのJPEGファイルを一発で名称変更してくれました。そんなに難しい内容ではないし、すでに多くの人間が書いたプログラムでしょうが、初手から修正なしで問題なく動く(ように見える)のすごいですね……。

#!/usr/bin/env python3
# rename_jpegs_by_exif.py
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path

try:
    from PIL import Image, ExifTags  # pip install Pillow
except ImportError:
    print("Pillow が必要です:  pip install Pillow", file=sys.stderr)
    sys.exit(1)

# EXIFタグ名→IDの逆引き
TAG_ID = {v: k for k, v in ExifTags.TAGS.items()}

PREFERRED_TAGS = [
    "DateTimeOriginal",   # 撮影日時
    "DateTime",           # 画像の更新日時
    "DateTimeDigitized",  # デジタイズ日時
]

def read_exif_datetime(path: Path) -> datetime | None:
    try:
        with Image.open(path) as im:
            exif = im.getexif()
            if not exif:
                return None
            # 優先順で日時文字列を取得
            dt_str = None
            for name in PREFERRED_TAGS:
                tag_id = TAG_ID.get(name)
                if tag_id in exif and exif.get(tag_id):
                    dt_str = exif.get(tag_id)
                    break
            if not dt_str:
                return None
            # 典型フォーマット "YYYY:MM:DD HH:MM:SS"
            # 稀にバイト列のことがあるので文字列化
            if isinstance(dt_str, bytes):
                dt_str = dt_str.decode(errors="ignore")
            dt_str = dt_str.strip()
            # サブ秒が別タグ(37521: SubSecTimeOriginal)に入っている場合がある
            subsec = exif.get(37521) or exif.get(37522) or exif.get(37520)  # Original / Digitized / Image
            if isinstance(subsec, bytes):
                subsec = subsec.decode(errors="ignore")
            # パース(コロン区切りの年月日に対応)
            base = datetime.strptime(dt_str[:19], "%Y:%m:%d %H:%M:%S")
            # サブ秒は使わない(重複は連番で解決)
            return base
    except Exception:
        return None

def mtime_fallback(path: Path) -> datetime:
    ts = path.stat().st_mtime
    return datetime.fromtimestamp(ts)

def format_dt(dt: datetime) -> str:
    # "YYYY-MM-DD HH.MM.SS" 形式
    return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d} {dt.hour:02d}.{dt.minute:02d}.{dt.second:02d}"

def next_available_name(target_dir: Path, base: str, ext: str, used: set[str]) -> Path:
    # 重複時は _01, _02, ... を付与
    candidate = f"{base}{ext}"
    i = 1
    while (target_dir / candidate).exists() or candidate in used:
        candidate = f"{base}_{i:02d}{ext}"
        i += 1
    used.add(candidate)
    return target_dir / candidate

def main():
    ap = argparse.ArgumentParser(description="JPEGをEXIF日時でリネーム")
    ap.add_argument("path", nargs="?", default=".", help="対象ディレクトリ(既定: カレント)")
    ap.add_argument("-n", "--dry-run", action="store_true", help="実際にはリネームせず計画のみ表示")
    ap.add_argument("-r", "--recursive", action="store_true", help="サブディレクトリも処理")
    args = ap.parse_args()

    root = Path(args.path).expanduser().resolve()
    if not root.exists():
        print("指定パスが存在しません", file=sys.stderr)
        sys.exit(1)

    patterns = ("*.jpg", "*.jpeg", "*.JPG", "*.JPEG")
    files = []
    if args.recursive:
        for ptn in patterns:
            files.extend(root.rglob(ptn))
    else:
        for ptn in patterns:
            files.extend(root.glob(ptn))

    if not files:
        print("対象JPEGが見つかりません")
        return

    used_names = set()  # この実行内での重複回避
    for src in sorted(files):
        # 拡張子は .jpg に正規化
        ext = ".jpg"
        dt = read_exif_datetime(src)
        if dt is None:
            dt = mtime_fallback(src)
        base = format_dt(dt)
        dst = next_available_name(src.parent, base, ext, used_names)

        if src.name == dst.name:
            # すでに目的の名前
            print(f"SKIP: {src.name}")
            continue

        print(f"{src.name}  ->  {dst.name}")
        if not args.dry_run:
            try:
                src.rename(dst)
            except FileExistsError:
                # 競合が発生したときの最終保険
                dst = next_available_name(src.parent, base, ext, used_names)
                src.rename(dst)

if __name__ == "__main__":
    main()