WordPress 移行後に記事内の画像が 404 になるときの対処
WordPress から Astro 製の CMS「EmDash」 へブログを移行したあと、記事本文の中の画像だけがあちこちで 404 になるという問い合わせが実読者から届きました。トップや一覧のサムネイルは出ているのに、記事を開くと本文中の画像が抜けている状態です。原因を追うと、移行時にそのまま取り込んだ本文(HTML/リッチテキスト)が、旧 WordPress の /wp-content/uploads/2019/03/foo.jpg のような絶対パスを参照したままでした。新環境にそのパスは存在しないので、当然 404 です。
規模も無視できず、2 サイト合わせて 15 日間で、この旧 /wp-content/uploads/ 配下へのリクエストが約 2,900 件ありました。読者のアクセスに加えて、Google 画像検索や外部サイトからのホットリンクも同じ URL にリクエストしてきます。
本記事では、本文を一切書き換えずに キャッチオール動的ルート 1 本で旧画像 URL を現行メディアへ 301 リダイレクトする設計と、WordPress 特有の命名の落とし穴をまとめます。
前提: 移行先は Astro(output: "server")+ Cloudflare(R2 にメディア実体、D1 にメディアのメタデータ)という構成です。旧画像 URL は /wp-content/uploads/<year>/<month>/<file> 形式。メディアは移行時に元ファイル名をキーに新メディアライブラリへ取り込み済み、というのが出発点です。
まず事実確認: 「URL だけが旧形式」で、実体は移行済みだった
対策を決める前に、404 になっているファイルのうち実体が本当に無いのはどれかを数えました。旧 URL のファイル名を新メディア DB のキーと突き合わせたところ、92〜93% は実体が存在していました。つまり大半は「画像が消えた」のではなく、本文が指している URL が古いだけです。
実体があるので、やるべきことはリクエストを正しい現行 URL へ送り直すことです。
設計判断: 本文の一括書き換えではなく、動的ルートで受ける
素直に思いつくのは「本文中の旧 URL を新 URL に一括置換する」migration スクリプトですが、これは本番コンテンツへの破壊的変更で、得られる効果に対してリスクが大きすぎます。
- 置換ミスが本文を壊し、記事数が多いほど検証しきれない
- 外部サイトからのホットリンクや Google 画像検索経由の流入は、本文を直しても旧 URL のままやってくるので救えない
- 一度きりの処理なので、あとから来る新しいパターンに対応できない
代わりに、リクエストを受け取る側に 1 本だけルートを足す方針にしました。旧 URL の形に一致するキャッチオール動的ルートを用意し、リクエストされたファイル名でメディア DB を引いて、見つかった現行 URL へ 301 で送ります。
- コンテンツは無変更(本文の旧 URL はそのまま。破壊的変更ゼロ)
- 冪等。何度リクエストが来ても同じ結果
- ホットリンクも画像検索経由も、旧 URL を叩く限りすべて救える
Astro の output: "server" なら、src/pages/wp-content/uploads/[...path].ts を置くだけで /wp-content/uploads/... 配下を丸ごとこのハンドラが受けます。
実装: ファイル名でメディアを引いて 301
import type { APIRoute } from "astro";
// D1 のメディアテーブルを「元ファイル名」で引くヘルパ(実装は環境依存)
async function findMediaUrlByFilename(filename: string): Promise<string | null> {
// SELECT url FROM media WHERE original_filename = ?1
// ...
return null;
}
export const GET: APIRoute = async ({ params }) => {
const path = params.path ?? ""; // "2019/03/foo.jpg"
const filename = path.split("/").pop() ?? ""; // "foo.jpg"
const url = await findMediaUrlByFilename(filename);
if (url) {
return new Response(null, { status: 301, headers: { Location: url } });
}
// 見つからなければ素直に 404(後述)
return new Response("Not Found", { status: 404 });
};ポイントは、パスの <year>/<month>/ 部分は捨ててファイル名だけで引くことです。移行時に元ファイル名をキーにしているので、年月ディレクトリは照合に不要ですし、WordPress 側で年月が食い違っていても拾えます。
WordPress 特有の命名の落とし穴とフォールバック
素のファイル名一致だけだと、WordPress の命名規則のために、実体があるのに引けないケースが出ます。
サムネイル命名 foo-300x200.jpg
WordPress は 1 枚アップロードすると、thumbnail(既定 150×150)や medium(既定 300 幅)などの中間サイズを自動生成し、元名-<幅>x<高さ>.jpg という名前で保存します(add_image_size() で追加したサイズも同じ規則です)。本文がこの中間サイズを参照していると、メディアには元画像しか無いので素のファイル名一致では引けません。
-<幅>x<高さ> サフィックスを剥がして元ファイル名でリトライします。
// foo-300x200.jpg → foo.jpg
const base = filename.replace(/-\d+x\d+(\.[a-z0-9]+)$/i, "$1");
if (base !== filename) {
const url = await findMediaUrlByFilename(base);
// ...
}編集版命名 foo-e1488286991819.jpg
WordPress の画像エディタで回転・切り抜きなどを保存すると、元名-e<タイムスタンプ>.jpg という編集版が作られます。厄介なのは逆方向で、本文は素の foo.jpg を参照しているのに、移行できているのは foo-e….jpg の方だけというケースです。
ここで「ファイル名 LIKE で近いものを自動的に当てる」フォールバックを足したくなりますが、同じ prefix の別画像へリダイレクトする事故が起きるので、やめておくべきです。-e 版が一意に定まる場合だけリダイレクトし、候補が複数あるときはリダイレクトしません。
未検出は素直に 404(ソフト 404 にしない)
見つからなかったとき、200 でプレースホルダ画像やトップへ返す“ソフト 404”は避けます。この URL 配下には .php を探すスキャナのリクエストも大量に飛んでくるので、存在しないものには正直に 404 を返すのが検索エンジンにもログの見通しにも健全です。
ステータスだけが 200 のまま返る“ソフト 404”が Astro SSR でなぜ起きるか、Astro.rewrite と Astro.response.status で正しく 404 を返す実装は「Astro SSR で正しく 404 を返す」にまとめています。
メディアが content-hash で重複排除される実装の制約
補足として、メディアのアップロードが中身のハッシュで重複排除される実装には設計上の制約があり、「-e 版のバイナリを素のファイル名で登録し直す」ことができません。中身が同一なら既存の行が返るだけで、別名では入りません。この場合の選択肢は「本文を直す」か「別バイナリとして入れる」の二択になります。命名のフォールバックでは解決できないケースがあると知っておくと、判断が早くなります。
地味だが致命的な罠: .gitignore の無アンカー uploads
実装したのにルートが 404 のままになる現象に一度はまりました。原因は .gitignore です。WordPress 由来のプロジェクトだと、アップロード物を無視するために
uploadsという行が入っていることがあります。アンカーの無い uploads は、リポジトリ内のどの階層の uploads/ にもマッチします。つまり新設した src/pages/wp-content/uploads/ が丸ごと ignore され、[...path].ts がコミットされず、デプロイもされません。ローカルの dev では動くのに本番だけ 404 になるので、気づきにくい不具合です。
意図が「ルート直下のアップロード物だけ無視」なら、先頭を / でアンカーします。
/uploads実体が本当に無い 7〜8% の回収先
残る「実体が本当に無い」ファイルは、命名フォールバックでは救えません。回収先の実例を挙げておきます。
- Wayback Machine: 旧 URL のスナップショットが web.archive.org に残っていれば、そこから実ファイルを吸い出せます
- 移行時の WXR: WordPress のエクスポート(WXR)には
attachmentとして元 URL が記録されているので、どのファイルがどこにあったかの特定に使えます - ホットリンクだった画像: 調べると「別サイトの画像を直リンクしていただけ」で、元サイトにまだ生きていたものもありました。これは自サイトに実体が無くて当然です
動的ルートで大半のリクエストを本文の変更なしに送り直し、どうしても回収できない本当に必要な数枚だけ、最終手段として本文を修正します。
関連して、リダイレクト実装で日本語スラッグだけがリダイレクトされなくなる percent-encoding の罠は「日本語 URL の 301 リダイレクトだけが効かない」にまとめています。
まとめ
- WordPress→ヘッドレス移行後、本文が旧
/wp-content/uploads/...を参照したままだと記事内画像が 404。実読者・画像検索・ホットリンクからリクエストが来る(2 サイト 15 日で約 2,900 リクエスト) - 404 の 9 割超は実体は移行済みで URL だけが旧形式。やるべきは現行 URL への送り直し
- 本文の一括書き換え(破壊的・救える範囲も狭い)ではなく、
src/pages/wp-content/uploads/[...path].tsのキャッチオール動的ルート 1 本でファイル名からメディアを引いて 301。無変更・冪等・外部流入も救える - WordPress 命名の落とし穴:
-WxHサムネイルはサフィックスを剥がして元名にフォールバック。-e<timestamp>編集版は逆パターンがあり、LIKE の自動マッチは別画像へのリダイレクトを起こすので、一意なときだけ当てる - 未検出はソフト 404 にせず素直に 404。
.gitignoreの無アンカーuploadsが新ルートを silently ignore する罠に注意(/uploadsでアンカー) - 実体が無い分は Wayback / WXR / ホットリンク元から回収し、最後に残った数枚だけ本文修正