WordPressで記事を投稿する際、その記事に付けた「タグ」それぞれに対して何か処理をしたくなるときって、あるよね。

俺にはあったんだよ!

ということで、そのために必要なアクションフックと、その使い方を今回調べてみた。

タグで「関連記事」を取得したい

このブログでは記事の下部に「関連記事」というのを設置している。

こうすれば、記事を読んでくれた人がさらに別の記事を読んでくれる……かもしれない。

検索エンジンから流入した訪問者が、帰ってしまう前にできるだけたくさんのページを見てくれることが、特に商用サイトでは重要なのだ。

……このブログは商用でもアフィブログでもないけど。

で、この「関連記事」とはどのように取得しているのか。

実はとてもシンプル、この記事と同じ「タグ」をもつ記事をリストアップしているのさ。

その、リストアップするためのアルゴリズムを最近改良してみた。

従来のアルゴリズムでは、

現在の記事が持つタグのうち、どれか一つでももつ記事

を投稿が新しい順に取得していたが、新しいアルゴリズムではこれに加え、

現在の記事と共通するタグを多く持つほど優先度が上がる

という仕様に変更しようとしたわけだ。

……どうやってこれを実装したものか……

各タグ毎に記事一覧を取得し、記事が出現する回数を数え、多い順に○件取り出す。

というふうに実装しようと思ったが、それではタグの数だけクエリを実行することになって処理が重すぎる。

そこで考えたのが、タグの「カスタムフィールド」にあらかじめ記事のリストを保存しておく、という方法だ。

タグのカスタムフィールドにアクセス

記事のカスタムフィールドと同じように、タグにもカスタムフィールドが存在するが、標準ではそれにアクセスすることはできない。

記事のカスタムフィールドと違って、タグの編集ページにはカスタムフィールドを追加・編集するフォームもないし、表示すらされないのでわかりにくい。

タグやカテゴリなどの「ターム」にカスタムフィールドが導入されたのはWordPress 4.4.0と、かなり最近のことだ。

かつてはカスタムオプション項目を追加してカスタムフィールドに代えていたから、多少は簡単になったわけだが。

タグのカスタムフィールドの読み込みには get_term_meta() 、追加には add_term_meta() 、上書きには update_term_meta() をそれぞれ用いる。

「あらかじめ取得しておく」タイミングとしては、タグを含む記事の一覧が変更されるのは「記事を投稿・更新する」タイミングだから、そのタイミングで実行されるアクションフックを利用して処理をすればいい。

アクションフックを探せ

ということで、記事を投稿・更新するのに使われる関数 wp_insert_post() に含まれるアクションフックを探す。

しかし、この関数に含まれるアクションフックはいずれもタグの情報を引き渡さない。

日本語、英語を問わずネットの情報を探し回ったが、類似の情報は見つからないし。

そこで視点を変えて、記事を投稿・更新する際に各タグを追加・削除するタイミングで実行されるアクションフックを使えばいいのではないか、と気付くのに1日かかった……

そのアクションフックが set_object_terms だ。

do_action( 'set_object_terms', int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids )

左右にスクロールして全体を見られるよ。

引数を六つも取るが、一つ目はターム(タグなど)を追加するオブジェクト(記事など)のID、二つ目は追加するタームの名前の配列、三つ目は追加するターム・タクソノミーIDの配列、四つ目は追加するタクソノミーのスラッグ、五つ目は以前のタームに追加する(true)か置き換える(false)か、六つ目は以前のターム・タクソノミーIDの配列、となっている。

なお、このアクションに関数をフックするには以下のように書くこと。

add_action( 'set_object_terms', '<関数名>', 10, 6 );

タグのカスタムフィールドに保存

それでは上記のアクションフックを使って、タグの記事一覧をコンマ区切りのIDのリストとしてカスタムフィールドに保存する。

テーマ関数ファイル(functions.php)に以下の記述を追加する。

