日本語 URL の 301 リダイレクトだけが効かない — percent-encoding の大文字・小文字問題
WordPress から別 CMS への移行で、旧 URL → 新 URL の 301 リダイレクトをマッピングテーブル(DB に source と target を保存して、リクエストパスと突き合わせる方式)で実装しました。英語スラッグの記事は全部きれいに 301 が返るのに、日本語スラッグのタグページだけが全滅する、という現象に当たりました。
原因は、percent-encoding の大文字・小文字でした。%e3%81%82 と %E3%81%82 は同じ「あ」ですが、文字列としては別物です。
本記事では、どこで大文字と小文字が生まれるのか(ブラウザ・URL API・WordPress それぞれの挙動)と、リダイレクト実装側での正しい正規化をまとめます。
前提: Astro のミドルウェアで context.url.pathname を使った例ですが、リクエストパスを文字列比較するあらゆるリダイレクト実装(Express、Cloudflare Workers、nginx の map など)に同じ問題があります。
実際に届くリクエストは「大文字」でエンコードされている
まず、実際にサーバーへ届くパスがどうなっているかです。WHATWG の URL Standard に準拠したパーサー(ブラウザ、Node.js の URL、Workers ランタイム)は、非 ASCII 文字を percent-encoding するとき大文字の16進数を使います。
new URL("https://example.com/あい").pathname;
// => "/%E3%81%82%E3%81%84" ← 大文字
encodeURIComponent("あ");
// => "%E3%81%82" ← これも大文字これは RFC 3986 とも整合しています。§2.1 は「URI の生成・正規化では大文字の16進数を使うべき」と明記しており、大文字が正規形です。
ユーザーがアドレスバーに日本語 URL を打つ、検索結果やリンクの生の日本語 URL をブラウザが解決する — いずれの経路でも、サーバーに届く pathname は大文字エンコードになります。
一方、WordPress 由来のスラッグは「小文字」で保存されている
WordPress は日本語タイトルから生成したスラッグを、DB やエクスポートファイル(WXR)の中に `%e3%81%82` のような小文字エンコードで保持しています。移行スクリプトがこれをそのままリダイレクトテーブルの source に流し込むと、こうなります。
- テーブルの source:
/tag/%e3%82%bf%e3%82%b0(小文字) - 実際のリクエスト:
/tag/%E3%82%BF%E3%82%B0(大文字) - 文字列比較: 不一致 → リダイレクトされず 404
英語スラッグには percent-encoding が存在しないので影響ゼロ。日本語(非 ASCII)スラッグだけが静かに全滅します。移行テストを英語記事だけでやっていると見逃す、いやらしいパターンです。
落とし穴を増やす仕様: URL API は「既にエンコード済み」の文字列を直さない
「URL API が大文字にしてくれるなら、テーブル側も URL API に通せば揃うのでは」と思うかもしれませんが、通りません。
new URL("https://example.com/%e3%81%82").pathname;
// => "/%e3%81%82" ← 小文字のまま!WHATWG URL パーサーは、すでに percent-encoding されている並びはそのまま保持します(大文字への正規化はしません)。大文字になるのは「パーサー自身が生文字をエンコードした場合」だけです。つまり、どちらの形が出てくるかは入力の経路次第で、暗黙に揃うことは期待できません。
解決: 保存時に decode → encode で正規形に揃える
リダイレクトテーブルへ書き込む時点で、source を一度デコードしてからエンコードし直します。
function normalizePathSegment(segment) {
return encodeURIComponent(decodeURIComponent(segment));
}
normalizePathSegment("%e3%82%bf%e3%82%b0");
// => "%E3%82%BF%E3%82%B0" ← 大文字の正規形encodeURIComponent は常に大文字でエンコードするので、これで実際のリクエストと同じ形になります。ASCII のみのスラッグはこの変換で変化しないため、全件に無条件で適用して問題ありません。
別解として、比較する瞬間に両辺をデコードして生文字同士で比べる方法もあります(decodeURIComponent(pathname) === decodeURIComponent(source))。新規に設計するならこちらの方が頑健ですが、既存実装が「pathname の文字列一致」で動いているなら、データ側を正規形に揃える方が変更が小さく済みます。
なお、不正な percent-encoding(% の後ろが16進でない等)が混ざっていると decodeURIComponent は例外を投げるので、移行データを一括処理するときは try-catch で退避させておくと安全です。
検証時の注意: リダイレクトマップのキャッシュ
もう一つ、この調査で時間を溶かした小ネタを。リダイレクトテーブルを参照する実装は、パフォーマンスのために起動時にマップをメモリへキャッシュしていることがあります。その場合、DB に行を追加しても走っているプロセスには反映されません。「直したのに直っていない」ときは、修正が間違っているのではなく、再起動していないだけかもしれません。検証はプロセス再起動後に行ってください。
WordPress 移行全般の手順は「WordPressサイトのローカル⇄本番の移行」も参考にどうぞ。
まとめ
- ブラウザ・URL API が生成する percent-encoding は大文字(RFC 3986 の正規形)。実際のリクエストの
pathnameは大文字で届く - WordPress 由来の日本語スラッグは小文字エンコードで保存されている。文字列比較のリダイレクトは日本語スラッグだけ全滅する
- URL API は既エンコード済み文字列を正規化しないので、暗黙には揃わない
- 保存時に
encodeURIComponent(decodeURIComponent(s))で正規形に揃える。または比較時に両辺をデコードする - リダイレクトマップがキャッシュされる実装では、検証前にプロセスを再起動する