Cocoon+Bogo利用時のカテゴリー記事数をなんとかする

PHP

WordPressのテーマとしてCocoonを愛用していますが、別のブログで二言語化するにあたってプラグインのBogoを使っています。

Bogo自体は言語ごとのコンテンツ管理はスムーズに行えますけども、カテゴリー別の記事数が言語別に集計されない問題があって、そいつをなんとかやっつけた経緯と最終コードを記事にしておきます。

カテゴリーリストに言語別の投稿数を表示したい

簡単に言うと、該当のブログでは全ての記事が日本語記事と英語記事のペアで構成されています。

Bogoを使っている場合、対象の言語を選択すると、ブログ上には該当言語の記事のみが表示されます。

日本人なので日本語がデフォルト表示になるように設定していますが、裏にはすべての記事の英語版も存在するわけです。

これを言語スイッチャーで切り替えて日本語版と英語版を管理している形。

当然内容は同じ記事なので、カテゴリーも同じというわけで、この場合に何が起こるかというと、純粋にカテゴリーリストの記事数が倍増します。

でも、記事数10って表示されていてカテゴリーページを見たら5ページしかないとか、正直なんじゃこれ問題なわけです。

なので、カテゴリー別に記事数を表示したくなるのは当然の欲求なわけです。

実際に同じ問題に悩んで、かつ解決されている方もいる

基本的にWordpressベースだと、たいていやりたいことはWebに転がっているものです。

探しますよね。

意外と需要が少ないのか、2記事だけ引っかかってきました。

【WordPress】多言語化(その3):カテゴリーの投稿数を言語別にする【Bogo】
その2の続き。英語記事も試験的に書いてみたり、ほぼ英語版の土台が出来た。ただ、1点だけどうしても直したい、気持ち悪い箇所がある。それは…ウィジェットに使用しているカテゴリーの投稿数が言語別にならない。どうやらBogoはそういう仕様だそうで。
Bogoでカテゴリーの投稿数のカウントを言語毎に行う
WordpressでBogoを使って多言語サイトを作成するとカテゴリーの記事数表示が現在の言語の記事だけでなく全ての言語の記事の総数になってしまう。それを修正するハックを紹介する。

お二方とも同じような実装で解決されたようなのですが、何故か私は上手くいかなかったので、最近ちょっと面白いなって思っているGeminiと戯れてみようと思い、お互い(?)協力しながら解決までたどり着きました。

ソースコードは最後に載せておきます。(2階層対応版)

最初の試み:フィルターフックと WP_Query

先駆者を参考に、wp_list_categories()にフィルターフックを施して各カテゴリーの投稿数を言語別に取得しようとしたんですが、なんかうまいことでない。

というか、倍増したまま変化がありませんでした。

最後までたどり着いた今思い返すと、コード自体は間違っていなかったんですが、この時点ではとりあえずコードのせいにしてGeminiへ相談したのです。

私

以下はphp のソースコードです。

wordpressのfunction.phpに記載する内容で、サイドバーのカテゴリーとして分類されている記事をまとめるコントロールに表示させる記事数を、言語別にカウントしたいと思っています。(参考先のブログのコードを貼り付ける)

Gemini
Gemini

なるほど、WordPressのサイドバーに表示されるカテゴリーリストで、各カテゴリーに属する記事数を言語別に表示したいのですね。現在のコードを拝見すると、基本的な処理はできていますが、いくつか改善点と考慮すべき点があります。

~ソースのサンプルが爆速で出力される~

吐き出された回答を見ると多言語プラグインを指定していなかったことに気づいて訂正しました。

私

bogoを使っているので訂正案をください

Gemini
Gemini

承知いたしました。Bogoプラグインをご利用とのことですので、BogoのAPIに合わせてコードを修正します。Bogoでは、投稿の言語情報はカスタムタクソノミー language で管理されるため、それを利用して言語別の投稿数をカウントする必要があります。

間違っているコード

