WordPress 移行後に記事内の画像が 404 になる — /wp-content/uploads/ を動的ルート1本で旧 URL のまま救う
WordPress からヘッドレス CMS へブログを移行したあと、記事本文の中の画像だけがあちこちで 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 で重複排除される実装の制約
補足として設計上の制約を 1 つ。メディアのアップロードが中身のハッシュで重複排除される実装だと、「-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 だけが旧形式。やるべきは復元ではなく送り直し
- 本文の一括書き換え(破壊的・救える範囲も狭い)ではなく、
src/pages/wp-content/uploads/[...path].tsのキャッチオール動的ルート 1 本でファイル名からメディアを引いて 301。無変更・冪等・外部流入も救える - WordPress 命名の落とし穴:
-WxHサムネイルはサフィックスを剥がして元名にフォールバック。-e<timestamp>編集版は逆パターンがあり、LIKE 自動マッチは誤画像の元なので一意なときだけ - 未検出はソフト 404 にせず素直に 404。
.gitignoreの無アンカーuploadsが新ルートを silently ignore する罠に注意(/uploadsでアンカー) - 実体が無い分は Wayback / WXR / ホットリンク元から回収し、最後に残った数枚だけ本文修正