自動記事作成でスラッグが重なったときの対応:カスタマイズで作った自動投稿で、同じ URL の記事が見えなくなっていた

広告WordPress・PHP を学べる本

Amazonのアソシエイトとして、WordPress functions.phpは適格販売により収入を得ています。

管理しているサイトに、カスタマイズで自動記事作成の機能を作ったものがあります。

元のデータから記事を作り、毎日決まった時刻に公開する機能です。

ある朝、サイトマップを見直したら、同じ 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-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-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_ は、サイトの設定に合わせて変えます)。

同じスラッグの記事を数える SQL(読むだけ)
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 で流すと、サイトマップで気づいた重なりと同じスラッグが並びました(複製した日がずれているので、数は少し違いました)。

あるスラッグの中で、どれが表示されているかは、公開日の新しい順に並べるとわかります。

1 つのスラッグの記事を並べる SQL(読むだけ)
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 などを付けること

参考

関連機能

  1. サイトマップエラー error on line 2 at column 6: XML declaration allowed only at the start of the document の修正…

  2. Code Snippetsにコードを追加したらエラー表示になった時の対応方法

  3. WordPress コンソールログの「JQMIGRATE: Migrate is installed, version 3.4.1」を非表示にする

サイドバー

よく使うカスタマイズ

最近の記事

Profile

PAGE TOP