管理している 2 つのサイトで、同じ話題の記事がいくつもできていました。
読む人から見ると、どれを読めばよいか分かりません。
検索でも、似た記事どうしで順位を取り合ってしまいます。
そこで、話題が重なった記事を 1 本にまとめ、元の記事の URL はまとめ先へ 301 で転送することにしました。
この記事では、その作り方と、確かめた結果を書きます。
まとめたもの
| サイト | 元の記事 | まとめ先 |
|---|---|---|
| サイト A(このブログ) | 1 本(WordPress のプラグインの選び方と All in One SEO) | SEO のプラグインの選び方(書き直した記事) |
| サイト B(お客様のサイト) | 同じ季節の話題のコラム 6 本 | その話題のコラム 1 本(7 本分の中身をまとめた) |
どちらも、元の記事は消さずに「下書き」にしました。
消さなければ、あとで中身を見直したり、戻したりできます。
やったことの流れ
| 順 | やること | 理由 |
|---|---|---|
| 1 | まとめ先の記事に、元の記事のよいところを入れて書き直す | 元の URL から来た人が、まとめ先で同じ答えを見つけられるように |
| 2 | 元の記事を下書きにする | 消さずに残す。下書きの URL は、外からは 404 になる |
| 3 | テーマに転送の一覧を足す | 404 になった元の URL だけを、まとめ先へ 301 で送る |
| 4 | curl で元の URL を見る | 301 と転送先(Location)を確かめる |
| 5 | サイトマップと内部リンクを見る | 元の URL が残っていないかを確かめる |
301 と 302 のちがい
転送には、ずっと移す「恒久的」なものと、しばらくだけの「一時的」なものがあります。
Google 検索セントラルの「リダイレクトと Google 検索」には、次のように書かれています。
| 種類 | 状態の番号 | 検索結果に出る URL | 正規の URL の手がかりになるか |
|---|---|---|---|
| 恒久的 | 301・308 | 転送先 | なる(転送先が正規版というシグナル) |
| 一時的 | 302・307 | 転送元 | ならない |
同じページでは、URL を変えるときは、できるだけサーバー側の恒久的なリダイレクトを使うようにすすめています。
記事をまとめたら元に戻すつもりはないので、301 にしました。
なお、同じページには、転送を何か月残せばよいかという期間の目安は書かれていませんでした。
戻す予定がなければ恒久的なリダイレクトを使う、とだけ書かれています。
転送の一覧をテーマに持つ理由
転送は、プラグインでも .htaccess でもできます。
今回は、テーマのファイルに一覧を書く形にしました。
| 理由 | 中身 |
|---|---|
| git に残る | テーマは git で管理しているので、いつ・何を・どこへ転送したかが、コミットの記録に残る |
| プラグインを増やさない | 転送が数本なら、数十行の PHP で足りる。プラグインの更新や設定の画面が増えない |
| 404 のときだけ動かせる | WordPress が 404 と決めたあとに動くので、公開中のページをまちがって転送しない |
| 一覧が 1 か所にまとまる | これからまとめる記事が増えても、一覧に 1 行足すだけ |
コード:template_redirect で 404 のときだけ転送する
このブログでは、テーマの functions フォルダーに merged-redirects.php を足しました。
コメントは、記事用に短くしています。
<?php
// まとめた記事の URL へ 301 で転送する
// - WordPress が 404 と決めたときだけ動く(下書きにした記事は 404 になる)
// - 公開中のページには触らない
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* まとめた記事の転送の一覧(元のパス => 転送先のパス)
*/
function fs_merged_post_redirects() {
return array(
'/archives/1317' => '/archives/1319', // プラグインの選び方と All in One SEO → SEO のプラグインの選び方
);
}
function fs_redirect_merged_posts() {
if ( ! is_404() ) {
return;
}
$path = (string) wp_parse_url( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '', PHP_URL_PATH );
$path = untrailingslashit( $path );
$map = fs_merged_post_redirects();
if ( isset( $map[ $path ] ) ) {
wp_safe_redirect( home_url( $map[ $path ] ), 301 );
exit;
}
}
add_action( 'template_redirect', 'fs_redirect_merged_posts', 0 );
| 書き方 | 役目 |
|---|---|
template_redirect |
どのページを出すかが決まったあと、画面を出す前に動くフック。ここなら is_404() が使える |
優先度 0 |
同じフックのほかの処理(ふつうは優先度 10)より先に動かす。理由は下に書きます |
is_404() |
WordPress が「このページはない」と決めたときだけ true。下書きの記事の URL は、ログインしていない人には 404 になる |
wp_parse_url( …, PHP_URL_PATH ) |
URL からパスだけを取り出す。?nc=… のようなクエリは外れる |
wp_unslash() |
$_SERVER の値に付くことがあるバックスラッシュを外す |
untrailingslashit() |
最後の「/」を外す。/archives/1317 と /archives/1317/ を同じに見る |
isset( $map[ $path ] ) |
一覧にあるパスだけを転送する。ほかの 404 には触らない |
wp_safe_redirect( …, 301 ) |
301 で転送する。転送先が自分のサイトのときだけ送る安全な関数 |
home_url() |
サイトのアドレスを頭に付ける。作業環境でも本番でも、同じ一覧のまま使える |
exit |
転送のあとに、404 のページを出さないように止める |
このファイルは、テーマの functions.php から読み込みます。
require_once ( dirname(__FILE__) . '/functions/merged-redirects.php' );
サイト B の一覧
お客様のサイトも、同じ作りのファイル(functions/column-merge-redirect.php)にしました。
ちがうのは、一覧の中身だけです(関数の名前の頭は、記事では mysite_ にしています)。
function mysite_merged_column_redirects() {
return array(
'/archives/column/20260704' => '/archives/column/20260623', // (元の記事のタイトル)
'/archives/column/20260703' => '/archives/column/20260623', // (元の記事のタイトル)
'/archives/column/20260630' => '/archives/column/20260623', // (元の記事のタイトル)
'/archives/column/20260627' => '/archives/column/20260623', // (元の記事のタイトル)
'/archives/column/20260625' => '/archives/column/20260623', // (元の記事のタイトル)
'/archives/column/20260624' => '/archives/column/20260623', // (元の記事のタイトル)
);
}
6 本とも、まとめ先は同じ 1 本です。
コメントに元の記事のタイトルを書いておくと、あとで一覧を見たときに、何をまとめたかが分かります。
優先度を 0 にした理由
このブログには、もとから「404 のページをトップへ送る」処理がありました。
// もとからあった処理(優先度 10):404 のページをトップへ送る
function is404_redirect() {
if ( is_404() ) {
// (amp の付いた URL の転送は省略)
wp_safe_redirect( home_url( '/' ) ); // 302 リダイレクト
exit();
}
}
add_action( 'template_redirect', 'is404_redirect' );
この処理は優先度 10 で、302 でトップへ送ります。
転送の処理を同じ優先度 10 にすると、読み込む順によっては、元の記事の URL がトップへ 302 で送られてしまいます。
そこで、転送の処理を優先度 0 にして、先に動くようにしました。
一覧にない 404 は、今までどおりトップへ送られます(下の「確かめた結果」)。
クライアントのサイトでも、消した記事の URL に 410 を返す処理を同じ形(template_redirect・優先度 0・404 のときだけ)で入れていたので、それに合わせました。
確かめた結果
作業環境と本番で、元の URL を curl -I で見ました。
キャッシュを避けるため、URL に ?nc=(乱数) を付けています。
$ curl -I "http://example.local/archives/1317?nc=(乱数)" HTTP/1.1 301 Moved Permanently X-Redirect-By: WordPress Location: http://example.local/archives/1319
サイト A
| URL | 作業環境 | 本番 |
|---|---|---|
元の記事 /archives/1317 |
301 → /archives/1319 |
301 → /archives/1319 |
元の記事(最後に /)/archives/1317/ |
301 → /archives/1319 |
試していません |
まとめ先 /archives/1319 |
200 | 200 |
| 一覧にない、ない記事の URL | 302 → トップ(もとからの処理) | 試していません |
どちらも、返事に X-Redirect-By: WordPress が付いていて、WordPress の中で転送していることが分かりました。
転送先には ?nc=… が付いていませんでした。
パスだけを見て、一覧の転送先へそのまま送る作りなので、クエリは引き継ぎません。
サイト B(お客様のサイト)
本番では、元の 6 本はもう下書きになっています。
| URL | 本番 |
|---|---|
元のコラム 6 本(/archives/column/20260624 ほか) |
6 本とも 301 → /archives/column/20260623 |
まとめ先 /archives/column/20260623 |
200 |
作業環境の DB は、まとめる前に本番から複製したものなので、元の 6 本がまだ公開のままでした。
そのままでは 6 本とも 200 で、転送されませんでした。
公開中のページには触らない、という作りのとおりです。
そこで、作業環境の DB で 6 本の状態をメモしてから下書きにして、もう一度見ました。
| URL | 作業環境(公開のまま) | 作業環境(下書きにしたあと) |
|---|---|---|
| 元のコラム 6 本 | 6 本とも 200 | 6 本とも 301 → /archives/column/20260623 |
| 元のコラム(最後に /)1 本 | 試していません | 301 → /archives/column/20260623 |
| まとめ先 | 200 | 200 |
| 一覧にない、ない URL | 404 | 試していません |
見たあと、6 本はメモのとおり公開に戻しました。
内部リンクとサイトマップ
転送があっても、サイトの中のリンクが元の URL のままだと、毎回 1 回よけいに転送されます。
作業環境の DB で、公開中の記事の本文とカスタムフィールドに、元の URL が入っていないかを数えました。
| サイト | 探した URL | 本文 | カスタムフィールド |
|---|---|---|---|
| サイト A | archives/1317 |
0 | 0 |
| サイト B | 元のコラム 6 本の URL | 6 本とも 0 | 6 本とも 0 |
本番では、トップ・コラムの一覧・まとめ先の記事のページを開いて、元の URL へのリンクがないこと(0 件)を見ました。
ほかのページは、本番では全部は見ていません。
サイトマップは、SEO のプラグイン(All in One SEO)が作っています。
| サイト | 見たサイトマップ | URL の数 | 元の URL | まとめ先 |
|---|---|---|---|---|
| サイト A | 投稿のサイトマップ | 192 | なし | あり |
| サイト B | コラムのサイトマップ | 58 | 6 本ともなし | あり |
下書きにした記事は、サイトマップから外れていました。
まとめ先の記事の canonical(正規の URL)も、まとめ先の URL そのものになっていました。
気をつけること
- この作りは、404 のときだけ動きます。元の記事を公開に戻すと、転送は止まり、元の記事が出ます(作業環境で公開のままのときに 200 だったのと同じです)
- 元の記事を「ゴミ箱」に入れたときの動きは試していません。今回は、中身を残すために下書きにしました
- クエリ(
?のあと)は引き継ぎません。広告の計測の値などを付けた URL を転送したいときは、作りを変える必要があります - まとめ先の記事には、元の記事で答えていたことを入れておきます。中身のちがう記事へ送ると、読む人が困ります
- 転送が何百本にもなるときは、テーマの一覧より、プラグインやサーバーの設定のほうが向いているかもしれません(試していません)
まとめ
- 同じ話題の記事は 1 本にまとめ、元の記事は消さずに下書きにした
- 下書きの URL は 404 になるので、
template_redirectでis_404()のときだけ、一覧にあるパスをまとめ先へ 301 で送る - もとからある「404 をトップへ 302 で送る」処理より先に動くよう、優先度を 0 にした
- 転送の一覧はテーマのファイルに書いた。git に残り、プラグインも増えない
- curl で 301 と転送先を見て、サイトマップと内部リンクに元の URL が残っていないことも確かめた