<?php
/**
 * サイドバーのカテゴリー投稿数を言語別に表示する (Bogo対応)
 *
 * @param string $output HTML出力
 * @param array  $args   wp_list_categories() の引数
 * @return string フィルター後のHTML出力
 */
function wp_list_categories_by_lang_bogo( $output, $args ) {
    $lang = get_locale();
    $categories = get_categories( $args );
    $category_counts = array();

    if ( ! empty( $categories ) ) {
        $category_ids = wp_list_pluck( $categories, 'term_id' );
        $query = new WP_Query(
            array(
                'post_type' => 'post',
                'cat' => $category_ids,
                'tax_query' => array(
                    array(
                        'taxonomy' => 'language',
                        'field'    => 'slug',
                        'terms'    => $lang,
                    ),
                ),
                'posts_per_page' => -1,
                'fields' => 'ids',
            )
        );

        if ( $query->have_posts() ) {
            foreach ( $query->posts as $post_id ) {
                $post_categories = wp_get_post_categories( $post_id );
                foreach ( $post_categories as $cat_id ) {
                    if ( isset( $category_counts[ $cat_id ] ) ) {
                        $category_counts[ $cat_id ]++;
                    } else {
                        $category_counts[ $cat_id ] = 1;
                    }
                }
            }
        }
        wp_reset_postdata();

        // 出力されたHTMLをDOMDocumentで解析
        $dom = new DOMDocument();
        @$dom->loadHTML( mb_convert_encoding( $output, 'HTML-ENTITIES', 'UTF-8' ) );
        $xpath = new DOMXPath( $dom );
        $list_items = $xpath->query( '//li[contains(@class, "cat-item")]' );

        foreach ( $list_items as $li ) {
            $classes = $li->getAttribute( 'class' );
            preg_match( '/cat-item-(\d+)/', $classes, $match );
            if ( isset( $match[1] ) && isset( $category_counts[ $match[1] ] ) ) {
                $count_span = $dom->createElement( 'span', ' (' . $category_counts[ $match[1] ] . ')' );
                $anchor = $xpath->query( './a', $li )->item(0);
                if ( $anchor ) {
                    $anchor->appendChild( $count_span );
                }
            }
        }

        $output = $dom->saveHTML();
    }

    return $output;
}
add_filter( 'wp_list_categories', 'wp_list_categories_by_lang_bogo', 10, 2 );
?>

結果としては、非常にそれっぽいですがこのコードで私のやりたいことは実現できなかったのです。

Bogoプラグインのメタキーを活用

PHPのログを出力することで、発行されるクエリを確認することができてGeminiと解決の糸口を見つけました。

Gemini
Gemini

Bogoプラグインは、投稿の言語情報をカスタムメタフィールド _locale に保存していることが分かりました。そこで、WP_Querymeta_query を利用して、このメタキーに基づいて言語別の投稿数を取得する方法を試しました。

私

出力された。これだったか。

このあたりです。

ここで生成されたクエリがこんな感じで、条件句を読み解いてなんとかって感じ。

SELECT
	wp_posts.ID
  FROM
  	wp_posts
    	LEFT JOIN
			wp_term_relationships
			ON ( wp_posts.ID = wp_term_relationships.object_id )
		INNER JOIN
			wp_postmeta
			ON ( wp_posts.ID = wp_postmeta.post_id )
		LEFT JOIN
			wp_postmeta AS postmeta_bogo
			ON ( wp_posts.ID = postmeta_bogo.post_id AND postmeta_bogo.meta_key = '_locale' )
 WHERE
 	1=1
   AND ( wp_term_relationships.term_taxonomy_id IN ( 5,6,7,9,10,11,12 ) )
   AND (
   		( wp_postmeta.meta_key = '_locale' AND wp_postmeta.meta_value = 'ja' ) )
   AND (
   		(	wp_posts.post_type = 'post'
		AND ( wp_posts.post_status = 'publish' OR wp_posts.post_status = 'private' ) ) )
   AND ( 1=0 OR postmeta_bogo.meta_value LIKE 'ja' OR postmeta_bogo.meta_id IS NULL )
 GROUP BY wp_posts.ID
 ORDER BY wp_posts.post_date DESC

