case_631 SaaS・IT

WordPress 301統合slugの再公開をClaude Codeで機械遮断

WordPress 301統合slugの再公開をClaude Codeで機械遮断

自社メディア6サイトで、301統合済みslugと同名の新記事をcronが再生産する「ゾンビ公開」が発生。3層のチェックが素通りした原因と、wp_insert_post_dataフックで公開を機械遮断するmu-plugin実装を実例で解説。

結論:過去に301統合で「消す」と判断したスラッグと同名の新記事を自動化パイプラインが再生産すると、「publish状態なのに旧301リダイレクトの裏で誰にも見えない」——いわばゾンビ公開が起きます。当社の自社メディア6サイト運用で実際に発生し、重複チェック・公開後検証・slug照合という3層のチェックが全部素通りしました。最終的に効いた防御は、運用手順の強化ではなく、WordPressのwp_insert_post_dataフックで公開の瞬間に301ルールテーブルと照合して遮断するmu-pluginでした。

  • 要点1:301統合の判断はリダイレクトルール(当社の実測で185件のテーブル)としてデータに残ります。しかしそのテーブルを参照しない記事生成パイプラインは、同じスラッグの「死体」を何度でも再生産する
  • 要点2:3層が素通りした理由は「重複チェックが公開済み記事しか見ない」「公開後検証がcurl -Lでリダイレクト先の200を拾って偽合格」「WordPressのslug自動一意化(-2付与)で301すら踏まない亜種の発生」の3つ
  • 要点3:対策はwp_insert_post_dataフックで新規公開時のslugを301転送元と照合し、衝突したらdraftへ落としてタイトルに理由を焼き込む機械遮断。-2亜種対策として、1〜2桁の数字接尾辞を剥がした形でも照合する(年号4桁は剥がさない)

対象読者:WordPressで複数サイトやcron自動投稿を運用している開発者・運用エンジニア、301統合・コンテンツ統廃合を進めているメディア運用者、Claude Codeで運用トラブルの根因調査を回してみたい方

8月上旬の朝でした。運用中のメディアの実描画チェックをしていて、手が止まったんです。管理画面の記事一覧には前日付でpublishの記事が並んでいる。でもサイトのトップにも、カテゴリ一覧にも、その記事がどこにも見えない。URLを直接叩くと、開いたのはまったく別の記事でした。

正体は、過去に301統合で消したスラッグと同名の新記事を、cronの記事生成パイプラインが再生産していたこと。本記事は、当社が運用する自社メディア6サイトで実際に起きたこの「ゾンビ公開」の根因調査から、WordPress層で公開を機械遮断するガード実装、6サイトへの配備検証までをClaude Codeで進めた実例の記録です。実在の自社運用事例で、登場する数値はすべて当社環境の実測です。

「publishなのに誰にも見えない」——ゾンビ公開の構造

前提から説明します。当社は自社メディアを6サイト運用していて、検索カニバリゼーション対策として過去に何度か「クラスタ統合手術」をやっています。似たテーマの記事を代表1本に寄せ、残りは削除して旧URLから統合先へ301リダイレクトを張る、というやつです。リダイレクトのルールは、mu-plugin(must-useプラグイン)が読むオプションテーブルに「転送元パス → 転送先パス」の形で保存されていて、当社の実測で185件ありました。

一方で、記事の生産はcronの自動化パイプラインが回しています。トピックのキューから記事を書き、WordPressにpublishで投稿する。ここで事故が起きました。過去に「統合して消した」テーマと同じトピックが再びキューに入り、同じスラッグで公開されたんです。

WordPressの投稿処理としては何も異常がありません。DB上はpublish、管理画面にも正常に並ぶ。ところがフロントで旧スラッグのURLにアクセスすると、mu-pluginの301ルールが記事の表示より先にマッチして、統合先の記事へ飛ばされます。HTTP 301はMDNの定義どおり「恒久的な移転」を示すステータスで、検索エンジンもこのリダイレクトに従います(developer.mozilla.org 2026年8月5日参照)。つまり新記事は、DB上は生きているのに、読者からもクローラーからも永遠に到達できない。「ゾンビ公開」と呼んでいる理由です。

