記事の下に関連の項目を自動で出す:プラグインを使わずテーマで作る

広告WordPress・PHP を学べる本

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

記事を読んだあとの行き先がない

管理しているサイトで、記事のページの作りを見直していたときのことです。このサイトは 1 ページに 1 つの用語の説明が並ぶ、用語集のような作りなのですが、記事を読み終えたところにほかの記事へのリンクがなく、読んだ人の次の行き先がありませんでした。似た項目へのリンクが下にあれば、続けて読んでもらえますし、検索エンジンのクロールもページの間をたどりやすくなります。

関連記事を出すプラグインもありますが、表示のたびに重い検索をするものや、出す記事が毎回変わるものもあります。そこで、選び方を決めてテーマの中に小さな機能として作り、記事の本文の下に 8 個のリンクを出すようにしました。

選び方の決まり

決まり 理由
同じカテゴリー(いちばん上の親カテゴリー)の中から選ぶ カテゴリー A の記事の下に、カテゴリー B の項目が出ないようにする
タイトルに同じ漢字が多いものを先にする 同じ字を含む項目は、内容も近いことが多い
足りない分は、記事の ID の組から決まる順で足す 同じページは、いつも同じリンクになる
ランダムにしない 表示のたびにリンクが変わると、クロールのたびに違うページへのリンクになり、たどり方が安定しない
8 個まで PC で 2 列・4 行に収まる数にした

「同じ漢字が多い」は、タイトルから漢字だけを取り出し、重ならない並びにして、相手のタイトルの漢字と共通する数を数えています。形態素解析のような仕組みは使わず、文字を比べるだけなので軽く、用語のタイトルのように短い文字列では、これでも近いものがよく並びます。

作ったファイル

ファイルはテーマのフォルダの中の functions フォルダに置き、functions.php から require_once で読み込んでいます。記事の本文の下に出すため、single.php の本文のあとで my_the_related_items() を呼んでいます。実際のファイルから、読みの表示やテーマの設定に合わせた部分を外して短くしたものが次のコードです。

/wp-content/themes/(テーマのフォルダ)/functions/related-items.php
<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'MY_RELATED_NUM', 8 );
define( 'MY_RELATED_KEY', 'my_related_pool_' );

// タイトルの中の漢字(重ならない並び)
function my_related_kanji( $text ) {
    if ( ! preg_match_all( '/[\x{3400}-\x{4DBF}\x{4E00}-\x{9FFF}\x{F900}-\x{FAFF}]/u', (string) $text, $m ) ) {
        return array();
    }
    return array_values( array_unique( $m[0] ) );
}

// 記事のいちばん上の親カテゴリーの ID
function my_related_top_category( $post_id ) {
    $terms = get_the_category( $post_id );
    if ( ! $terms ) {
        return 0;
    }
    $term = $terms[0];
    for ( $i = 0; $i < 5 && $term->parent; $i++ ) {
        $parent = get_term( $term->parent, 'category' );
        if ( ! $parent instanceof WP_Term ) {
            break;
        }
        $term = $parent;
    }
    return (int) $term->term_id;
}

// カテゴリーごとの一覧(ID・タイトル・タイトルの漢字)を 1 日ためる
function my_related_pool( $top_id ) {
    $key  = MY_RELATED_KEY . $top_id;
    $pool = get_transient( $key );
    if ( is_array( $pool ) ) {
        return $pool;
    }
    global $wpdb;
    $term_ids = array_map( 'intval', array_merge( array( $top_id ), (array) get_term_children( $top_id, 'category' ) ) );
    $in       = implode( ',', $term_ids ); // 整数だけ
    $rows     = $wpdb->get_results(
        "SELECT DISTINCT p.ID, p.post_title
         FROM {$wpdb->posts} p
         INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
         INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
         WHERE p.post_type = 'post' AND p.post_status = 'publish'
           AND tt.taxonomy = 'category' AND tt.term_id IN ({$in})
         ORDER BY p.ID ASC"
    );
    $pool = array();
    foreach ( (array) $rows as $r ) {
        $pool[] = array( (int) $r->ID, (string) $r->post_title, my_related_kanji( $r->post_title ) );
    }
    set_transient( $key, $pool, DAY_IN_SECONDS );
    return $pool;
}

