MENU

問い合わせ


    【WordPressで作るキュレーションメディア 第7回】人気記事ランキングと関連記事を小さなプラグインで作る

    記事が増えてくると、「よく読まれている記事」や「この記事を読んだ人におすすめの記事」を表示して、読者に次の1本を見つけてもらいたくなります。WordPress には人気記事や関連記事のプラグインがたくさんありますが、多機能なものほどデータベースへの負荷や、更新が止まったときのリスクも抱えることになります。

    「人気記事ランキングと関連記事を出したい。プラグインを入れれば済む話? 自作するとしたら何に気をつければいい?」

    結論から言うと、閲覧数は「ページ表示後にブラウザから REST API へ送って数える」方式にし、ランキングと関連記事は結果をキャッシュする小さな動的ブロックとして作ると、ページキャッシュと両立でき、仕組みも把握しやすくなります。ただし、閲覧数は「おおよその人気」を知るためのもので、正確な数ではないという割り切りを最初に合意しておくことが大切です。

    前回の第6回では、著者・監修者のプロフィールを表示する仕組みを作りました。第7回の今回は、人気記事ランキングと関連記事をプラグインに追加します。

    目次

    今回のゴールと、閲覧数の数え方

    完成すると、記事ページの著者ボックスの下に次の2つが表示されます。

    • 関連記事:表示中の記事と同じ特集(第2回)の記事を優先し、足りなければ同じタグの記事で補って最大4件
    • よく読まれている記事:過去7日間の閲覧数が多い順に5件(ブロックの設定で「過去30日間」「累計」、件数も変更可)

    なぜ PHP の表示処理で数えないのか

    閲覧数を数える一番簡単な方法は、記事を表示する PHP の処理の中で数を1増やすことです。しかし、この方法はページキャッシュ(=一度作った HTML を保存して使い回し、表示を速くする仕組み)と相性がよくありません。キャッシュが効いていると PHP が実行されないため、閲覧数が増えなくなります。

    そこで今回は、次の流れにしました。

    手順どこで動くか内容
    1サーバー記事ページの HTML に、小さな JS と送信先 URL を埋め込む(キャッシュされても問題ない)
    2ブラウザページ表示後、JS が REST API(POST /wp-json/curation/v1/view/<記事ID>)に送信する
    3サーバー記事の存在・bot かどうか・同じ人の重複・回数制限を確認し、閲覧数を1増やす
    4サーバーランキングの表示時に閲覧数を集計し、結果を10分間キャッシュする

    JS は表示のたびにブラウザで実行されるので、ページキャッシュが効いていても数えられます。

    集計の「割り切り」

    この方式でも、閲覧数は正確な数にはなりません。次の点はあらかじめ割り切っています。

    • JS を実行しない閲覧(多くのクローラー、JS を無効にしている人)は数えない
    • 同じ人(同じ IP アドレス)が同じ記事を30分以内に再表示しても1回として数える。一方で、同じ回線を使う複数の人は1人として扱われることがある
    • 明らかな bot(User-Agent に bot や crawl などを含むもの)は除外するが、完全には見分けられない
    • 投稿を編集できるユーザー(編集部)の閲覧は数えない

    広告の課金や成果報告など、正確さが必要な数字には使わない前提です。正確なアクセス解析が必要なら、専用のアクセス解析ツールを併用してください。

    今回追加・変更したファイル

    ファイル内容
    プラグイン includes/ranking.php閲覧数の REST エンドポイント、集計、古いデータの削除(新規)
    プラグイン includes/related.php関連記事の取得とキャッシュ(新規)
    プラグイン assets/view-counter.js閲覧を送信する JS(ビルド不要。新規)
    プラグイン blocks/ranking/人気記事ランキングの動的ブロック(新規)
    プラグイン blocks/related/関連記事の動的ブロック(新規)
    プラグイン curation-tools.php読み込み・ブロック登録・無効化時の処理、バージョン 0.7.0
    テーマ parts/related-ranking.html関連記事と人気記事のテンプレートパーツ(新規)
    テーマ templates/single*.html・theme.json・style.cssパーツの追加と登録、バージョン 0.7.0

    データは第0回の方針どおり独自のテーブルを作らず、投稿メタに保存します。累計は _curation_views_total、日別は _curation_views_d_20250826 のように日付を含むキーにし、31日より古い日別データは毎日1回の定期処理で削除します。

    閲覧数を受け付ける REST エンドポイント

    wp-content/plugins/curation-tools/includes/ranking.php(抜粋)

    function curation_tools_register_view_route() {
    	register_rest_route(
    		'curation/v1',
    		'/view/(?P<id>\d+)',
    		array(
    			'methods'             => WP_REST_Server::CREATABLE,
    			'callback'            => 'curation_tools_rest_count_view',
    			// ログインしていない閲覧者から呼ばれる公開エンドポイント。
    			// 認証の代わりに、対象記事の検証・同一閲覧者の重複除外・回数制限・bot 除外を行う。
    			'permission_callback' => '__return_true',
    			'args'                => array(
    				'id' => array(
    					'type'     => 'integer',
    					'minimum'  => 1,
    					'required' => true,
    				),
    			),
    		)
    	);
    }
    add_action( 'rest_api_init', 'curation_tools_register_view_route' );

    閲覧者はログインしていないため、認証なしで呼べる公開エンドポイントにしています。WordPress では permission_callback を省略すると警告が出るため、公開であることを __return_true で明示し、その代わりに処理の中で入力を厳しく確認します。

    📰 出典:WordPress REST API Handbook「Adding Custom Endpoints」

    includes/ranking.php(数える処理の抜粋)

    $post_id = (int) $request['id'];
    $post    = get_post( $post_id );
    if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status || post_password_required( $post ) ) {
    	return new WP_Error( 'curation_invalid_post', '記事が見つかりません。', array( 'status' => 404 ) );
    }
    
    if ( curation_tools_is_bot( (string) $request->get_header( 'user_agent' ) ) ) {
    	return rest_ensure_response( array( 'counted' => false ) );
    }
    
    // IP アドレスはそのまま保存せず、サイト固有の鍵でハッシュ化した値だけを一時的に使う。
    $ip     = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
    $client = wp_hash( $ip );
    
    // 同じ閲覧者からのリクエストは、10分あたり60回までしか受け付けない。
    $budget_key = 'curation_view_budget_' . $client;
    $budget     = (int) get_transient( $budget_key );
    if ( $budget >= 60 ) {
    	return new WP_Error( 'curation_too_many_requests', 'リクエストが多すぎます。', array( 'status' => 429 ) );
    }
    set_transient( $budget_key, $budget + 1, 10 * MINUTE_IN_SECONDS );
    
    // 同じ閲覧者が同じ記事を30分以内に再表示しても数えない。
    $seen_key = 'curation_view_' . md5( $client . '|' . $post_id );
    if ( get_transient( $seen_key ) ) {
    	return rest_ensure_response( array( 'counted' => false ) );
    }
    set_transient( $seen_key, 1, 30 * MINUTE_IN_SECONDS );
    
    curation_tools_increment_post_meta( $post_id, CURATION_TOOLS_VIEWS_TOTAL_KEY );
    curation_tools_increment_post_meta( $post_id, CURATION_TOOLS_VIEWS_DAY_KEY . wp_date( 'Ymd' ) );

    確認の順番と理由は次のとおりです。

    • 公開中の記事だけを数える:存在しない ID、下書き、パスワード保護の記事は 404 を返します。存在しない ID で投稿メタが作られることもありません。
    • IP アドレスは保存しない:wp_hash()(=サイト固有の鍵を使ったハッシュ関数)で変換した値を、一時保存(transient)のキーにだけ使います。重複判定は30分、回数制限は10分で自動的に消えます。
    • 回数制限:同じ IP から短時間に大量に送られても、60回を超えると 429(リクエストが多すぎる)を返し、それ以上は処理しません。

    閲覧数の加算は、「値を読む → 1足す → 保存する」の3手順にすると、同時に閲覧があったときに数え漏れが起きやすくなります。そこで、SQL の UPDATE 文1回で加算しています。

    includes/ranking.php(加算処理の抜粋。コーディング規約チェック用の phpcs コメントを省略)

    $updated = $wpdb->query(
    	$wpdb->prepare(
    		"UPDATE {$wpdb->postmeta} SET meta_value = CAST( meta_value AS UNSIGNED ) + 1 WHERE post_id = %d AND meta_key = %s",
    		$post_id,
    		$key
    	)
    );
    if ( ! $updated ) {
    	add_post_meta( $post_id, $key, 1, true );
    }
    wp_cache_delete( $post_id, 'post_meta' );

    データベースを直接操作するときは、値を必ず $wpdb->prepare() で渡し、WordPress が持っているメタのキャッシュを wp_cache_delete() で消しておきます。

    ブラウザ側の JS

    assets/view-counter.js(冒頭のコメントを省略)

    ( function () {
    	const { navigator } = window;
    	const config = window.curationToolsViewCounter;
    	if ( ! config || ! config.endpoint ) {
    		return;
    	}
    	if ( navigator.sendBeacon ) {
    		navigator.sendBeacon( config.endpoint );
    		return;
    	}
    	window.fetch( config.endpoint, {
    		method: 'POST',
    		keepalive: true,
    		credentials: 'omit',
    	} );
    } )();

    navigator.sendBeacon() は、ページの表示を邪魔せずに小さなデータを送るためのブラウザの機能です。ビルドの必要がない短い JS なので、第4回の npm run build の対象には含めていません。読み込みは wp_enqueue_script() の strategy => 'defer'(=HTML の読み込みを止めずに実行する指定)で行い、記事ページかつ投稿の編集権限がないユーザーのときだけ出力します。

    📰 出典:MDN Web Docs「Navigator: sendBeacon() メソッド」

    期間別ランキングを集計してキャッシュする

    ランキングは、期間内の日別キーの閲覧数を合計し、多い順に並べます。

    includes/ranking.php(curation_tools_get_ranking() の抜粋。引数の検証と phpcs コメントを省略)

    $cache_key = 'curation_ranking_' . $period . '_' . $count;
    $cached    = get_transient( $cache_key );
    if ( is_array( $cached ) ) {
    	return $cached;
    }
    
    if ( 0 === $days[ $period ] ) {
    	$keys = array( CURATION_TOOLS_VIEWS_TOTAL_KEY );
    } else {
    	$keys = array();
    	for ( $i = 0; $i < $days[ $period ]; $i++ ) {
    		$keys[] = CURATION_TOOLS_VIEWS_DAY_KEY . wp_date( 'Ymd', time() - $i * DAY_IN_SECONDS );
    	}
    }
    
    global $wpdb;
    $placeholders = implode( ', ', array_fill( 0, count( $keys ), '%s' ) );
    $ids = $wpdb->get_col(
    	$wpdb->prepare(
    		"SELECT pm.post_id
    		FROM {$wpdb->postmeta} AS pm
    		INNER JOIN {$wpdb->posts} AS p ON p.ID = pm.post_id
    		WHERE pm.meta_key IN ( {$placeholders} )
    			AND p.post_type = 'post' AND p.post_status = 'publish' AND p.post_password = ''
    		GROUP BY pm.post_id
    		ORDER BY SUM( CAST( pm.meta_value AS UNSIGNED ) ) DESC, pm.post_id DESC
    		LIMIT %d",
    		array_merge( $keys, array( $count ) )
    	)
    );
    
    $ids = array_map( 'intval', $ids );
    set_transient( $cache_key, $ids, 10 * MINUTE_IN_SECONDS );
    return $ids;

    集計結果は transient(=有効期限つきの一時保存)に10分間保存します。ランキングは全ページの表示で使われるため、毎回集計するとデータベースの負荷になるからです。その代わり、ランキングへの反映は最大10分遅れます。

    📰 出典:WordPress Developer Resources「Transients API」

    表示は動的ブロック curation-tools/ranking の render.php で行い、記事タイトルとリンクを番号付きリストで出します。閲覧数の数値は表示していません。上で書いたとおり集計には割り切りがあり、数値を出すと正確な数字のように受け取られやすいためです。エディターでは ServerSideRender(=サーバーで作った表示をエディター内にそのまま見せる部品)を使い、期間と件数を変えるとその場でプレビューが変わるようにしました。

    📰 出典:Block Editor Handbook「@wordpress/server-side-render」

    古い日別データの削除は wp_schedule_event() で毎日1回実行し、プラグインを無効化したときは wp_clear_scheduled_hook() で定期処理を止めます。

    関連記事を同じ特集・タグから選ぶ

    関連記事は、まず同じ特集の記事を新しい順に探し、足りない分を同じタグの記事で補います。第2回で「特集は編集部の企画単位、タグはテーマ横断」と役割を分けたので、特集を優先したほうが関連の強い記事が並びます。

    includes/related.php(curation_tools_get_related_post_ids() の抜粋。phpcs コメントを省略)

    $version   = (int) get_option( 'curation_tools_related_version', 1 );
    $cache_key = sprintf( 'curation_related_%d_%d_v%d', $post_id, $count, $version );
    
    $cached = get_transient( $cache_key );
    if ( is_array( $cached ) ) {
    	return $cached;
    }
    
    $ids = array();
    foreach ( array( 'feature', 'post_tag' ) as $taxonomy ) {
    	$terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) );
    	if ( is_wp_error( $terms ) || empty( $terms ) ) {
    		continue;
    	}
    	$query = new WP_Query(
    		array(
    			'post_type'           => 'post',
    			'post_status'         => 'publish',
    			'has_password'        => false,
    			'posts_per_page'      => $count - count( $ids ),
    			'post__not_in'        => array_merge( array( $post_id ), $ids ),
    			'tax_query'           => array(
    				array(
    					'taxonomy' => $taxonomy,
    					'terms'    => $terms,
    				),
    			),
    			'fields'              => 'ids',
    			'no_found_rows'       => true,
    			'ignore_sticky_posts' => true,
    		)
    	);
    	$ids = array_merge( $ids, array_map( 'intval', $query->posts ) );
    	if ( count( $ids ) >= $count ) {
    		break;
    	}
    }
    
    set_transient( $cache_key, $ids, 12 * HOUR_IN_SECONDS );
    return $ids;

    結果は記事ごとに12時間キャッシュします。記事を追加・更新したときに古い関連記事が残り続けないよう、記事の保存・削除時に「版番号」(curation_tools_related_version)を1つ上げ、キャッシュのキーに含めています。版番号が変わると古いキャッシュは使われなくなり、有効期限が来た時点で WordPress が自動的に削除します。

    fields => 'ids'(ID だけを取得)と no_found_rows => true(総件数を数えない)は、一覧の表示に必要ない処理を省いて問い合わせを軽くする指定です。

    テンプレートに配置する

    2つのブロックはテンプレートパーツにまとめ、第6回の著者ボックスの下に置きました。

    wp-content/themes/curation-child/parts/related-ranking.html

    <!-- wp:group {"style":{"spacing":{"margin":{"top":"var:preset|spacing|50"},"padding":{"top":"var:preset|spacing|40"}},"border":{"top":{"color":"var:preset|color|accent-6","width":"1px"}}},"layout":{"type":"default"}} -->
    <div class="wp-block-group" style="border-top-color:var(--wp--preset--color--accent-6);border-top-width:1px;margin-top:var(--wp--preset--spacing--50);padding-top:var(--wp--preset--spacing--40)">
    	<!-- wp:curation-tools/related /-->
    
    	<!-- wp:curation-tools/ranking {"style":{"spacing":{"margin":{"top":"var:preset|spacing|40"}}}} /-->
    </div>
    <!-- /wp:group -->

    関連記事がない記事、閲覧データがまだない場合は、それぞれのブロックが何も出力しません。

    動作確認の方法

    第0回・第2回の手順(サンプル記事3本が作られます)の後、プラグインをビルドしてから確認しました(筆者の環境で確認済みです)。

    # 閲覧を送る(同じ IP から2回目は counted:false)
    curl -s -X POST -A 'Mozilla/5.0' http://localhost:8088/wp-json/curation/v1/view/4
    # 閲覧数を確認する
    docker compose run --rm wpcli wp eval 'var_dump( get_post_meta( 4, "_curation_views_total", true ) );'
    # 関連記事のキャッシュを確認する
    docker compose run --rm wpcli wp transient list --search='curation_related_*'
    • ブラウザ(ヘッドレスブラウザ、一般的なブラウザの User-Agent を指定)で記事を2回表示すると、1回目は {"counted":true}、2回目は {"counted":false} が返り、累計と当日の閲覧数がそれぞれ1になる
    • ヘッドレスブラウザ本来の User-Agent や、bot の User-Agent、空の User-Agent では数えない
    • 存在しない記事 ID と下書きの記事 ID は 404
    • 同じ IP から短時間に70回送ると、60回を超えた分が 429 になる
    • 日別データを用意して集計すると、過去7日間・過去30日間・累計で並び順がそれぞれ期待どおりに変わる。2回目の取得はキャッシュから返り、集計の問い合わせは実行されない
    • 記事ページの下部に関連記事とランキングが表示される。特集が同じ記事がない記事では、同じタグの記事が関連記事になる
    • 記事を更新すると版番号が上がり、関連記事が作り直される
    • 定期処理を手動実行(wp cron event run curation_tools_cleanup_daily_views)すると、31日より古い日別データだけが削除される
    • 管理者でログインして記事を表示すると、閲覧数送信の JS が出力されない。閲覧数のメタは REST API の記事データに含まれない

    つまずきやすい点・セキュリティ上の注意

    • CDN やロードバランサーの内側では IP アドレスが変わる:その場合 REMOTE_ADDR は利用者ではなく中継サーバーの IP になり、全員が同じ人として扱われます。サーバー構成に合わせて、信頼できる中継サーバーが付けるヘッダーから IP を取り出す設定が必要です。ヘッダーは偽装できるため、無条件に信用してはいけません。
    • 公開エンドポイントは「誰でも呼べる」前提で作る:記事 ID の検証、回数制限、書き込む内容を「数を1増やす」だけに限定することで、悪用されたときの影響を小さくしています。
    • 一時データも個人情報の扱いに注意:IP アドレスそのものは保存していませんが、閲覧の計測を行うことはプライバシーポリシーに記載しておきましょう。
    • オブジェクトキャッシュの有無で負荷が変わる:transient は、Redis などのオブジェクトキャッシュがない環境ではデータベース(options テーブル)に保存されます。アクセスが多いサイトでは、サーバー側のキャッシュも合わせて検討してください(第10回で扱います)。

    発注者向けメモ:人気記事は「正確さ」と「負荷」の割り切りを先に決める

    人気記事や関連記事は、既存のプラグインを使えばすぐに表示できます。一方で、多機能なプラグインには、データベースへの負荷が大きいもの、広告や外部サービスへの通信を含むもの、更新が止まってしまうものもあります。今回のような自作は数日規模の作業で済むことが多いですが、その前提として次の点を合意しておきましょう。

    • 閲覧数の正確さ:bot・キャッシュ・同じ回線の複数人など、どこまでを割り切るか。閲覧数の数字を画面に出すか
    • ランキングの反映の遅れ:キャッシュにより、何分遅れまで許容するか
    • 関連記事の選び方:特集・タグ・カテゴリのどれを優先するか。編集部が手動で指定する記事を混ぜるか
    • データの保存期間:日別データを何日分残すか。アクセス解析ツールとの役割分担

    工数が増えるのは、閲覧数を別のアクセス解析サービスから取り込みたい場合、読者ごとの閲覧履歴に基づくおすすめを出したい場合(個人データの扱いも含めて設計が必要)、大量アクセスに備えて専用のデータ保存先を用意する場合です。

    打ち合わせでは、次のように聞いてみてください。

    • 「ページキャッシュを使った場合でも、閲覧数は正しく増えますか? どういう仕組みで数えていますか?」
    • 「bot や同じ人の連続アクセスは、どのように除外していますか? 除外できないケースは何ですか?」
    • 「ランキングや関連記事の表示で、1ページあたりデータベースへの問い合わせはどのくらい増えますか?」

    まとめと次回予告

    第7回では、人気記事ランキングと関連記事を作りました。

    • 閲覧数はページ表示後にブラウザから REST API へ送り、ページキャッシュと両立させる
    • 公開エンドポイントは permission_callback で公開を明示し、記事の検証・重複除外・回数制限・bot 除外を行う
    • 日別の閲覧数は投稿メタに保存し、SQL の UPDATE で加算、古いデータは定期処理で削除する
    • ランキングは10分、関連記事は12時間キャッシュし、記事の更新時は版番号で作り直す
    • 閲覧数は「おおよその人気」を知るためのもので、正確さが必要な用途には使わない

    次回は「目次と構造化データ(JSON-LD: Article / BreadcrumbList / ItemList)」です。見出しから目次を作り、記事の情報を検索エンジンに正確に伝える構造化データを出力します。

    この連載の記事一覧

    この記事は連載「WordPressで作るキュレーションメディア」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次