厄介なのは、404のような「壊れている」シグナルが一切出ないことです。パイプラインは成功ログを吐き、記事数のカウントも増える。生成コストも消費される。でも誰にも読まれない記事が静かに積み上がっていく。気づいたのが目視チェックだったのは、正直かなり悔しいポイントでした。

3層のチェックはなぜ全部素通りしたのか

パイプラインには重複・品質まわりのチェックが3層ありました。全部素通りした理由を、実測で確認した順に並べます。

チェック内容 素通りした理由
① 重複チェック 既存記事とのタイトル・slug照合 照合対象が「公開済み記事」のみ。301統合で削除済みの記事は一覧に存在せず、185件のルールテーブルは照合対象外だった
② 公開後URL検証 公開URLにHTTPアクセスして200を確認 curl -Lを使っていたため、301→統合先の200を拾って合格判定(偽合格)
③ slugの一意性 WordPress標準の自動一意化 同slugの投稿が残っているケースではwp_unique_post_slug()が「-2」を自動付与。「-2」付きURLは301ルールに載っておらず、リダイレクトすら踏まない亜種になった

①は設計思想の穴です。「いま公開されている記事と被っていないか」しか見ていないので、過去に書いて、統合して、消したテーマはまっさらな白地に見えてしまう。削除の判断は301テーブルに残っているのに、そのデータを誰も読んでいませんでした。

②は検証ログを見返していて気づきました。ステータス欄には「200 OK」がきれいに並んでいるんです。ただしそれは-Lオプションでリダイレクトを追いかけた末の、統合先の記事の200でした。「200が返る=その記事が見えている」ではない。リダイレクトを追う設定の検証は、この種の事故に対して何も検証していないのと同じでした。

③はさらに意地の悪い亜種です。公式リファレンスによると、wp_unique_post_slug()はdraft・pending・auto-draft状態では一意性チェックをせず、公開のタイミングで重複があれば数字の接尾辞を付けます(developer.wordpress.org 2026年8月5日参照)。過去の再生産分の残骸と衝突した投稿は「-2」付きスラッグで公開され、こちらは301を踏まないので見える状態で公開される。ただし統合の判断を無視した重複記事なので、統合先の記事と検索面でカニバリを起こします。調査中にこの亜種を見つけたときは、想定より穴が深いぞとヒヤッとしました。

Claude Codeでの根因調査ログ——実描画からDB実測まで

調査はClaude Codeで進めました。ポイントは、パイプラインのコードを読んで原因を「推測」する前に、HTTPとDBの実挙動を先に測ることです。運用トラブルでは、コードから挙動を推測すると大体外します。

最初に投げたのは実描画検証のプロンプトです。

直近7日にpublishになった記事のURL一覧をWP-CLIで出して、
それぞれをリダイレクトを追わない設定(curl に -L を付けない)で取得し、
「HTTPステータス/Locationヘッダ/実際に表示されるページ」を表にしてください。
仮定した点は必ず"仮定"と明記してください。

数分で、該当記事のURLが「301 + Location: 統合先パス」を返していることが表になりました。管理画面のpublish一覧とフロントの見え方の矛盾が、ここで初めて数字として固定されます。

次に、301ルールテーブルとの突合です。

このサイトのmu-pluginが読む301ルールテーブル(オプションに保存された
転送元→転送先のマップ)を全件ダンプして、直近30日にpublishされた
記事のslugと突合してください。転送元と衝突しているslugを一覧にして、
ルールの総件数も出してください。
不足している情報があれば、最初に質問してから作業を開始してください。

ここでルール総数185件と、転送元スラッグに衝突している公開記事、さらに前述の「-2」付き亜種が特定できました。原因は確定です。301統合の判断はデータとして残っているのに、それを参照するチェックが1層もなかった。これが根因でした。

