この記事でできること
同じものを指す2件のレコードを1件にまとめ、片方を削除する処理は珍しくありません。重複して取り込んだ記事、二重登録された商品、統合したアカウントなどです。このとき、消した側の詳細ページのURLは404になります。
そのURLが検索結果やブックマーク、SNSの共有に残っていると、同じ内容が統合先に残っているのに、読者は「ページが見つかりません」にたどり着きます。統合先へ恒久的なリダイレクト(308)で転送すれば、読者は同じ内容を読めますし、検索エンジンにもURLの移転として伝わります。
この記事は、個人開発している技術記事キュレーションサービスDevPickで、重複記事の統合で消えた旧URLを308で転送するようにしたときの確認項目をまとめたものです。DevPickはバックエンドがFastAPI、フロントエンドがNext.jsのApp Routerですが、確認項目の多くは構成に依存しません。
確認1:統合した時点で「旧ID→統合先ID」を残しているか
レコードを物理削除すると、そのIDが何に統合されたかは、後からはもう分かりません。転送するには、統合する処理の中で対応を書き残しておく必要があります。
DevPickでは、旧記事IDを主キー、統合先の記事IDを値に持つ転送用のテーブルを足し、統合処理が旧記事を削除するのと同じトランザクションで1行書き込むようにしました。統合がロールバックされれば、対応の記録も一緒に消えます。
この仕組みを入れる前に統合したレコードは、対応がどこにも残っていません。ログや調査メモなどから旧IDと統合先が分かるものは、テーブルを作るマイグレーションで初期データとして入れておきます。
確認すること:
- 対応の記録を、削除と同じトランザクションで書いているか
- 仕組みを入れる前に統合した分を、調べられる範囲で埋めたか
確認2:統合が重なったとき、転送が何段も連なる形になっていないか
AをBに統合したあと、BがさらにCに統合されることがあります。対応をそのまま足していくと、Aを開くとBへ転送され、Bを開くとさらにCへ転送される、という連鎖になります。検索エンジンが辿るリダイレクトの段数には限りがあるので、段数は少ないほうが安全です。
DevPickでは、書き込むときに連鎖をまとめています。BをCに統合するとき、「転送先がBになっている行」をすべてCへ付け替えてから、B→Cの行を書きます。これで、Aの行もA→Cになります。
読み取る側でも、念のため対応表を辿れる段数に上限を設け、同じIDに戻ってくる循環を見つけたら転送をやめるようにしています。
確認すること:
- 統合先がさらに統合されたとき、既存の対応を新しい統合先へ付け替えているか
- 読み取り側で、段数の上限と循環の検出を入れているか
確認3:転送先が消えたとき、404に戻っているか
統合先も、保持期間切れなどでいずれ削除されます。DevPickでは、記事を90日の保持期間で消しています。
そのときに対応表だけを見て転送すると、転送した先が404になります。DevPickでは、対応表を辿り終えたあとに統合先の記事が実在するかを確かめ、無ければ転送せず404を返すようにしました。
確認すること:
- 転送する前に、最終的な転送先が存在することを確かめているか
確認4:「存在しない」と「見せられない」を分けて扱っているか
詳細APIが404を返す理由は、1つとは限りません。DevPickでは、記事が存在しない場合のほかに、他の利用者が登録した非公開の記事を開いた場合も404を返します。
対応表を引くのは、記事が存在しない場合だけにしました。記事が存在していて権限が無い場合は、これまでどおり404です。「404なら対応表を引く」という1つの分岐にすると、権限が無くて404になった記事についても対応表を引くことになり、どちらの理由の404かで振る舞いを分けられなくなります。
確認すること:
- 対応表を引く条件が「レコードが存在しない」だけになっているか
確認5:SSRで内部APIを呼ぶとき、APIの転送を自動で追っていないか
DevPickの記事詳細ページは、Next.jsのサーバー側でバックエンドの詳細APIを呼び、その結果でページを描画します。バックエンドのAPIも、旧IDに対しては308を返すようにしました。
ここで、サーバー側のfetchが既定のまま転送を追うと、APIの308に従って統合先の記事を取得し、旧URLのまま統合先の内容を200で描画します。読者には同じ内容が見えるので一見問題ありませんが、検索エンジンには、旧URLと統合先のURLに同じ内容のページが2つあるように見えます。
DevPickでは、ページから呼ぶfetchにredirect: "manual"を付けて、APIの308をそのまま受け取るようにしました。APIのLocationヘッダーはAPIのパス(/api/news/articles/{id})を指しているので、ページの転送先には使えません。統合先のIDを独自のレスポンスヘッダーでも返しておき、ページ側はそのIDから、日本語版なら/articles/{id}、英語版なら/en/articles/{id}を組み立てて、permanentRedirectで転送します。
確認すること:
- サーバー側の
fetchが、内部APIの転送を自動で追っていないか - ページの転送先を、APIのパスではなくページのURL(言語ごとのURLを含む)から組み立てているか
確認6:本番で、実際に308のステータスが返っているか
Next.jsのpermanentRedirectは、通常は308のレスポンスを返します。ただし公式ドキュメントには、ストリーミング中に呼んだ場合はmetaタグを挿入して、クライアント側で転送すると書かれています(permanentRedirect)。この場合、HTTPのステータスは308になりません。
ブラウザで開くとどちらも統合先へ移るので、見た目では区別できません。DevPickでは、本番で旧URLのステータスと転送先をcurlで確かめました。
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://devpick.it-araiguma.com/articles/60068
308 https://devpick.it-araiguma.com/articles/60083
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://devpick.it-araiguma.com/en/articles/60068
308 https://devpick.it-araiguma.com/en/articles/60083
統合先の/articles/60083は200、対応表に無い存在しないIDは404のままであることも合わせて確認しています。
なお、恒久的な転送には301もあります。Googleは301と308をどちらも恒久的なリダイレクトとして扱います(リダイレクトとGoogle検索)。DevPickはpermanentRedirectが返す308に揃えました。
確認すること:
- 旧URLを
curlで開き、ステータスが308(または301)になっているか。200でmetaタグが返っていないか - 言語ごとのURLそれぞれが、同じ言語の統合先へ転送されているか
まとめ
- 統合で消したURLは、統合先へ308で転送する。対応は、削除と同じトランザクションで書き残す。仕組みを入れる前の統合分は、分かる範囲で初期データにする
- 統合先がさらに統合されたら、既存の対応を新しい統合先へ付け替えて、転送を1段に保つ
- 転送先が消えたら、転送せずに404に戻す
- 対応表を引くのは「レコードが存在しない」場合だけにし、権限による404とは分ける
- SSRで内部APIを呼ぶなら、APIの転送を自動で追わず、ページのURLへの転送に組み立て直す
- 本番で
curlを使い、308のステータスが返っていることを確かめる
統合した記事の旧URLを、最初は「削除」として扱っていた経緯は、Zenn版に書いています。