【WordPress】記事更新時にxml(静的ファイル)を生成するコードを自作したときのメモ

電脳備忘録

本記事のソースコードの利用によって生じた損害について、当方は一切の責任を負いません。ご自身の判断と責任のもとで参照・ご利用ください。

開発現場では、既成のツールでは対応しきれない、独自の要件にぶつかることがよくありますよね。最近、私もまさにそんな状況に直面しました。特定のXMLファイルを生成する必要があったのですが、既存のサイトの仕組みがブラックボックスになっていて、どう手をつけていいか分からない状態だったんです。

ブラックボックス化した環境でのXML生成と、解決への道のり

メインの課題は、このtop.xmlという独自のXMLファイルを生成することでした。フロントエンドのJavaScriptがこのファイルを読み込み、動的なリストを表示する仕組みだったので、常に最新の情報が反映されている必要があります。
以前は、このXML生成は少し非効率な方法で行われていました。手動での更新が必要なため、手間がかかる上に、たまに更新を忘れてしまうというヒューマンエラーも発生していました。

WordPressの固定ページでXMLを「表示」させる方法も試しましたが、これはうまくいきませんでした。ファイルが読み込まれて表示されるまでにタイムラグが発生し、リアルタイム性が求められる要件には合いませんでした。
そこで、公開中の投稿の特定情報(主にカスタムフィールドのデータ)が常にtop.xmlに反映されるように、投稿が更新または公開されるたびにXMLが自動生成される仕組みを作ることにしたのです。手間をかけずに、top.xmlが常に最新のコンテンツを反映する状態を目指しました。

なぜプラグインではなく、自力で実装したのか?

「なぜXML生成やサイトマップに既存のWordPressプラグインを使わなかったの?」と疑問に思うかもしれません。実は、今回の仕様を満たしてくれるプラグインが見つからなかったからです。また、sitemap.xmlについても、プラグインで動的に生成するのではなく、静的なファイルとして出力したいという要件がありました。
そこで、テーマのfunctions.phpファイル内に直接コードを記述し、仕様を満たすカスタムソリューションを開発することにしたのです。

データベースクエリを最適化し、2つのXMLファイルを同時生成

top.xmlとsitemap.xmlの両方を効率的に生成するために、私は単一のPHP関数内でこれらすべてを処理する方法を選びました。

基本的なアイデアはシンプルです。両方のXMLファイルに必要なすべてのデータを、最適化されたデータベースクエリを一度だけ実行して取得すること。このアプローチは、複数の独立したクエリを実行するよりも、データベースへの負荷を大幅に軽減し、処理速度を向上させます。

1. データの効率的な取得

WordPressのグローバル変数 $wpdb を活用して、WordPressデータベースに直接アクセスしました。
特に重要なのは、{$wpdb->postmeta}への単一のLEFT JOINと、MAX(CASE WHEN ... END)ステートメントの組み合わせです。これを使うことで、投稿のカスタムフィールドに保存された特定の情報(custom_field_key_1, custom_field_key_2など、あなたが設定したカスタムフィールド名に該当します)を、標準の投稿データ(スラッグなどを指すpost_name)と一緒に、非常に効率的に取得できます。これにより、データベースへの呼び出し回数が最小限に抑えられ、高速かつ効率的な処理が保証されるわけです。

// 1回のクエリで、両方のXMLファイルに必要なデータを全て取得する
$query = $wpdb->prepare(
    "SELECT
        p.post_name AS dirname,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_1,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_2,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_3,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_4,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_5,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_6,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_7,
        MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_8
    FROM
        {$wpdb->posts} p
    LEFT JOIN
        {$wpdb->postmeta} pm ON p.ID = pm.post_id
    WHERE
        p.post_type = %s AND p.post_status = %s
    GROUP BY
        p.ID
    ORDER BY
        custom_field_data_8 ASC", // 例: 日付系のカスタムフィールドで並び替え
    'your_custom_field_key_1', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_2', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_3', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_4', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_5', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_6', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_7', // あなたのカスタムフィールドキーに置き換えてください
    'your_custom_field_key_8', // あなたのカスタムフィールドキーに置き換えてください (例: 日付フィールド)
    'post', 'publish'
);
$results = $wpdb->get_results( $query );

この単一のクエリで、top.xmlに必要なカスタムフィールドデータと、後ほど説明するsitemap.xmlに必要な投稿名(スラッグ)と最終更新日の両方を効率よく取得しています。

2. top.xmlの生成

必要なデータが手に入ったら、次はtop.xmlを作りましょう。取得したデータを一つずつ処理し、XMLの形式に合わせて整形していきます。

ここでは、PHPの出力バッファリング(ob_start()とob_get_clean())がとても役立ちます。これにより、XMLの出力内容を一時的にメモリに保持し、すべて準備ができてからまとめてファイルに書き出すことができます。これにより、XML構造全体が効率的に構築され、ファイルへの書き込みもスムーズに行えます。