対策の設計——公開の瞬間にWordPress層で機械遮断する

修正方針を決めるにあたって、Claude Codeに設計案を出させました。

301統合済みのslugと同名の新規公開を止めたい。
(A) パイプライン側の重複チェックに301テーブル照合を足す案と、
(B) WordPress側のフックで公開処理そのものをガードする案について、
それぞれの防御範囲と副作用を先に説明してから、推奨を1つ選んでください。
数字と固有名詞は、根拠(出典/計算式)を添えてください。

結論はB、正確には「Aもやるが、最後の砦はB」です。設計判断は3つありました。

判断1:どこで守るか——全経路が必ず通る場所に置く

パイプライン側のチェック強化(A案)だけでは不十分です。投稿経路はcronパイプラインだけでなく、WP-CLI、REST API、管理画面と複数あり、今後も増えます。経路ごとにチェックを足す方式は、新しい経路が生えた瞬間に穴が開く。

そこでwp_insert_post_dataフィルタを選びました。公式リファレンスにあるとおり、これは「投稿データがデータベースへ挿入される直前」に適用されるフィルタで(developer.wordpress.org 2026年8月5日参照)、どの経路から投稿しても必ず通ります。チョークポイントが1箇所なら、防御も1箇所で済みます。

判断2:遮断の形——エラーではなくdraft降格+タイトル焼き込み

公開を例外やエラーで拒否する設計は、自動化パイプラインと相性が最悪です。パイプラインは失敗と見なしてリトライし、同じ死体を再生産し続けます。

採用したのは「投稿としては受理するが、post_statusをdraftに落とす」方式です。さらにタイトルの先頭に[301衝突・slug変更要]という接頭辞を焼き込みます。管理画面で人が見ても一目で分かり、機械がgrepしても拾える。「公開しない」という判断そのものを、ログではなくデータに焼く発想です。ログは流れますが、タイトルは残ります。

判断3:照合の正規化——数字接尾辞は剥がす、年号は剥がさない

完全一致の照合だけでは、③の「-2」亜種が抜けます。そこで、slug末尾の1〜2桁の数字接尾辞を剥がした形も照合候補に加えました。一方で、当社のslug規約は末尾に年号(-2026など)を付けるため、4桁の数字まで剥がすと誤爆します。正規表現の量指定子を{1,2}にしておけば、-2や-15は剥がれ、-2026は自然に保護されます。

mu-plugin実装——wp_insert_post_dataフックの骨子

実装の骨子がこちらです。当社で動作確認済みの設計をコード化したもので、301ルールの保存形式(オプション名・配列のキー構造)はサイトごとに読み替えてください。mu-pluginなので、wp-content/mu-plugins/に置くだけで有効化されます。

<?php
/**
 * 301統合済みslugの再公開(ゾンビ公開)を遮断するガード
 * 配置先: wp-content/mu-plugins/
 */
add_filter( 'wp_insert_post_data', 'urv_301_publish_guard', 10, 2 );

