WordPress REST APIにおける日本語ファイル名の画像アップロード失敗を図解。HTTPヘッダのlatin-1エンコーディング制約により非ASCII文字が拒否され、エラーとなる具体的なプロセスとメッセージが示されています。

はじめに

ブログ記事のアイキャッチ画像を自動生成して WordPress にアップロードするスクリプトを運用しています。生成 AI で画像を作り、WebP に変換し、REST API でメディアライブラリに登録する、という流れです。

ところがある日、特定の記事群だけアイキャッチが設定されていないことに気づきました。ログを見ると原因はこれです。

[Error] Upload failed: 'latin-1' codec can't encode characters in position 24-31: ordinal not in range(256)

画像そのものは正しく生成できています。落ちているのはアップロードの一歩手前、HTTP ヘッダの組み立てでした。

こんな人におすすめ

  • WordPress REST API でメディアを自動アップロードしている方
  • 日本語ファイル名を扱うスクリプトを書いている方
  • 「一部のデータだけ処理されない」系の不具合を追っている方

原因:HTTPヘッダは latin-1 でしか送れない

WordPress REST API にバイナリでメディアを送るときは、ファイル名を Content-Disposition ヘッダに載せます。

req = urllib.request.Request(
    f"{base}/wp-json/wp/v2/media",
    data=webp_path.read_bytes(),
    method="POST",
    headers={
        "Authorization": auth_header,
        "Content-Type": "image/webp",
        "Content-Disposition": f'attachment; filename="{webp_path.name}"',
    },
)

一見なんの変哲もないコードですが、webp_path.nameai暗号破壊脅威加速-eyecatch.webp のような日本語ファイル名だと例外になります。

HTTP のヘッダフィールドは仕様上 ISO-8859-1(latin-1)の範囲で表現することになっており、Python の http.client もヘッダを latin-1 で符号化します。日本語は latin-1 に存在しないため、送信前に UnicodeEncodeError が飛びます。サーバに届く前にクライアント側で落ちているわけです。

ここが分かりにくいところで、エラーメッセージには「WordPress」も「REST API」も出てきません。文字コードの話に見えるので、画像処理やエンコード周りを疑って時間を溶かしました。

なお、これはライブラリ選択の問題ではありません。urllib でも requests でも、ヘッダを latin-1 で符号化するという点は同じです。

回避策:ASCIIセーフなファイル名に変換する

ヘッダに載せる名前だけを ASCII に落とします。ローカルのファイル名は変えません。

def ascii_upload_filename(webp_path: Path) -> str:
    name = webp_path.name
    try:
        name.encode("latin-1")
        return name          # ASCII 名はそのまま通す(後方互換)
    except UnicodeEncodeError:
        pass

    stem = re.sub(r"[^A-Za-z0-9._-]+", "-", webp_path.stem).strip("-.")
    stem = re.sub(r"-{2,}", "-", stem) or "eyecatch"
    digest = hashlib.md5(webp_path.stem.encode("utf-8")).hexdigest()[:8]
    return f"{stem}-{digest}{webp_path.suffix}"

非 ASCII を落としただけでは別記事同士でファイル名が衝突しうるので、元の名前のハッシュを 8 桁だけ付けています。実際の変換結果はこうなります。

ai暗号破壊脅威加速-eyecatch.webp
  → ai-eyecatch-56ec3938.webp

24時間稼働型の高度なaiエージェント-remy-eyecatch.webp
  → 24-ai-remy-eyecatch-b5d8ef1d.webp

hermes-slack-agent-eyecatch.webp
  → hermes-slack-agent-eyecatch.webp   (ASCII名は変化なし)

これでアップロードが通り、media_id が返るようになりました。メディアの URL も ASCII になるため、結果的に扱いやすくなります。

RFC 5987 の filename* は採らなかった

非 ASCII のファイル名を送る仕様としては、filename*=UTF-8''<percent-encoded> という形式(RFC 5987 / RFC 6266)があります。ただし今回は採用しませんでした。

  • 受信側がこの形式を解釈する保証がない。WordPress のメディアエンドポイントは Content-Disposition から filename= を読む実装で、filename* に対応しているかを確認できていない
  • 仮に通ってもメディアの URL がパーセントエンコードされた長い文字列になり、あとから扱いづらい

「ASCII 名で保存されて困ることは無い」という判断で、単純な変換を選んでいます。filename* が通る環境なら、そちらのほうが元の名前を保てるので望ましい選択肢です。

本当の反省点は「下流が失敗を正常系として扱った」こと

技術的な原因は文字コードですが、この不具合が数か月見過ごされた理由は別のところにありました。

アイキャッチ生成スクリプトは、アップロードに失敗したときちゃんと終了コード 3 で落ちています。つまり上流は正しく失敗を報告していました。問題は、そのあと記事を投稿するスクリプトの側です。

投稿スクリプトはアイキャッチのメタ情報 JSON を読み、media_id が入っていればそれを featured_media にセットし、無ければ何も言わずにアイキャッチ無しで投稿していました。「まだアップロードしていない」と「アップロードに失敗した」を区別せず、どちらも「アイキャッチが無い記事」として正常に処理してしまっていたのです。

結果として、記事の公開自体は成功します。エラーも出ません。誰かが記事一覧を目視して「あれ、この記事だけ画像がない」と気づくまで、壊れていることが分からない状態でした。

対策として、media_id が入っていない場合は警告を出すようにしました。

[Warn] Eyecatch JSON found but not uploaded yet: xxx-eyecatch.json
       (generate_eyecatch.py --upload を先に実行してください)

一行の警告ですが、これがあれば発見までの数か月は数分になっていたはずです。

まとめ

  • HTTP ヘッダは latin-1 の範囲でしか送れない。Content-Disposition に日本語ファイル名を載せるとクライアント側で UnicodeEncodeError になる
  • エラーメッセージに WordPress も REST API も出てこないため、原因の見当がつきにくい
  • ヘッダ用の名前だけを ASCII に変換すればよい。衝突を避けるためハッシュを添える
  • そして、失敗を「値が無い状態」として下流に渡すと、下流はそれを正常系として処理してしまう。値が無いことに意味がある場合は、無いことを明示的に警告する

自動化スクリプトは、成功したときだけ静かであるべきです。失敗したのに静かなのが、いちばん高くつきます。