データを出力する際には、esc_html() を使ってXMLエスケープ処理を施すことに特に注意を払いました。これは、XMLの形式が壊れるのを防ぐだけでなく、潜在的なセキュリティ脆弱性を回避するためにも非常に重要です。もしXML内で
のようなHTMLタグを使いたい場合は、<br>のように数値文字参照に変換することで、XMLとして正しく扱えるように対応しています。

3. sitemap.xmlの生成

次に、sitemap.xmlの生成です。こちらはtop.xmlとは少し異なり、post_name(投稿のスラッグ)とpost_modified(最終更新日)という、WordPressのwp_postsテーブルに直接存在する情報だけで事足ります。そのため、カスタムフィールドのデータを取得するために使ったpostmetaテーブルとの結合は不要です。
よりシンプルに、投稿名と最終更新日だけを取得する別のクエリを実行することで、sitemap.xml用のデータ取得も非常に効率的に行えます。

// sitemap.xml のためのクエリ (post_modified を取得)
$query_sitemap = $wpdb->prepare(
    "SELECT
        p.post_name,
        p.post_modified
    FROM
        {$wpdb->posts} p
    WHERE
        p.post_type = %s AND p.post_status = %s
    ORDER BY
        p.post_date ASC",
    'post', 'publish'
);
$results_sitemap = $wpdb->get_results( $query_sitemap );

top.xmlと同様に、sitemap.xmlの生成でも出力バッファリングを利用します。WordPressの関数であるhome_url()とesc_url()を使い、サイトのURLと投稿のスラッグを組み合わせて、各ページの動的なURLを構築します。

それぞれのURLにはタグでpost_modified(投稿の最終更新日)を日付形式で設定します。これにより、検索エンジンはサイトの更新頻度を把握し、最新の情報を適切にインデックスしてくれます。
もちろん、トップページや「お問い合わせ」ページのような静的な固定ページも忘れません。これらもサイトマップに適切に追加することで、サイト全体を検索エンジンに漏れなく伝えることができます。

4. 更新のトリガー設定

作成したXMLファイルが常に最新の状態に保たれるように、自動更新の仕組みを導入します。
具体的には、update_all_xml_files()関数をWordPressのtransition_post_statusアクションにフックしました。このアクションは、WordPressの投稿ステータス(例: 下書きから公開へ、公開から非公開へなど)が変更されるたびに自動的に実行される仕組みです。

これにより、サイトの投稿が更新されたり、新しく公開されたりするたびに、XMLファイルが自動的に再生成されるようになります。手動でファイルを更新する手間が一切なくなり、常に最新のXMLファイルが提供されるわけです。

function trigger_all_xml_updates( $new_status, $old_status, $post ) {
    // 投稿タイプが 'post' でない場合は何もしない
    if ( 'post' !== $post->post_type ) {
        return;
    }
    
    // ステータス変更が「公開」に関連する場合のみXMLを更新
    if ( $new_status === 'publish' || $old_status === 'publish' ) {
        update_all_xml_files();
    }
}
add_action( 'transition_post_status', 'trigger_all_xml_updates', 10, 3 );

この自動設定のおかげで、投稿が公開されたり、非公開になったり、あるいは既存の投稿が更新されたりするたびに、XMLファイルは自動的に作り直されます。これで、手動でファイルを更新する手間が一切なくなり、常に最新のデータが反映されたXMLファイルを提供できるようになりました。

まとめ

プラグインに頼れず自力で実装しなければいけない状況での実装は経験にはなりますが、正直面倒ですね。
「データベースクエリの最適化」「ファイル生成のための出力バッファリング」「WordPressフックによる自動化」などは、さまざまな開発シーンに応用できるとは思います。

全体のコード

<?php
/**
 * top.xml と sitemap.xml を同時に生成・更新する関数
 */