function urv_301_publish_guard( $data, $postarr ) {
    // 1) 公開になろうとしている投稿だけを対象にする
    if ( 'publish' !== $data['post_status'] ) {
        return $data;
    }

    // 2) すでに公開済みの投稿の「更新」は対象外(新規公開だけ見る)
    if ( ! empty( $postarr['ID'] ) ) {
        $existing = get_post( $postarr['ID'] );
        if ( $existing && 'publish' === $existing->post_status ) {
            return $data;
        }
    }

    // 3) 301ルールテーブル(転送元パス => 転送先パス)を読む
    $rules = get_option( 'urv_301_redirects', array() );
    if ( empty( $rules ) || ! is_array( $rules ) ) {
        return $data;
    }

    // 4) 照合候補: slugそのもの + 数字接尾辞(1〜2桁)を剥がした形
    //    ※ {1,2} なので "-2026" のような年号4桁は剥がされない
    $slug = $data['post_name']
        ? $data['post_name']
        : sanitize_title( $data['post_title'] );
    $candidates = array( $slug );
    if ( preg_match( '/^(.+)-[0-9]{1,2}$/', $slug, $m ) ) {
        $candidates[] = $m[1];
    }

    // 5) 301ルールの「転送元」スラッグと照合
    foreach ( array_keys( $rules ) as $source_path ) {
        $source_slug = basename( untrailingslashit( $source_path ) );
        if ( in_array( $source_slug, $candidates, true ) ) {
            // 衝突: draftへ落とし、理由をタイトルに焼き込む
            $data['post_status'] = 'draft';
            if ( 0 !== strpos( $data['post_title'], '[301衝突・slug変更要]' ) ) {
                $data['post_title'] = '[301衝突・slug変更要] ' . $data['post_title'];
            }
            error_log( sprintf(
                '[301-guard] blocked zombie publish: slug=%s matched=%s',
                $slug,
                $source_path
            ) );
            break;
        }
    }
    return $data;
}

短いコードですが、押さえどころが3つあります。

  • 「新規公開」だけを見る:既に公開済みの記事の更新まで遮断すると、リフレッシュ運用が全部止まります。既存投稿がpublishなら素通しにする条件(手順2)が実運用では必須でした
  • 照合はin_arrayの厳密比較:301ルールの転送元はパス形式で保存されているので、basename()でスラッグに正規化してから突合します
  • フィルタの戻り値で完結する:wp_insert_post_data$dataを返すだけで挙動が変わるので、DBを直接触るコードが不要。副作用が読みやすい

デプロイと実挙動テスト——php -lからmd5突合まで

デプロイはClaude Codeに次の流れで進めさせました。過去にコードをsshのインライン引数で流してクォート事故を起こしたことがあるので、ローカルでファイルを作り、構文チェックしてから転送する手順に固定しています。

作成したガードのPHPファイルをローカルで php -l にかけて構文確認し、
問題なければ scp で対象サーバの /tmp に転送、mu-plugins/ へ配置してください。
検証用のスニペットは ssh のインライン引数に書かず、
ファイルとして転送して wp eval-file で実行してください。
本番のDBを書き換える操作は、実行前にコマンド一覧を見せてください。

配置後、本番での実挙動テストです。ガードのような「止める系」の実装は、止まることと止まらないことの両方をテストしないと意味がありません。

本番で次の2ケースをテストしてください。
(1) 301ルールの転送元と同名slugのテスト投稿をdraftで作り、publishへ変更
    → draftに戻り、タイトルに [301衝突・slug変更要] が付くこと
(2) どのルールとも衝突しない正常slugの投稿を同じ手順でpublish
    → 影響を受けずに公開されること
終わったらテスト投稿を削除し、実行したコマンドと結果を表で報告してください。

両ケースとも期待どおりに動くことを確認しました。衝突slugはdraftに落ちてタイトルに接頭辞が付き、正常slugはそのまま公開される。-2付きの衝突slugも、接尾辞剥がしの照合で遮断されます。

最後に6サイトへの横展開です。ここにも過去の教訓があって、当社は以前「デプロイ成功と表示されたのに本番ファイルが古いまま」という事故を経験しています。以来、配備の完了判定は転送コマンドの成功表示ではなく、本番実ファイルのハッシュ一致で行うことにしています。

このmu-pluginを残り5サイトの mu-plugins/ にも配布してください。
配布後、6サイト全部の該当ファイルの md5 を取得して、
ローカルの正本と全一致することを表で示してください。
1件でも不一致があれば、修正せずにまず報告してください。

6サイト全部のmd5がローカル正本と一致したことを確認して、配備完了としました。

項目 対策前 対策後
301ルールテーブルの参照 どのチェック層も参照せず(0箇所) 公開処理で全件照合(185件・全投稿経路)
検知タイミング 人の目視(事後・不定期) 公開の瞬間に自動遮断(draft降格)
公開後URL検証 curl -Lで偽合格 -Lなし+Locationヘッダ検査に変更
-2付き亜種 素通り 数字接尾辞を剥がして照合(年号4桁は保護)
適用範囲 自社メディア6サイト(md5突合で配備検証済み)