-- [28-Apr-2025 08:47:34 UTC] Found posts: 21

親カテゴリーの投稿数も考慮

元コードもそうっぽいんですが、これで出たはいいものの、親カテゴリーがどこかへ消え去ってしまっています。

というのも、Wordpressの場合基本的に記事に紐づいているは直接紐づけたカテゴリだけなので、そのカテゴリに親が居ようが居まいが関係ないんですよね。

なので、とりあえず親カテゴリが存在するかをチェックして親がいたら配列に追加する形でフォロー。

最後に、wp_list_categories() が出力するHTML構造を DOMDocumentDOMXPath で解析し、各カテゴリーの<li>タグ内の<a>タグに言語別の投稿数を<span>要素として追加する処理を実装しました。

最後のハマりポイント

これでできたと思ったんですが、Cocoonのウィジェット設定でカテゴリを出力している場合、勝手にCocoonが記事数を数えて出力してくれているので、(今回書いたコードはCocoonに実装されていないので)結局はカテゴリごとの記事数が2倍出力されてしまうという穴がありました。

なので、Cocoonの機能である「投稿数を表示」をチェック・オフに。

これでOK。

以上を持って最終的なコードは以下の通りとなりました。

<?php
/**
 * wp_list_categories の出力をフィルターし、言語別の投稿数を各カテゴリーに表示する
 *
 * @param string $output HTML形式のカテゴリーリスト
 * @param array $args wp_list_categories() に渡された引数の配列
 * @return string フィルター後のHTML形式のカテゴリーリスト
 */
function wp_list_categories_by_lang_bogo_meta( $output, $args ) {
    // 現在のロケール(言語設定)を取得
    $lang = get_locale();
    // wp_list_categories() で取得したカテゴリーリストを取得
    $categories = get_categories( $args );
    // 各カテゴリーの投稿数を格納する配列を初期化
    $category_counts = array();

    // カテゴリーリストが空でない場合
    if ( ! empty( $categories ) ) {
        // カテゴリーオブジェクトからタームIDの配列を取得
        $category_ids = wp_list_pluck( $categories, 'term_id' );
        // WP_Query を使用して、現在の言語に一致する投稿を取得
        $query = new WP_Query(
            array(
                'post_type' => 'post',
                'cat' => $category_ids, // 対象カテゴリーID
                'meta_query' => array(
                    array(
                        'key' => '_locale', // Bogoプラグインが使用する言語メタキー
                        'value' => $lang, // 現在の言語
                        'compare' => '=',
                    ),
                ),
                'posts_per_page' => -1, // 全ての投稿を取得
                'fields' => 'ids', // 投稿IDのみを取得
            )
        );

        // 投稿が存在する場合
        if ( $query->have_posts() ) {
            // 各投稿をループ処理
            foreach ( $query->posts as $post_id ) {
                // 投稿に紐づくカテゴリーIDの配列を取得
                $post_categories = wp_get_post_categories( $post_id );
                // 各カテゴリーIDをループ処理
                foreach ( $post_categories as $cat_id ) {
                    // 子カテゴリーの投稿数をカウント
                    if ( isset( $category_counts[ $cat_id ] ) ) {
                        $category_counts[ $cat_id ]++;
                    } else {
                        $category_counts[ $cat_id ] = 1;
                    }

                    // 親カテゴリーが存在する場合はその投稿数もカウント
                    $category = get_category( $cat_id );
                    if ( $category->parent != 0 ) {
                        $parent_id = $category->parent;
                        if ( isset( $category_counts[ $parent_id ] ) ) {
                            $category_counts[ $parent_id ]++;
                        } else {
                            $category_counts[ $parent_id ] = 1;
                        }
                    }
                }
            }
        }
        // WP_Query の情報をリセット
        wp_reset_postdata();

        // 出力されたHTMLをDOMDocumentで解析
        $dom = new DOMDocument();
        @$dom->loadHTML( mb_convert_encoding( $output, 'HTML-ENTITIES', 'UTF-8' ) );
        $xpath = new DOMXPath( $dom );

        // クラス名に "cat-item" を含むリストアイテムを取得
        $list_items = $xpath->query( '//li[contains(@class, "cat-item")]' );

        // 各リストアイテムをループ処理
        foreach ( $list_items as $li ) {
            // クラス名からカテゴリーIDを抽出
            $classes = $li->getAttribute( 'class' );
            preg_match( '/cat-item-(\d+)/', $classes, $match );
            // カテゴリーIDが抽出でき、かつそのIDの投稿数がカウントされている場合
            if ( isset( $match[1] ) && isset( $category_counts[ $match[1] ] ) ) {
                // 投稿数を表示する<span>要素を作成
                $count_span = $dom->createElement( 'span', ' (' . $category_counts[ $match[1] ] . ')' );
                // <a>タグの子要素を取得
                $anchor = $xpath->query( './a', $li )->item(0);
                // <a>タグが存在する場合、投稿数の<span>要素を追加
                if ( $anchor ) {
                    $anchor->appendChild( $count_span );
                }
            }
        }
        // 変更後のHTMLを保存
        $output = $dom->saveHTML();
    }

    // フィルター後のHTMLを返す
    return $output;
}
// wp_list_categories フィルターフックに上記関数を登録
add_filter( 'wp_list_categories', 'wp_list_categories_by_lang_bogo_meta', 10, 2 );