// 記事を保存・削除したら、ためた一覧を消す
function my_related_clear_pools( $post_id = 0 ) {
    if ( $post_id && 'post' !== get_post_type( $post_id ) ) {
        return;
    }
    $tops = get_terms( array( 'taxonomy' => 'category', 'parent' => 0, 'hide_empty' => false, 'fields' => 'ids' ) );
    if ( is_wp_error( $tops ) ) {
        return;
    }
    foreach ( $tops as $top_id ) {
        delete_transient( MY_RELATED_KEY . (int) $top_id );
    }
}
add_action( 'save_post', 'my_related_clear_pools' );
add_action( 'before_delete_post', 'my_related_clear_pools' );

// 関連の項目を選ぶ。array( array( ID, タイトル ), ... )
function my_related_items( $post_id, $num = MY_RELATED_NUM ) {
    $post_id = (int) $post_id;
    $top_id  = my_related_top_category( $post_id );
    if ( ! $top_id ) {
        return array();
    }
    $mine   = my_related_kanji( get_post_field( 'post_title', $post_id ) );
    $scored = array();
    foreach ( my_related_pool( $top_id ) as $item ) {
        if ( $item[0] === $post_id ) {
            continue;
        }
        $scored[] = array(
            'score' => $mine ? count( array_intersect( $mine, $item[2] ) ) : 0,
            'order' => crc32( $post_id . ':' . $item[0] ), // 表示のたびに変わらない順
            'item'  => $item,
        );
    }
    usort(
        $scored,
        function ( $a, $b ) {
            if ( $a['score'] !== $b['score'] ) {
                return $b['score'] - $a['score'];
            }
            return $a['order'] <=> $b['order'];
        }
    );
    $out = array();
    foreach ( array_slice( $scored, 0, $num ) as $s ) {
        $out[] = array( $s['item'][0], $s['item'][1] );
    }
    return $out;
}

// 記事の本文の下に出す(single.php から呼ぶ)
function my_the_related_items() {
    if ( ! is_singular( 'post' ) ) {
        return;
    }
    $items = my_related_items( get_the_ID() );
    if ( ! $items ) {
        return;
    }
    $li = '';
    foreach ( $items as $it ) {
        $li .= '<li><a href="' . esc_url( get_permalink( $it[0] ) ) . '">' . esc_html( $it[1] ) . '</a></li>';
    }
    echo '<div class="my-related"><h3 class="my-related__title">関連の項目</h3><ul class="my-related__list">' . $li . '</ul></div>' . "\n";
}
書き方 役目
preg_match_all( '/[\x{4E00}-\x{9FFF} …]/u', … ) タイトルから漢字だけを取り出す。array_unique() で同じ字は 1 つにする
my_related_top_category() 記事のカテゴリーから親をたどり、いちばん上のカテゴリーの ID を返す
get_transient()・set_transient( …, DAY_IN_SECONDS ) カテゴリーごとの記事の一覧(ID・タイトル・漢字)を 1 日ためる。ためた一覧があれば DB を読まない
$in は intval 済み SQL に入れるのは整数だけなので、そのまま IN ( … ) に書ける
add_渡すion( 'save_post', … ) 記事を保存・削除したら、ためた一覧を消す。次に表示したときに作り直す
array_intersect() 自分のタイトルと相手のタイトルで、共通する漢字の数を数える
crc32( $post_id . ':' . $item[0] ) 数が同じときの順番。記事の ID の組から決まるので、表示のたびに変わらず、記事ごとには違う並びになる
esc_url()・esc_html() リンクとタイトルを、HTML に出す形にしてから出す

一覧を transient にためる理由

表示のたびに同じカテゴリーの記事をすべて読み直すと、記事が千件を超えるサイトでは、そのぶん DB への問い合わせが重くなります。選ぶのに使うのは ID とタイトルだけなので、カテゴリーごとにまとめて 1 回だけ読み、transient に 1 日ためるようにしました。2 回目からは、ためた一覧の中で数を数えて並べ替えるだけです。