【要注意】よくある失敗パターンと回避策

失敗1:重複チェックを「公開済み記事」だけで済ませる

❌ 現在公開中の記事タイトル・slugとだけ照合する
⭕ 301ルールテーブル・削除履歴・未公開キューも照合対象に含める

なぜ重要か:コンテンツ統廃合をやっているサイトでは、「過去に書いて消した」テーマこそ再生産されやすいからです。削除・統合の判断は必ずデータ(リダイレクトルール)に残っているので、そこを照合しない重複チェックは片目をつぶって見張りをしているのと同じです。

失敗2:公開後検証にcurl -Lを使う

curl -Lで200が返れば合格とする
-Lを付けずに叩き、「200かつLocationヘッダなし」を合格条件にする

なぜ重要か:-Lはリダイレクトを追跡するオプションなので、301の先にある別ページの200を「本人の200」として拾ってしまいます。検証したいのが「そのURLでその記事が見えること」なら、リダイレクトは追ってはいけません。

失敗3:slug照合を完全一致だけにする

❌ 転送元スラッグとの完全一致だけを見る
⭕ WordPressの自動一意化(-2付与)を前提に、数字接尾辞を剥がした形も照合する

なぜ重要か:wp_unique_post_slug()は衝突時に黙って接尾辞を付けるため、「同じ記事なのにURLだけ微妙に違う」亜種が生まれます。完全一致の照合はこの亜種に対して無力です。ただし剥がしすぎにも注意で、年号(-2026)などslug規約上の数字は保護する必要があります。

失敗4:ガードを運用手順書(人の注意)で実装する

❌ 「公開前に301テーブルを確認すること」と手順書に書く
⭕ WordPressのフックで機械遮断し、遮断理由をタイトルに焼き込む

なぜ重要か:cronは手順書を読みません。人も毎回は読みません。全投稿経路が必ず通る場所(今回はwp_insert_post_data)に置いたチェックだけが、経路の増減に関係なく機能し続けます。

この事例から持ち帰れること——判断はデータに、防御はWordPress層に

今回の学びを一般化すると、こうなります。「削除・統合の判断」はデータに残る。しかし、そのデータを参照しないパイプラインは、同じ死体を何度でも再生産する。判断を記録するだけでは足りず、判断を「全経路が必ず通る場所」で強制して初めて、防御になります。

この構図はWordPressの301に限りません。当社の他の運用でも、取引先マスタの名寄せ・重複統合では「統合済みレコードの再登場」、退職者SaaSアカウントの棚卸しでは「削除したはずのアカウントの残存」と、形を変えて同じ問題に出会います。共通する対策は「正本データとの照合を、人の注意ではなくチョークポイントの機械チェックにする」ことでした。

適用余地という意味では、次のような環境なら同じ実装がほぼそのまま使えます。

  • サイト統合・リブランド・CMS移行で301リダイレクトを大量に張った企業サイト
  • 複数の人・ボットが投稿する編集部体制のWordPressメディア
  • cronで記事や商品ページを自動生成しているEC・メディア運用(cron自体の運用設計はClaude CodeをVPSで動かす手順と7つの落とし穴で別途まとめています)

正直に付け加えると、このガードは万能ではありません。301ルールテーブル自体が壊れていれば照合も壊れますし、転送元をパスの形で持たない実装(正規表現ベースのリダイレクトなど)では正規化ロジックの作り直しが必要です。「ガードを入れたから終わり」ではなく、ガードが実際に発火した記録を定期的に見る運用までがセットです。

よくある質問

Q1. 301リダイレクト済みのスラッグで新記事を公開すると、何が起きますか?