このコードをCocoonの子テーマが持っているfunction.phpへ書き込んで完成。

まとめ

相当時間はかかったけど、ハマった部分以外で考えるとほぼ(私は)ノーコードでこの機能が実装できたことに結構驚きを感じています。

何がすごいって、Geminiがちゃんとwordpressの標準機能を理解していて、かつデフォルトでないテーマを使用していることも理解し、デバッグログを食わせたら不都合がある部分を的確に指摘して追加の調査を私に指示。最終的には回答までたどり着かせたということ。

現時点で完成はしたけれど、正直言ってwordpressの標準機能のこととか全然知らないし、結果のコードを読んで何をしているかは理解できたけど、これを空で書けるほどPHPにも明るくない。

プログラムの基本的な知識は必要だけど、それさえあればやりたいことと調べる材料をくべてあげるだけでここまでのコードが生成されることはちょっと感動もの。

世間が騒ぐはずですね。

ちなみにこれ、記事書いていて気づいたんですが、カテゴリーが3回層以上あったら拾えないという罠付きコードなので、カテゴリーが深い方はちゃんと再帰するように組み直してくださいね。

といってみた。

私

今気づいたけど、カテゴリーが三回層以上あったら処理できないね。

親カテゴリーを処理している箇所を再帰してn階層でも処理できるように書き換えて

Gemini
Gemini

おっしゃる通りです!現在のコードでは、親カテゴリーの直下の階層までしか考慮されていません。3階層以上深くネストされたカテゴリー構造の場合、親の親、さらにその親…といった階層の投稿数は集計されません。

これを修正し、n階層のカテゴリー構造でも正しく投稿数を集計できるように、親カテゴリーを処理する部分を再帰関数に書き換えます。

再帰版のコードも載せておきます。

<?php
/**
 * wp_list_categories の出力をフィルターし、言語別の投稿数を各カテゴリーに表示する
 *
 * @param string $output HTML形式のカテゴリーリスト
 * @param array $args wp_list_categories() に渡された引数の配列
 * @return string フィルター後のHTML形式のカテゴリーリスト
 */