記事を足したり、タイトルを直したりしたときに古い一覧が残らないよう、save_post と before_delete_post で一覧を消しています。どのカテゴリーの一覧かを調べずにいちばん上のカテゴリーの分をすべて消す作りですが、親カテゴリーは数が少ないので、これで十分でした。

PC は 2 列、スマホは 1 列

リンクは ul に並べ、CSS のグリッドで PC は 2 列、幅の狭い画面では 1 列にしています。長いタイトルでページが横にはみ出さないよう、minmax( 0, 1fr ) と overflow-wrap: anywhere を指定しました。

/wp-content/themes/(テーマのフォルダ)/style.css(足した部分)
.my-related__list {
    list-style: none;
    margin: 0;
    padding: 14px 24px;
    border: 1px solid #ccc;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: 24px;
}
.my-related__list li {
    margin: 0;
    padding: 6px 0;
    border-bottom: 1px dotted #ddd;
    min-width: 0;
    overflow-wrap: anywhere;
}
@media only screen and (max-width: 767px) {
    .my-related__list {
        grid-template-columns: minmax(0, 1fr);
        padding: 10px 16px;
    }
}

確認した結果

本番から複製した作業環境で、検証用スクリプトから WordPress を読み込み、公開中の記事 6 件で次のことを確認しました。時間は、選ぶ関数を呼ぶ前と後の microtime() の差です。

確認したこと 結果
出る数 6 件とも 8 個
同じ記事で 2 回呼んだときのリンク 6 件とも同じだった
タイトルに同じ漢字を含む項目の数(8 個のうち) 記事によって 1〜8 個。共通の字が少ない記事では、決まった順で足した項目が多くなる
ためた一覧があるときの時間(実際のテーマの関数) 1 回あたり 0.66〜0.82ms
ためた一覧がないときの 1 回目 一覧を作るので遅くなる。作業環境では DB への初めの接続を含めて 400ms ほどだった
この記事の例のコードと、実際のテーマの関数の結果 6 件とも、同じ ID が同じ順で出た
my_related_clear_pools() を呼ぶ ためた一覧が消えた

作業環境のページを HTTP で開いても、記事の下に 8 個のリンクが出て、2 回開いて同じ HTML になることを確認しました。PC で 2 列、幅 390px で 1 列になり、ページが横にはみ出さないことはブラウザで見ています。試しで作られた transient は、確認のあとに消しました。

気をつけること

  • ページキャッシュを使っているサイトでは、記事を足したあと、ほかの記事の下のリンクが変わるのはキャッシュが消えてから
  • カテゴリーの中の記事が非常に多いと、ためる一覧も大きくなる。transient は options テーブルに入るので、大きさを見ておく
  • 前後の記事へのリンクを出すテーマなら、同じ記事が 2 回並ばないよう、そのリンク先を候補から外すとよい(実際のファイルでは外している)
  • テーマに追加しているので、テーマを切り替えると出なくなる

まとめ

  • 関連の項目は、同じ親カテゴリーの中から、タイトルに同じ漢字が多いものを先に選ぶと、軽い処理で近い項目が並ぶ
  • 足りない分は記事の ID の組から決まる順で足し、同じページはいつも同じリンクにする
  • カテゴリーごとの一覧は transient に 1 日ため、記事の保存で消す。ためた一覧があれば、作業環境で 1 回 1ms 以下だった
  • CSS のグリッドで PC は 2 列、スマホは 1 列にし、長いタイトルではみ出さないようにする

参考

関連機能

  1. WordPress のユーザーの編集画面に項目を足す:所属店舗などをプロフィールに保存して表示する

  2. ダッシュボードに自由に使えるメモ用エリアを追加

  3. WordPressランダムに1件記事を表示させるショートコード ( カスタム投稿、任意の件数に対応 )

サイドバー

よく使うカスタマイズ

最近の記事

Profile

PAGE TOP