WordPress上は正常にpublishになりますが、フロントではリダイレクトルールが先に効くため、旧スラッグのURLにアクセスしても転送先のページが表示され、新記事には誰も到達できません。エラーが出ないぶん404より発見が遅れます。本記事ではこれを「ゾンビ公開」と呼んでいます。

Q2. WordPressの301リダイレクトはhtaccessとプラグインのどちらで設定すべきですか?

件数が少なく変更頻度が低いなら.htaccess(サーバ層)でも問題ありません。ただし今回のように「公開ガードなど他の仕組みからルールを参照したい」場合は、プラグインやmu-pluginでルールをデータベース(オプション等)に持つ方が扱いやすいです。重要なのはどちらを選ぶかより、ルールを他の処理から機械的に参照できる形で一元管理することです。

Q3. スラッグ末尾に「-2」が勝手に付くのはなぜですか?

WordPressのwp_unique_post_slug()が、同じスラッグの投稿が既に存在する場合に数字の接尾辞を自動付与するためです(developer.wordpress.org 2026年8月5日参照)。draft段階では一意性チェックが行われず、公開のタイミングで付くという点も見落としがちな仕様です。

Q4. save_postフックではだめですか?

save_postは投稿がDBへ保存された「後」に発火するアクションなので、いったんpublishとして世に出た後に取り消す形になります。wp_insert_post_dataはDB書き込み「前」のフィルタなので、公開状態が一瞬も発生しません。遮断が目的なら書き込み前のフィルタが適切です。

Q5. Redirectionなどのリダイレクトプラグインを使っている場合も同じガードが必要ですか?

必要性は同じです。リダイレクトプラグインはルールを独自のテーブルに保存しているだけなので、「新規公開時にそのテーブルと照合する」発想はそのまま適用できます。照合先をプラグインのルール格納先に読み替えて、同様のフックを実装してください。

Q6. Claude Codeがなくてもこの実装はできますか?

できます。本記事のPHPコード自体は普通のmu-pluginです。Claude Codeが効いたのは実装よりむしろ「実描画検証→DB実測→根因特定→本番テスト→6サイト配備検証」という調査・検証の反復を、コマンドの実行と結果の突合込みで高速に回せた部分でした。

まとめ:同じ事故を防ぐ3つのアクション

最後に、今日からできるアクションを3つに絞ります。

  1. 自サイトの301ルールの所在と件数を確認する——.htaccessか、プラグインか、独自テーブルか。「どこに何件あるか」を即答できない状態が一番危険です
  2. 公開後検証のcurlに-Lが付いていないか見直す——付いていたら、その検証はリダイレクト事故を検知できません。「200かつLocationなし」に条件を変えましょう
  3. 新規公開時に301テーブルを照合するガードを1サイトに入れる——本記事のコード骨子で小さく始めて、実挙動テスト(止まる・止まらないの両方)まで確認してから横展開してください

コンテンツ統廃合と自動化を両方進めているサイトほど、この事故は静かに起きます。自社のWordPress運用にClaude Codeを組み込みたい、運用ガードの設計を相談したいという方は、Uravationへの問い合わせからどうぞ。次回は、ガードが弾いてdraftに落ちた記事を棚卸しして、slugの付け直しから再公開までを半自動化した運用フローを紹介する予定です。


著者プロフィール
佐藤傑(さとう・すぐる)。株式会社Uravation代表取締役。早稲田大学法学部在学中に生成AIの可能性に魅了され、X(旧Twitter)で活用法を発信(@SuguruKun_ai、フォロワー約10万人)。100社以上の企業向けAI研修・導入支援を展開。著書『AIエージェント仕事術』(SBクリエイティブ)。

参考・出典

Next Step

この事例を、自社の業務に置き換える。

対象業務、利用データ、評価基準、社内展開の順番まで整理すると、AI開発ツール導入の失敗を減らせます。

導入を相談する

チームで学ぶなら: Claude Code 法人研修(2日間ハンズオン) / 1人で習得するなら: 個別指導(週1マンツーマン)