記事を読んだあとの行き先がない
管理しているサイトで、記事のページの作りを見直していたときのことです。このサイトは 1 ページに 1 つの用語の説明が並ぶ、用語集のような作りなのですが、記事を読み終えたところにほかの記事へのリンクがなく、読んだ人の次の行き先がありませんでした。似た項目へのリンクが下にあれば、続けて読んでもらえますし、検索エンジンのクロールもページの間をたどりやすくなります。
関連記事を出すプラグインもありますが、表示のたびに重い検索をするものや、出す記事が毎回変わるものもあります。そこで、選び方を決めてテーマの中に小さな機能として作り、記事の本文の下に 8 個のリンクを出すようにしました。
選び方の決まり
| 決まり | 理由 |
|---|---|
| 同じカテゴリー(いちばん上の親カテゴリー)の中から選ぶ | カテゴリー A の記事の下に、カテゴリー B の項目が出ないようにする |
| タイトルに同じ漢字が多いものを先にする | 同じ字を含む項目は、内容も近いことが多い |
| 足りない分は、記事の ID の組から決まる順で足す | 同じページは、いつも同じリンクになる |
| ランダムにしない | 表示のたびにリンクが変わると、クロールのたびに違うページへのリンクになり、たどり方が安定しない |
| 8 個まで | PC で 2 列・4 行に収まる数にした |
「同じ漢字が多い」は、タイトルから漢字だけを取り出し、重ならない並びにして、相手のタイトルの漢字と共通する数を数えています。形態素解析のような仕組みは使わず、文字を比べるだけなので軽く、用語のタイトルのように短い文字列では、これでも近いものがよく並びます。
作ったファイル
ファイルはテーマのフォルダの中の functions フォルダに置き、functions.php から require_once で読み込んでいます。記事の本文の下に出すため、single.php の本文のあとで my_the_related_items() を呼んでいます。実際のファイルから、読みの表示やテーマの設定に合わせた部分を外して短くしたものが次のコードです。
<?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 を指定しました。
.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 列にし、長いタイトルではみ出さないようにする