function set_tag_post_list( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) {
    if ( $taxonomy !== 'post_tag' )
        return;
    $the_post = get_post($object_id);
    if ( $the_post->post_type === 'post' && ( $the_post->post_status === 'publish' || $the_post->post_status === 'private' ) ) {
        foreach ( $terms as $term ) {
            $tag_obj = get_term_by('name', $term, 'post_tag');
            if ( ! $tag_obj )
                continue;
            $tag_id = $tag_obj->term_id;
            $posts = get_posts(array( 'posts_per_page' => -1, 'tag_id' => $tag_id, 'order' => 'ASC', ));
            $post_ids = array();
            if ( $posts ) {
                foreach ( $posts as $value ) {
                    $post_ids[] = $value->ID;
                }
            }
            $post_ids[] = $the_post->ID;
            $post_ids = array_unique(array_map('intval', $post_ids));
            sort($post_ids);
            update_term_meta($tag_id, 'postlist', array(implode(',', $post_ids)));
        }
    }
}
add_action( 'set_object_terms', 'set_tag_post_list', 10, 6 );

2行目の if 文で、タグ以外のタームについては無視するようにする。

11行目の get_posts() が、タグの記事一覧を取得している部分だ。

13行目から17行目までのループで、記事のIDだけの配列を生成し、18行目で現在の記事のIDも追加、19行目でIDの重複を除去、20行目でソートしている。

そして21行目(下から5行目)で update_term_meta() によりタグのカスタムフィールドの「postlist」というキーにコンマ区切りの記事IDのリストを保存する。

タグのカスタムフィールドを可視化する

せっかくタグにカスタムフィールドを追加したのだから、ちゃんと追加されているのか見てみたくなるが、上述の通り標準ではこれを見ることができない。

一応、MySQLのデータベースに直接アクセスすれば見ることができるが……

タグの編集ページにカスタムフィールドを表示させてみよう。

これもテーマ関数ファイルに追加で。

function add_term_form_fields( $term ) {
    $term_id = $term->term_id;
    $term_meta = get_term_meta($term_id);
    if ( is_array($term_meta) ) {
        $cnt = 1;
        foreach ( $term_meta as $key => $value_array ) {
            foreach ( $value_array as $key2 => $value ) {
                $value = unserialize($value);
                if ( is_array($value) )
                    $value = implode(',', $value);
                if ( ! is_string($value) && ! is_int($value) )
                    continue;
                $name = 'term_meta['.$key.']['.$key2.']';
?>
<tr class="form-field term-meta-wrap">
    <th><label for="term_meta_<?php echo $cnt; ?>">カスタムフィールド:<br><code><?php echo $name; ?></code></label></th>
    <td><input id="term_meta_<?php echo $cnt; ?>" name="<?php echo $name; ?>" type="text" size="40" value="<?php echo esc_attr($value); ?>" /></td>
</tr>
<?php
                $cnt ++;
            }
        }
    }
}
add_action( 'post_tag_edit_form_fields', 'add_term_form_fields' );

途中、HTML部分(緑字部分)を含むので、いったんPHP部分を終了させる ?> やPHP部分を再開させる <?php を記述しているが、このコード全体は <?php?> の間に記述することを前提としているので注意。

最後の行にあるアクションフック post_tag_edit_form_fields が、タグの編集ページにフォーム部品を追加するアクションだ。

これで、タグの編集ページに既存のカスタムフィールドを表示するフォーム部品が追加される。

残念ながら、このフォームでは新たにカスタムフィールドを追加したり、既存のカスタムフィールドを削除したりすることはできない。

ただ、このフォームでは既存のカスタムフィールドを書き換えることはできる。

しかし、書き換えたカスタムフィールドを保存するにはもうひと工夫が必要になる。

以下の記述をテーマ関数ファイルに追加しよう。

function save_extra_term_fileds( $term_id ) {
    if ( isset($_POST['term_meta']) ) {
        foreach ( $_POST['term_meta'] as $key => $value ) {
            update_term_meta($term_id, $key, $value);
        }
    }/* else { // タグ編集(クイック編集)時にも取得
        $tag_obj = get_term_by('id', $term_id, 'post_tag');
        if ( $tag_obj ) {
            $posts = get_posts(array( 'posts_per_page' => -1, 'tag_id' => $term_id, 'order' => 'ASC', ));
            $post_ids = ',';
            if ( $posts ) {
                foreach ( $posts as $value ) {
                    $post_ids .= $value->ID.',';
                }
            }
            update_term_meta($term_id, 'postlist', array(trim($post_ids, ','))); 
        }
    }*/
}
add_action( 'edited_term', 'save_extra_term_fileds' );