function update_all_xml_files() {
    global $wpdb;

    // ======== top.xml の生成に必要なデータを取得 ========
    // 汎用的なカスタムフィールドキーを使用。実際にはあなたのキーに置き換えてください。
    $query = $wpdb->prepare(
        "SELECT
            p.post_name AS dirname,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_1,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_2,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_3,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_4,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_5,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_6,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_7,
            MAX(CASE WHEN pm.meta_key = %s THEN pm.meta_value END) AS custom_field_data_8
        FROM
            {$wpdb->posts} p
        LEFT JOIN
            {$wpdb->postmeta} pm ON p.ID = pm.post_id
        WHERE
            p.post_type = %s AND p.post_status = %s
        GROUP BY
            p.ID
        ORDER BY
            custom_field_data_8 ASC", // 例: 日付系のカスタムフィールドで並び替え
        'your_custom_field_key_1', // 例: 'item_class'
        'your_custom_field_key_2', // 例: 'item_name'
        'your_custom_field_key_3', // 例: 'item_yomi_hira'
        'your_custom_field_key_4', // 例: 'item_yomi_kana'
        'your_custom_field_key_5', // 例: 'item_company_name'
        'your_custom_field_key_6', // 例: 'item_status'
        'your_custom_field_key_7', // 例: 'item_entry_title'
        'your_custom_field_key_8', // 例: 'item_publication_date' (日付形式のカスタムフィールドを想定)
        'post', 'publish'
    );
    $results = $wpdb->get_results( $query );

    // ======== top.xml の生成 ========
    ob_start();
    echo '<?xml version="1.0" encoding="UTF-8"?>';
    ?>
<data>
<?php if ( ! empty( $results ) ) : foreach ( $results as $row ) : ?>
  <item>
    <item_1><?php echo esc_html($row->custom_field_data_1); ?></item_1>
    <item_2><?php echo esc_html($row->custom_field_data_2); ?></item_2>
    <item_3><?php echo esc_html($row->custom_field_data_3); ?></item_3>
    <item_4><?php echo esc_html($row->custom_field_data_4); ?></item_4>
    <item_5><?php echo esc_html($row->custom_field_data_5); ?>&#60;br&#62;<?php echo esc_html($row->custom_field_data_6); ?>&#60;br&#62;</item_5>
    <item_6><?php
    $string = nl2br(esc_html($row->custom_field_data_7), false);
    // &#60;br&#62; の変換処理(念のため残していますが、esc_htmlとnl2brで十分な場合が多いです)
    $convmap = array(0x3C, 0x3C, 0, 0xFFFF, 0x3E, 0x3E, 0, 0xFFFF);
    $result = mb_encode_numericentity($string, $convmap, "UTF-8");
    echo str_replace(PHP_EOL, '', $result);
    ?></item_6>
    <dirname><?php echo esc_html($row->dirname); ?></dirname>
  </item>
<?php endforeach; endif; ?>
</data>
    <?php
    $top_xml_output = ob_get_clean();
    file_put_contents( get_home_path() . 'top.xml', $top_xml_output );


    // ======== sitemap.xml の生成 ========
    $site_url = home_url();
    // post_modified(最終更新日)を取得するように変更。不要なJOINを削除。
    $query_sitemap = $wpdb->prepare(
        "SELECT
            p.post_name,
            p.post_modified
        FROM
            {$wpdb->posts} p
        WHERE
            p.post_type = %s AND p.post_status = %s
        ORDER BY
            p.post_date ASC",
        'post', 'publish'
    );
    $results_sitemap = $wpdb->get_results( $query_sitemap );

    ob_start();
    echo '<?xml version="1.0" encoding="utf-8"?>';
    ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc><?php echo esc_url($site_url); ?>/</loc>
    <changefreq>monthly</changefreq>
    <priority>1.0</priority>
    <lastmod>2018-11-01</lastmod> </url>
  <url> 
    <loc><?php echo esc_url($site_url); ?>/sample-page-1.html</loc> <changefreq>yearly</changefreq>
    <priority>0.1</priority>
    <lastmod>2017-09-01</lastmod> 
  </url> 
  <url> 
    <loc><?php echo esc_url($site_url); ?>/sample-page-2.html</loc> <changefreq>yearly</changefreq>
    <priority>0.1</priority>
    <lastmod>2017-09-01</lastmod> 
  </url>  
<?php if ( ! empty( $results_sitemap ) ) : foreach ( $results_sitemap as $p ) : ?>
  <url>
    <loc><?php echo esc_url($site_url); ?>/URL_PATH_TO_POSTS/<?php echo esc_attr($p->post_name); ?>/</loc> <changefreq>monthly</changefreq>
    <priority>1.0</priority>
    <lastmod><?php echo date('Y-m-d', strtotime($p->post_modified)); ?></lastmod> 
  </url>  
<?php endforeach; endif; ?>
</urlset>
    <?php
    $sitemap_xml_output = ob_get_clean();
    file_put_contents( get_home_path() . 'sitemap.xml', $sitemap_xml_output );
}

/**
 * 投稿ステータスの変更を検知してXMLファイル群の生成トリガーとする関数
 */
function trigger_all_xml_updates( $new_status, $old_status, $post ) {
    // 投稿タイプが 'post' でない場合は何もしない
    if ( 'post' !== $post->post_type ) {
        return;
    }
    
    // ステータス変更が「公開」に関連する場合のみXMLを更新
    if ( $new_status === 'publish' || $old_status === 'publish' ) {
        update_all_xml_files();
    }
}

// 投稿のステータスが変更されたときにトリガー関数を一度だけ実行する
add_action( 'transition_post_status', 'trigger_all_xml_updates', 10, 3 );
0%