管理しているサイトに、カスタマイズで自動記事作成の機能を作ったものがあります。
元のデータから記事を作り、毎日決まった時刻に公開する機能です。
ある朝、サイトマップを見直したら、同じ URL の記事がいくつもあることに気づきました。
1 つの URL で表示されるのは 1 本だけなので、同じ URL のほかの記事は、だれにも見えていませんでした。
この記事では、なぜ WordPress なのにスラッグが重なったのか、重なるとどうなるのか、見つけ方と直し方をまとめます。
何が起きていたか
サイトマップ(カスタム投稿の分)に載っている記事の数より、URL の種類のほうが少なくなっていました。
重なっていたスラッグは、次のような形でした(スラッグの文字は例に変えています。形は同じです)。
| 重なっていたスラッグ(例) |
|---|
202503101025-item-green-tea |
202503111840-item-black-tea-bag |
202503101951-item-green-tea |
頭の 12 けたは、記事の元になるデータを作った日時(分まで)です。
そのあとに、記事の分類と、内容を表す英語の言葉が続きます。
同じ分に作ったデータで、分類と言葉まで同じだと、まったく同じスラッグになります。
WordPress なのに、なぜ重なったのか
ふつう、WordPress で記事を保存すると、wp_insert_post() の中で wp_unique_post_slug() が呼ばれます。
同じスラッグの記事がすでにあれば、-2、-3 のように番号を付けて、重ならないようにしてくれます。
それでも重なったのは、カスタマイズで作った自動記事作成の機能が、WordPress の関数や REST API を通さず、DB の wp_posts の表に直接書き込んでいたからです。
直接書くと、wp_unique_post_slug() は呼ばれません。
指定したスラッグが、そのまま入ります。
入れ方ごとに試した
本当にそうなるのか、作業環境の WordPress(7.1.3)で、試しのスクリプトを動かして確かめました。
どの入れ方でも、スラッグは同じ fs-slug-test を指定しています(カスタム投稿タイプの名前は、例の my_article にしています)。
公開のときに外のサービスへ知らせるプラグインが動かないように、プラグインを読まずに動かしました(REST だけは、カスタム投稿の REST の設定を持つプラグインを 1 つだけ読みました)。
<?php
// 試しのスクリプト(作業環境だけで動かす)。同じスラッグを指定して、入れ方ごとに結果を見る
require 'wp-load.php';
global $wpdb;
$name = function ( $id ) use ( $wpdb ) {
return $wpdb->get_var( $wpdb->prepare( "SELECT post_name FROM {$wpdb->posts} WHERE ID = %d", $id ) );
};
// 1. 公開で入れる → 2. 同じスラッグで、もう 1 本公開
$a = wp_insert_post( array( 'post_type' => 'my_article', 'post_title' => 'T1', 'post_name' => 'fs-slug-test', 'post_status' => 'publish' ) );
$b = wp_insert_post( array( 'post_type' => 'my_article', 'post_title' => 'T2', 'post_name' => 'fs-slug-test', 'post_status' => 'publish' ) );
echo $name( $a ), ' / ', $name( $b ), "\n";
// 3. 同じスラッグで下書き → wp_publish_post() で公開
$c = wp_insert_post( array( 'post_type' => 'my_article', 'post_title' => 'T3', 'post_name' => 'fs-slug-test', 'post_status' => 'draft' ) );
wp_publish_post( $c );
echo $name( $c ), "\n";
// 9. wp_posts に直接書く(WordPress の関数を通さない)
$wpdb->insert( $wpdb->posts, array(
'post_type' => 'my_article',
'post_title' => 'T8 direct',
'post_name' => 'fs-slug-test',
'post_status' => 'publish',
// …ほかの列も入れる
) );
echo $name( $wpdb->insert_id ), "\n";
| 書き方 | 役目 |
|---|---|
wp_insert_post() |
WordPress の関数で記事を作る。中で wp_unique_post_slug() が呼ばれる |
'post_name' => 'fs-slug-test' |
スラッグを指定する |
wp_publish_post() |
下書きや予約の記事を公開にする関数 |
$wpdb->insert( $wpdb->posts, … ) |
WordPress の関数を通さず、表に直接 1 行書く |
結果は次のとおりです。
| 入れ方 | 入ったスラッグ | 重なったか |
|---|---|---|
1. wp_insert_post() で公開(1 本目) |
fs-slug-test |
- |
2. wp_insert_post() で公開(2 本目) |
fs-slug-test-2 |
重ならない |
3. wp_insert_post() で下書き |
fs-slug-test |
下書きのあいだは重なる |
3 のあと、wp_publish_post() で公開 |
fs-slug-test のまま |
重なった |
4. 下書き → wp_update_post() で公開 |
fs-slug-test-3 |
重ならない |
5. wp_insert_post() で予約(future) |
fs-slug-test-4 |
重ならない |
6. REST API(POST /wp/v2/my_article)で公開 |
fs-slug-test-5 |
重ならない |
| 7. REST API で下書き → REST API で公開 | fs-slug-test-6 |
重ならない |
| 8. スラッグを指定せず、タイトルだけ同じで 2 本公開 | fs-slug-same-title と fs-slug-same-title-2 |
重ならない |
9. wp_posts に直接書く |
fs-slug-test |
重なった |
重なったのは、表に直接書いたときと、下書きを wp_publish_post() で公開したときの 2 つでした。
REST API は、下書きで作るときも、公開のときと同じように重ならない名前を決めていました。
タイトルが同じでも、スラッグを指定しなければ、タイトルから作ったスラッグに番号が付きます。
WordPress 本体のコードで確かめた
wp_unique_post_slug() は、下書き・承認待ちのときは、何もしないでスラッグを返します。
また、重なりを探すのは、同じ投稿タイプの中だけです。
// wp-includes/post.php の wp_unique_post_slug() のはじめ(WordPress 7.1.3)
if ( in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true )
|| ( 'inherit' === $post_status && 'revision' === $post_type ) || 'user_request' === $post_type
) {
return $slug;
}
// 階層のない投稿の種類(投稿・my_article など)で、重なりを探す SQL
$check_sql = "SELECT post_name FROM $wpdb->posts WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1";
| 書き方 | 役目 |
|---|---|
'draft', 'pending', 'auto-draft' |
下書き・承認待ち・自動の下書きのときは、重なりを見ない |
post_type = %s |
同じ投稿タイプの中で、同じ名前を探す |
ID != %d |
自分自身は数えない |
SQL には日付の条件がありません。
そのため、パーマリンクに日付が入っていても、同じ種類の中では、日付が違っても同じスラッグは使えない作りです(日付の入ったパーマリンクでは試していません)。
反対に、投稿タイプが違えば、同じスラッグでも番号は付きません。
wp_publish_post() は、状態を publish に書きかえるだけで、スラッグを見直しません。
// wp-includes/post.php の wp_publish_post() の一部 $wpdb->update( $wpdb->posts, array( 'post_status' => 'publish' ), array( 'ID' => $post->ID ) );
下書きで入れておいて wp_publish_post() で公開する流れだと、表に直接書くのと同じように重なります。
予約投稿の公開も wp_publish_post() を使いますが、予約(future)で入れるときに番号が付くので、試した範囲では重なりませんでした。
重なると、どの記事が出るのか
URL を開くと、WordPress はスラッグで記事を探します。
記事 1 本を出すときの検索(WP_Query)は、件数をしぼらず、並びは公開日の新しい順です。
そして、いちばん上の 1 本を表示します。
作業環境で、同じスラッグの公開中の記事を 3 本にして、開いてみました。
| 開いた URL | 結果 | 表示された記事 |
|---|---|---|
/archives/my_article/fs-slug-test |
200 | T8 direct(3 本の中で公開日がいちばん新しい) |
/?post_type=my_article&p=(T1 の ID) |
301 → /archives/my_article/fs-slug-test |
T8 direct |
/?post_type=my_article&p=(T3 の ID) |
301 → /archives/my_article/fs-slug-test |
T8 direct |
/archives/my_article/fs-slug-test-2 |
200 | T2 |
ID を指定して開いても、きれいな URL に飛ばされて、いちばん新しい記事が出ました。
つまり、古い記事は、どの URL からも開けません。
404 にもならないので、エラーのログにも出ません。
本番でも、重なった URL では、どれもいちばん新しい日付の記事が出ていました。
困るのは、新しい記事が同じ URL で出るたびに、それまで見えていた記事が入れ替わって消えることです。
前の記事が検索に載っていた場合、検索から来た人には、別の記事が出てしまいます。
見つけ方
1. 同じ post_name を数える
DB を読めるなら、いちばん確かなのは、同じ post_name を数えることです(表の頭の wp_ は、サイトの設定に合わせて変えます)。
SELECT post_name, COUNT(*) AS cnt FROM wp_posts WHERE post_type = 'my_article' AND post_status = 'publish' AND post_name <> '' GROUP BY post_name HAVING COUNT(*) > 1 ORDER BY cnt DESC;
| 書き方 | 役目 |
|---|---|
post_type = 'my_article' |
重なりは同じ種類の中で起きるので、種類をしぼる |
post_status = 'publish' |
見えなくなるのは公開中の記事どうし |
post_name <> '' |
スラッグが空の行(自動の下書きなど)を外す |
HAVING COUNT(*) > 1 |
2 本以上あるスラッグだけを出す |
出てきた cnt から 1 を引いた数が、そのスラッグで見えなくなっている記事の数です。
本番から複製した作業環境の DB で流すと、サイトマップで気づいた重なりと同じスラッグが並びました(複製した日がずれているので、数は少し違いました)。
あるスラッグの中で、どれが表示されているかは、公開日の新しい順に並べるとわかります。
SELECT ID, post_date, post_title FROM wp_posts WHERE post_type = 'my_article' AND post_status = 'publish' AND post_name = '202503101025-item-green-tea' ORDER BY post_date DESC;
いちばん上の 1 本が表示されている記事で、ほかは見えていない記事です。
2. サイトマップで数える
DB を読めないときは、サイトマップの URL を数えて、同じ URL があるかを見ます。
今回も、最初に気づいたのはこちらでした。
載っている記事の数と、URL の種類の数が違えば、どこかで重なっています。
どの URL で何本重なっているかまで確かめるなら、DB で数えるほうが確かです。
直し方
直すところは、記事を入れる自動記事作成の機能の側です。
WordPress の中から入れるなら、wp_insert_post() を使うのがいちばんです。
今回の機能は WordPress の外で動いていて、表に直接書く作りだったので、wp_unique_post_slug() と同じ考え方を、自分で書きました。
<?php
// 自動記事作成の機能の直し(考え方だけを短くしたもの)
// 1. スラッグの最後に、元のデータの番号を付ける
// 例:202503151200-item-oolong-tea-512
function build_post_slug( DateTimeInterface $created, string $words, int $dataId ): string {
return sprintf( '%s-item-%s-%d', $created->format( 'YmdHi' ), $words, $dataId );
}
// 2. 新しく作るときだけ、wp_posts にない名前にする(-2、-3 … を付ける)
function unique_post_name( PDO $pdo, string $slug ): string {
$like = addcslashes( $slug, '\\%_' ) . '-%';
$st = $pdo->prepare( 'SELECT post_name FROM wp_posts WHERE post_name = ? OR post_name LIKE ?' );
$st->execute( array( $slug, $like ) );
$used = array();
foreach ( $st->fetchAll( PDO::FETCH_COLUMN ) as $name ) {
$used[ mb_strtolower( $name ) ] = true;
}
if ( ! isset( $used[ mb_strtolower( $slug ) ] ) ) {
return $slug;
}
for ( $n = 2; $n <= 100; $n++ ) {
if ( ! isset( $used[ mb_strtolower( $slug . '-' . $n ) ] ) ) {
return $slug . '-' . $n;
}
}
return $slug . '-' . bin2hex( random_bytes( 4 ) );
}
| 書き方 | 役目 |
|---|---|
build_post_slug() |
スラッグの最後に、元のデータの番号を付ける。データが違えば、同じ分・同じ分類・同じ言葉でも重ならない |
post_name = ? OR post_name LIKE ? |
同じ名前と、名前-… の名前をまとめて読む |
addcslashes( $slug, … ) |
LIKE の %・_ などを、ふつうの文字として探す |
mb_strtolower() |
MySQL の照合順序に合わせて、大文字と小文字を同じとみなす |
-2 から -100 |
重なっていたら、空いている番号を付ける(wp_unique_post_slug() と同じ形) |
random_bytes( 4 ) |
100 まで全部使われていたときだけ、ランダムな 8 文字を付ける |
重なりは、投稿タイプを分けずに、表の全部の行で見ています。
WordPress は同じ種類の中だけを見ますが、ほかの種類の名前とも重ならないほうが安全だと考えました。
新しく作るときだけ名前を決め、更新のときはスラッグを変えません。
すでに出ている記事の URL が、直しで変わらないようにするためです。
直すまでの応急処置(テーマ側)
機能の直しが入るまでのあいだ、テーマにも、重なったスラッグを自動で直す処理を足しました。
1 時間に 1 回、直近 3 日に公開された記事を見て、前からある記事と同じスラッグなら、新しい方のスラッグの最後に記事の ID を付けます。
<?php
// テーマの functions.php から読むファイル(応急処置)。1 時間に 1 回、直近 3 日に公開された記事を見る
function my_slug_patrol() {
global $wpdb;
if ( get_transient( 'my_slug_patrol' ) ) {
return;
}
set_transient( 'my_slug_patrol', 1, HOUR_IN_SECONDS );
$posts = get_posts( array(
'post_type' => 'my_article',
'post_status' => 'publish',
'posts_per_page' => 50,
'date_query' => array( array( 'after' => '3 days ago' ) ),
'suppress_filters' => true,
) );
foreach ( $posts as $post ) {
// 同じスラッグで、前からある記事があるか
$older = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'my_article' AND post_status = 'publish'
AND post_name = %s AND ID <> %d AND ( post_date < %s OR ( post_date = %s AND ID < %d ) ) LIMIT 1",
$post->post_name, $post->ID, $post->post_date, $post->post_date, $post->ID
) );
if ( $older <= 0 ) {
continue;
}
// 新しい方の最後に、記事の ID を付ける(前からある記事の URL は変えない)
$wpdb->update( $wpdb->posts, array( 'post_name' => $post->post_name . '-' . $post->ID ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
}
}
add_action( 'init', 'my_slug_patrol', 19 );
| 書き方 | 役目 |
|---|---|
get_transient() と set_transient() |
1 時間に 1 回だけ動かす |
'date_query' |
直近 3 日に公開された記事だけを見る |
post_date < %s OR … |
自分より前からある、同じスラッグの記事を探す |
$post->post_name . '-' . $post->ID |
新しい方だけ名前を変える。前からある記事の URL は変えない |
clean_post_cache() |
表を直接書きかえたので、記事のキャッシュを消す |
本番で確かめた
直しを入れた翌朝の投稿で、スラッグがどうなったかを、本番の公開の REST API(ログインなしで読める記事の一覧)で見ました。
| 公開 | スラッグ |
|---|---|
| 直す前(10 月 11 日) | 202503121433-item-herb-tea |
| 直したあと(10 月 12 日) | 202503121433-item-jasmine-tea-512 |
最後に、元のデータの番号(512)が付いて、ほかの記事と重ならなくなりました。
頭の日時は前と同じ 202503121433 なので、番号がなければ、分類と言葉しだいでまた重なるところでした。
見えなくなった記事の扱い
見えていなかった記事は、消さずに、いったん下書きに戻しました。
もともとだれにも見えていなかったので、検索への影響はありません。
スラッグを重ならない名前に付け替える作業は、一覧を作って確かめてから、あとで行う予定です。
一度も URL で開けたことのない記事なので、名前を変えても、前の URL から飛ばす(リダイレクトする)必要はないと考えています。
気をつけること
- WordPress の外から DB に直接書くと、スラッグの重なりのほか、WordPress の関数がふだんしている処理(キャッシュを消す・フックを動かすなど)も動きません
- WordPress の中から入れるときも、下書きを
wp_publish_post()で公開すると、スラッグは見直されません。wp_update_post()で状態を変えれば、番号が付きました - 重なっても 404 にならないので、気づきにくいです。自動で記事を入れているサイトは、ときどき SQL かサイトマップで数えると安心です
- すでに見えている記事のスラッグは、変えないようにしました。変えると、検索に載っている URL が変わってしまうためです
- 試しで作った記事は、試したあとに全部消し、作業環境の表を試す前のバックアップと比べて、同じに戻ったことを確かめました
まとめ
- WordPress がスラッグの重なりを防ぐのは、
wp_insert_post()(の中のwp_unique_post_slug())を通ったときだけ。表に直接書くと、指定したスラッグがそのまま入る - 下書きのあいだは重なりを見ない。下書きを
wp_publish_post()で公開すると、重なったまま公開される - 同じスラッグの記事が複数あると、URL を開いたときは公開日のいちばん新しい記事が出て、ほかは ID を指定しても開けない
- 見つけるには、同じ
post_nameをGROUP BYで数える - 直し方は、スラッグに重ならない番号を入れることと、書く前に同じ名前を探して
-2などを付けること