これによりタグ編集ページで、一つ上のコードによりカスタムフィールドのフォーム部品を追加している場合のみ、保存時にカスタムフィールドも上書き保存するようになる。

最後の行のアクションフック edited_term は、タームを編集したときに実行されるものである。

また、コメントアウト部分(灰色部分)のコメントアウトを解除すれば、タグをクイック編集で保存した場合に、そのタグを含む記事のID一覧をコンマ区切りで取得するという、二つ上のコードと同様の処理を行えるようになる。

既存の記事が多い場合、過去の記事すべてに対して上記の処理を行うのは大変なので、初回はこちらの方法で処理すればいいのではないだろうか。

タグのカスタムフィールドを取得する

当初の目的通り、記事ページにおいて各タグのカスタムフィールドに保存された記事IDのリストを取得するには、以下のようにする。

$the_tags = get_the_terms(get_the_ID(), 'post_tag');
if ( $the_tags ) {
    $related = array();
    foreach ( $the_tags as $value ) {
        $merge = reset(get_term_meta($value->term_id, 'postlist', true));
        $merge = explode(',', $merge);
        $merge = array_unique(array_map('intval', $merge));
        $related = array_merge($related, $merge);
    }
}

1行目で記事に含まれるタグの一覧を取得。

5行目でタグのカスタムフィールドのキー「postlist」の値を取得。

6行目で、コンマ区切りのIDリストを配列の要素に分割。

7行目でIDの重複を削除。カスタムフィールドの保存時にも重複を削除しているが、念のため。

8行目で、タグ毎のループで取得した配列を以前のループのものに結合し、まとめる。

the_tags() とかでタグのリンクを取得してるテーマが多いだろうけど、タグの一覧を二度取得するのは無駄が大きいので、このように取得したタグの一覧 $the_tags からリンクを出力するようにしたい。

記事IDのリストから「関連記事」を取得

ここからはこの記事の主題からは外れるのでおまけだが、一つ上のコードにより取得した記事IDのリストをサクッと処理してみる。

$related_self = array_keys($related, get_the_ID(), true);
foreach ( $related_self as $key ) {
    unset($related[$key]); // 現在の投稿を除外
}
if ( $related && count($related) ) {
    $related_post = array_count_values($related); // 出現数を取得
    uksort($related_post, function ($a, $b) use ($related_post) {
        if ( $related_post[$a] === $related_post[$b] )
            return $a < $b ? 1 : -1; // 投稿IDでソート(降順)
        else
            return $related_post[$a] < $related_post[$b] ? 1 : -1; // 出現数でソート(降順)
    });
    $related_post = array_slice($related_post, 0, 6, true); // 最大6件に制限
    foreach ( $related_post as $post_id => $count ) {
        /* ループ処理 */
    }
}

ポイントは6行目の array_count_values() で、複数のタグに出現した投稿の出現数を数える。

この関数の返り値は、元の配列の値(投稿ID)をキーとし、出現数を値とする配列となる。

また、7行目から12行目ではユーザー定義関数を用いて配列をソートする関数 uksort() を使用している。

上記では無名関数(クロージャー)をその場で定義している。

この関数で用いるユーザー定義関数にはいくつかの決まりがあり、理解するのも説明するのも大変なので、まあこんなものにしておいてほしい。

ちなみに引数の $a はソート対象の配列の「とある左の要素のキー」で、 $b は「とある右の要素のキー」であり、返り値は左を先にソートしたい場合は負の数、右を先にソートしたい場合は正の数、というふうにする……ほら、わかりにくいだろう?

まとめ

投稿の保存時に各タグに対し、何らかの処理をしたい場合、アクションフック set_object_terms を使おう。

このあたり、情報が少なかったので書き残しておく。

……情報が少ないのは需要がないからなのかな……

さて、今回の検証をもとに「関連記事」の新しいアルゴリズムを作るとするか。

うん、もうひと工夫しようとして、また詰まってるんだ……

頑張ろ。