function wp_list_categories_by_lang_bogo_meta( $output, $args ) {
    // 現在のロケール(言語設定)を取得
    $lang = get_locale();
    // wp_list_categories() で取得したカテゴリーリストを取得
    $categories = get_categories( $args );
    // 各カテゴリーの投稿数を格納する配列を初期化
    $category_counts = array();

    // カテゴリーリストが空でない場合
    if ( ! empty( $categories ) ) {
        // カテゴリーオブジェクトからタームIDの配列を取得
        $category_ids = wp_list_pluck( $categories, 'term_id' );
        // WP_Query を使用して、現在の言語に一致する投稿を取得
        $query = new WP_Query(
            array(
                'post_type' => 'post',
                'cat' => $category_ids, // 対象カテゴリーID
                'meta_query' => array(
                    array(
                        'key' => '_locale', // Bogoプラグインが使用する言語メタキー
                        'value' => $lang, // 現在の言語
                        'compare' => '=',
                    ),
                ),
                'posts_per_page' => -1, // 全ての投稿を取得
                'fields' => 'ids', // 投稿IDのみを取得
            )
        );

        // 投稿が存在する場合
        if ( $query->have_posts() ) {
            // 各投稿をループ処理
            foreach ( $query->posts as $post_id ) {
                // 投稿に紐づくカテゴリーIDの配列を取得
                $post_categories = wp_get_post_categories( $post_id );
                // 各カテゴリーIDをループ処理
                foreach ( $post_categories as $cat_id ) {
                    // 現在のカテゴリーの投稿数をカウント
                    if ( isset( $category_counts[ $cat_id ] ) ) {
                        $category_counts[ $cat_id ]++;
                    } else {
                        $category_counts[ $cat_id ] = 1;
                    }

                    // 親カテゴリーの投稿数を再帰的にカウントする関数を呼び出す
                    count_parent_categories( $cat_id, $category_counts );
                }
            }
        }
        // WP_Query の情報をリセット
        wp_reset_postdata();

        // 出力されたHTMLをDOMDocumentで解析
        $dom = new DOMDocument();
        @$dom->loadHTML( mb_convert_encoding( $output, 'HTML-ENTITIES', 'UTF-8' ) );
        $xpath = new DOMXPath( $dom );

        // クラス名に "cat-item" を含むリストアイテムを取得
        $list_items = $xpath->query( '//li[contains(@class, "cat-item")]' );

        // 各リストアイテムをループ処理
        foreach ( $list_items as $li ) {
            // クラス名からカテゴリーIDを抽出
            $classes = $li->getAttribute( 'class' );
            preg_match( '/cat-item-(\d+)/', $classes, $match );
            // カテゴリーIDが抽出でき、かつそのIDの投稿数がカウントされている場合
            if ( isset( $match[1] ) && isset( $category_counts[ $match[1] ] ) ) {
                // 投稿数を表示する<span>要素を作成
                $count_span = $dom->createElement( 'span', ' (' . $category_counts[ $match[1] ] . ')' );
                // <a>タグの子要素を取得
                $anchor = $xpath->query( './a', $li )->item(0);
                // <a>タグが存在する場合、投稿数の<span>要素を追加
                if ( $anchor ) {
                    $anchor->appendChild( $count_span );
                }
            }
        }
        // 変更後のHTMLを保存
        $output = $dom->saveHTML();
    }

    // フィルター後のHTMLを返す
    return $output;
}
add_filter( 'wp_list_categories', 'wp_list_categories_by_lang_bogo_meta', 10, 2 );

/**
 * 親カテゴリーの投稿数を再帰的にカウントする関数
 *
 * @param int $cat_id 現在のカテゴリーID
 * @param array &$category_counts カテゴリーごとの投稿数を格納する配列(参照渡し)
 */
function count_parent_categories( $cat_id, &$category_counts ) {
    $category = get_category( $cat_id );
    if ( $category->parent != 0 ) {
        $parent_id = $category->parent;
        if ( isset( $category_counts[ $parent_id ] ) ) {
            $category_counts[ $parent_id ]++;
        } else {
            $category_counts[ $parent_id ] = 1;
        }
        // さらに親の親カテゴリーをカウントするために再帰呼び出し
        count_parent_categories( $parent_id, $category_counts );
    }
}

コメント

タイトルとURLをコピーしました