WordPressサイトの公開作業中、Search Consoleに送信したサイトマップが「取得できませんでした」になりました。ブラウザでwp-sitemap.xmlを開くと中身は正しく表示されるのに、HTTPステータスだけが404を返しているという状態です。
原因を追いかけた結果、引き金はWordPressの初期サンプル投稿「Hello world!」を削除したことでした。サイト内の「投稿」が0件になった瞬間から、WordPress標準のサイトマップが404を返すようになっていたのです。
固定ページとカスタム投稿タイプだけで作るコーポレートサイトでは、投稿を1件も使わない構成が普通にあります。同じ踏み方をする人がいそうなので、切り分けの過程と対処をそのまま書き残します。途中で一度、原因を誤って断定した経緯も含めて記録します。
きっかけ:中身は正しいのにステータスだけ404
整骨院のサイトを静的HTMLからWordPressへ移行し、SEOまわりを整えている最中でした。Search Consoleにサイトマップを送信すると、一度は「成功しました」と表示されたのに、数日後に再送信したら赤字で「取得できませんでした」に変わっていました。
コマンドラインで叩いてみると、症状の奇妙さがはっきりします。
$ curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-sitemap.xml
404
$ curl -s https://example.com/wp-sitemap.xml | head -c 200
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://example.com/wp-sitemap-index.xsl" ?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>...
XMLは正しく生成されている。なのにステータスは404。Googleはステータスを見て判断するので、中身が正しくても「取得できませんでした」になります。
最初の誤診:自分が書いたコードを疑った
直前に、SEO改善のためテーマへ手を入れていました。著者アーカイブをサイトマップから除外する処理です。
// 最初に書いたコード
function my_sitemap_remove_users( $provider, $name ) {
return ( 'users' === $name ) ? false : $provider;
}
add_filter( 'wp_sitemaps_add_provider', 'my_sitemap_remove_users', 10, 2 );
「フィルタでfalseを返したのがまずくて、サイトマップ全体が壊れたのだろう」と考え、別方式に書き換えた修正版を用意しました。時系列としても、このコードを入れた前後で200から404に変わっています。状況証拠は揃っているように見えました。
結果、直りませんでした。404のままです。しかも書き換えた新方式は、除外したかったwp-sitemap-users-1.xmlがサイトマップ一覧に復活してしまい、機能としても後退していました。つまり最初のコードは正しく動いていて、404とは無関係だったわけです。
切り分けが不十分なまま原因を断定し、クライアントに1往復ぶん余計な作業(テーマの再アップロード)をお願いしてしまいました。ここは反省点です。
切り分けをやり直す
思い込みを捨てて、WordPressの他のエンドポイントが生きているかを横並びで確認しました。セキュリティ系プラグインが特定のURLを塞ぐケースもあるためです。
for u in "/wp-sitemap.xml" "/feed/" "/wp-json/" "/wp-sitemap-index.xsl" "/?sitemap=index"; do
printf "%-26s %s\n" "$u" "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$u")"
done
| URL | ステータス |
|---|---|
| /wp-sitemap.xml | 404 |
| /feed/ | 200 |
| /wp-json/ | 200 |
| /wp-sitemap-index.xsl | 200 |
| /?sitemap=index | 404 |
フィードもREST APIも生きている。サイトマップ用のXSLスタイルシートまで200を返している。プラグインによる一律ブロックではなさそうです。
次に、サイトマップの中身の変化に気づきました。以前は載っていたwp-sitemap-posts-post-1.xml(投稿のサイトマップ)が消えていたのです。
$ curl -s https://example.com/wp-json/wp/v2/posts | python3 -c "import json,sys; print(len(json.load(sys.stdin)))"
0
公開中の投稿が0件。ここでようやく、直前に「Hello world!」を削除していたことと結びつきました。このサイトは固定ページとカスタム投稿タイプ(コラム)だけで構成していたため、サンプル投稿を消した時点で「投稿」がゼロになっていたのです。
真因:コードを読んで確定させる
「投稿0件が原因らしい」で止めると、また誤診を繰り返します。WordPressコアのソースで裏を取りました。関係するのは3つのファイルです。
1. サイトマップ描画は200を宣言していない
wp-includes/sitemaps/class-wp-sitemaps.phpのrender_sitemaps()は、インデックスを描画してexitするだけです。成功時にstatus_header(200)を呼んでいません。ステータスは、それ以前の処理が決めた値をそのまま引き継ぎます。
// 抜粋:インデックス描画部分
if ( 'index' === $sitemap ) {
$sitemap_list = $this->index->get_sitemap_list();
$this->renderer->render_index( $sitemap_list );
exit;
}
2. ステータスを決めているのは handle_404()
wp-includes/class-wp.phpのhandle_404()は、描画よりも前に走ります。判定はおおまかに次の順です。
- 投稿が見つかっていれば200
- 見つからなくても、
is_home()・is_search()・is_feed()や、対象が存在するアーカイブなら200 - どれにも当てはまらなければ404
「投稿が0件でもis_home()なら200になるのでは」と思いますが、ここに落とし穴があります。
3. サイトマップのリクエストは is_home にならない
wp-includes/class-wp-query.phpを見ると、is_homeを立てる条件からis_sitemapが明確に除外されています。
if ( ! ( $this->is_singular || $this->is_archive || $this->is_search || $this->is_feed
|| ( wp_is_serving_rest_request() && $this->is_main_query() )
|| $this->is_trackback || $this->is_404 || $this->is_admin
|| $this->is_robots || $this->is_favicon || $this->is_sitemap ) ) {
$this->is_home = true;
}
そしてsitemapというクエリ変数があるとis_sitemapが立ちます。つまりサイトマップのリクエストはis_homeから外れる。結果として次の条件が揃うと404が確定します。
- 公開中の「投稿」が0件(メインクエリが空になる)
- サイトマップのリクエストなので
is_homeではない - 他の救済条件にも当てはまらない
404が確定した後でrender_sitemaps()が走り、XMLだけが出力される。「中身は正しいのにステータスだけ404」という奇妙な症状は、こうして生まれていました。投稿が1件でもあれば$wp_query->postsが埋まるので200になります。Hello world!が、意図せず200を守っていたわけです。
対処:サイトマップ要求時は200を強制する
テーマのfunctions.phpに次を追加しました。コアの描画処理より先に走らせたいので、template_redirectの優先度を0にしています。
/**
* サイトマップ要求時は必ず200を返す
* 公開中の「投稿」が0件だとメインクエリが空になり404が確定するため、
* XMLは出力されるのにステータスだけ404になる現象への対処
*/
function my_sitemap_force_200() {
if ( get_query_var( 'sitemap' ) || get_query_var( 'sitemap-stylesheet' ) ) {
global $wp_query;
$wp_query->is_404 = false;
status_header( 200 );
}
}
add_action( 'template_redirect', 'my_sitemap_force_200', 0 );
投稿を1件用意すれば回避もできますが、使わない投稿をSEOのためだけに置いておくのは本末転倒です。サイト構成を変えずに済むこちらを選びました。
確認方法
ブラウザで開くと中身が見えてしまい判断を誤ります。必ずステータスコードで確認してください。
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-sitemap.xml
# 子サイトマップも確認する
curl -s https://example.com/wp-sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' \
| while read u; do
printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"
done
すべて200になったら、Search Consoleでサイトマップを一度削除してから再送信します。エラー履歴が残らず、状態がはっきりします。
つまずき早見表
| 症状 | 確認すること | 対処 |
|---|---|---|
| XMLは出るがステータスが404 | 公開中の「投稿」が0件でないか | 上記の200強制コードを追加 |
| サイトマップ自体が表示されない | 設定>表示設定の「検索エンジンがインデックスしないようにする」 | チェックを外す(オンだとコアが404を返す) |
| URLが404、他ページは正常 | パーマリンクのルールが古い可能性 | 設定>パーマリンクで変更せず保存 |
| 子サイトマップだけ404 | そのサイトマップに載るURLが0件でないか | コアの仕様。対象を作るか、そのプロバイダを除外 |
| Search Consoleが「取得できませんでした」 | curlでステータスを直接確認 | 200を確認してから再送信 |
正直に書いておくこと
このコードはステータスコードを200に矯正するだけで、サイトマップの中身や検索順位そのものを良くするものではありません。Googleがサイトマップを読めない状態を解消する、いわば土台の修理です。
また、投稿が0件でなければこの問題は起きません。ブログを運用しているサイトには関係のない話です。固定ページとカスタム投稿タイプだけで作るコーポレートサイトで、初期サンプル投稿を掃除したときに踏む、という限定された条件です。
今回引用したコードはWordPressコアの開発版(trunk)で確認したものです。バージョンによって実装が変わる可能性はあります。バージョンを上げた際は、サイトマップのステータスを一度確認してください。
そして冒頭に書いたとおり、私は一度「自分の書いたコードが原因だ」と誤って断定しました。状況証拠(そのコードを入れた前後で症状が変わった)だけで判断したのが原因です。他のエンドポイントと横並びで比べ、変化した箇所を数字で確かめるまで、原因を口にすべきではありませんでした。
要点3行
- 公開中の「投稿」が0件だと、WordPress標準のサイトマップはXMLを出しながらHTTPステータスだけ404を返す
- 原因は、サイトマップのリクエストが
is_homeから除外されており、投稿が空だとhandle_404()で404が確定するため template_redirectの優先度0でstatus_header(200)を強制すれば、サイト構成を変えずに直せる