ブログ用タグ: WordPress

  • フッタの著作権表示

    現在使用している TwentyTwenty テーマは、標準ではフッタに次のようなクレジットを表示する。

    <div class="footer-credits">
    	<p class="footer-copyright">&copy;2020年
    		<a href="https://listfreak.com/">*ListFreak</a>
    	</p><!-- .footer-copyright -->
    	<p class="powered-by-wordpress">
    		<a href="https://ja.wordpress.org/">Powered by WordPress</a>
    	</p><!-- .powered-by-wordpress -->
    </div><!-- .footer-credits -->
    

    ここから “Powered by WordPress” を除き、”©2020年” を “© 2005″(2005は当サイトの開始年)とする変更を行った。

    今後テーマを変える可能性もあるので、テーマファイルに手を入れない方法を探した。この部分は wp_footer() から呼び出されるが、テンプレートファイルのHTMLを書き換えられるようなフックはない。そこでJavaScriptで書き換えることにした。

    add_action( 'wp_footer', 'kh_footer', 22 );
    function kh_footer() {
    ?>
    <script type="text/javascript">
    document.querySelector("p.footer-copyright").firstChild.data = "© 2005 ";
    document.querySelector("p.powered-by-wordpress").textContent = "";
    </script>
    <?php
    }
    • wp_footer フックの中では優先度低めの22に設定。
    • ”&copy;2020年” の部分は要素(Element)ノードでなくテキストノード。よって firstElementChild でなく firstChildでアクセスする。
  • フロントページのタイトルにリスト件数などを載せる

    概要

    フロントページのタイトルを書き換えてリスト件数タグ個数、引用している書籍数を載せる。

    詳細

    フロントページのタイトルを書き換える

    タイトルを取得する get_the_title() に仕込まれている ‘the_title’ フィルターを使う。get_the_title() はメニューやウィジェット中の記事タイトルなどでも呼ばれまくっていて ‘the_title’ フィルターを使うコストは高そう。フロントページの記事タイトルだけを(テーマファイルなどに手を加えずに)動的に変える方法があればよいのだが思いつかず。

    フロントページを処理中であるかどうかは通常 is_front_page() を使うが、フロントページを描画する過程でも get_the_title() が複数呼ばれる。そこで ‘the_title’ フィルターに渡される投稿 ID が get_option( ‘page_on_front’ ) で取得できるフロントページ用の固定ページ ID と同一かどうかで識別する。

    リスト件数を載せる

    投稿 (post) の中で投稿ステータスが publish かつカテゴリが list であるものの数を数えればよいのだが、妥当な方法を思いつくまでに意外に時間がかかった。

    WP_Query() を使うと莫大な数の投稿オブジェクトが作られてしまう。特定の投稿ステータスを持った投稿の数カテゴリの数を個別に得る関数はあるが、それらを組み合わせて投稿数だけを得る負荷の低そうな関数が見当たらず。WP_Query() 中にあるフックを活用して SELECT 文に COUNT を追加する新しいクラスを作る事例もあったが、あまりにも大がかり。

    ただ調べてみると、WP_Query() は 1 回のクエリで取得する投稿オブジェクトの数にかかわらず総件数を取得している(ページネーションする場合総ページ数が必要なため)。なので、取得件数を 1 として WP_Query() を呼べばよいだけであった。

    タグ個数、引用している書籍数を載せる

    wp_count_terms() でOK。正確にはプライベート投稿にのみ属するタグや書籍を除く必要があるが、ほぼゼロであることはわかっているので省略。

    add_filter( 'the_title', 'kh_frontpage_title', 10, 2 );
    function kh_frontpage_title( $title, $id ) {
    	// フロントページ用の固定ページのタイトルであれば書き換える
    	if( $id == get_option( 'page_on_front' ) ) {
    		// posts_per_page => 1 とすれば戻り値に巨大な配列を得ることなく合計件数だけを得られる
    		$args = array(
    			'post_type'      => 'post',
    			'post_status'    => 'publish',
    			'category_name'  => 'list',
    			'posts_per_page' => 1,
    		);
    
    		$q = new WP_Query( $args );
    
    		$title = sprintf(
    			'%s のリスト<br><span style="font-size:smaller; font-weight:normal;">%s 冊の本と %s 個のタグを添えて</span>',
    			$q->found_posts, wp_count_terms( 'asin' ), wp_count_terms( 'post_tag' )
    		);
    	}
    	return $title;
    }
  • 「最近読まれているリスト」機能の実装

    最近アクセスの多いリストを一覧表示するウィジェットとアーカイブページを作る。

    人気のあるリストを紹介する場合、総アクセス数で測ると表示されるリストが固定的になるうえ、露出が高まることでアクセス数が増えるのでアクセス数の差が助長される。逆に直近の24時間など短い時間のアクセス数では変動が大きく、人気のあるリストとは言い難くなる場合もある。そこで直近7日間のアクセス数の多いリストを表示させることにした。


    直近7日間のアクセス数を記録する

    WordPress は投稿ごとのアクセス数を記録しないので、count_history というカスタムフィールドを作り、直近1週間のアクセス数を保存する。たとえば今日が 2021/6/2 とすると次のような配列として保存される。

    Array
    (
        [20210602] => 10
        [20210601] => 20
        [20210531] => 30
        [20210530] => 40
        [20210529] => 30
        [20210528] => 20
        [20210527] => 10
    )

    そのうえで lf_counter という別のカスタムフィールドに合計値(上の例でいえば 160)を保存する。具体的な処理は次の通り。

    $count_history = get_post_meta( get_the_ID(), 'count_history', true );
    
    // アクセス履歴あり
    if( is_array($count_history) ) {
    	// 7日前の日付を取得
    	$expdate = (int)wp_date( 'Ymd', strtotime( '-7 days' ) );
    	foreach( $count_history as $date => $count ){
    		// 7日前以前の記録を削除
    		if( $date <= $expdate ) {
    			unset( $count_history[$date] );
    		}
    	}
    // アクセス履歴なし
    } else {
    	$count_history = array();
    }
    
    // カウンタをインクリメント
    $today = (int)wp_date( 'Ymd' );
    ( isset($count_history[$today]) ) ? $count_history[$today]++ : $count_history[$today] = 1;
    
    // カウント履歴をカスタムフィールドに保存
    update_post_meta( get_the_ID(), 'count_history', $count_history );
    
    // 合計カウント数をカスタムフィールドに保存
    update_post_meta( get_the_ID(), 'lf_counter', array_sum( $count_history ) );
    

    カウント処理は、ページ表示処理の終盤に低い優先度で実行させる。

    add_action( 'wp_footer', 'kh_access_count', 30 );
    function kh_access_count() {
    
        /*
         * 除外するアクセスを識別する
         * and は最初の式がfalseなら次を評価しないのでorより高速
         */
        if ( is_single() // 投稿
          && has_category( 'list' ) // list カテゴリに属している
          && and ! current_user_can( 'administrator' ) // 管理者のアクセスでない
        ) {
            // 通過
        } else {
            return;
        }
    
        // 簡易 Crawler 除外
        // bot, bots, spider で終わる単語が入っていれば Crawler と判断
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
        if ( preg_match( '/.*(bots?|spider)\b/i', $ua ) ) {
            return;
        }
    
        /*
         * カウントする
         */
        (上述の処理)
    
    }

    直近7日間のアクセス数が多いリスト10個をキャッシュし、1時間ごとに更新する

    上述のように、リスト投稿ごとのアクセス数はカスタムフィールドに持たせている。全リスト投稿をカスタムフィールドの値でソートする処理は重そうなのでキャッシュし、1時間ごとに更新することにした。

    キャッシュの更新(スケジュール処理)と取得(ウィジェット表示)で同じ関数を使えるようにするため、下記のような関数を作成。

    function lf_get_popular_lists( $use_cache = true ) {
    	
    	if ( $use_cache ) {
    		$out = get_transient( 'LF_POPULAR_LISTS' );
    		if( false !== $out ) {
    			return $out;
    		}
    	}
    
    	// アクセスカウントを多い順に10取得
    	$q = new WP_Query( array(
    		'post_type'      => 'post',
    		'category_name'  => 'list',
    		'post_status'    => 'publish',
    		'posts_per_page' => 10,
    		'meta_key'       => 'lf_counter',
    		'orderby'        => 'meta_value_num',
    		'order'          => 'DESC',
    		'fields'         => 'ids',
    	) );
    
    	$lists = '<ul>';
    	foreach( $q->posts as $id ){
    		$lists .= '<li><a href="'.get_post_permalink( $id ).'">'.get_the_title( $id ).'</a></li>'.PHP_EOL;			
    	}
    	$lists .= '<li><a href="'.home_url( '/category/list?orderby=popthisweek' ).'">(もっと見る)</a></li>'.PHP_EOL;			
    	$lists .= '</ul>';
    
    	// Transientにキャッシュ
    	set_transient( 'LF_POPULAR_LISTS', $lists );
    }

    この関数を1時間ごとに $use_cache = false で呼び出すようスケジュール登録した。

    if ( ! wp_next_scheduled( 'lf_event_popular_lists', array( false ) ) ) {
        if ( ! $ret = wp_schedule_event( time(), 'hourly', 'lf_event_popular_lists', array( false ), true ) ) {
            // エラー処理
        }
    }
    
    add_action( 'lf_event_popular_lists', 'lf_get_popular_lists', 10, 1 );

    キャッシュした10リストを表示するウィジェットを作成する

    カスタムウィジェットは WP_Widget クラスを継承して作成する必要があったが、最近ではブロックエディタから登録できるようになったので、ショートコードを作った。

    add_shortcode( 'lf_popular_lists' , 'lf_popular_lists' );
    function lf_popular_lists( $args ){
    
        $use_cache = true;
        $out = lf_get_popular_lists( $use_cache );
    
        return $out;
    }

    アーカイブページをアクセスカウント順に表示する

    ウィジェットは10個だけだが、すべてのリストをアクセス数の多い順に並べて見られる機能もつけた。これはカテゴリアーカイブページを流用した。具体的には、

    • <ROOT>/category/list → 通常の新着順
    • <ROOT>/category/list?orderby=popthisweek → 直近7日間のアクセス数順

    とする。以下は Code Snippet に登録したスニペット。

    // /category/list で始まる場合のみ読み込む
    if ( 0 !== strpos( $_SERVER['REQUEST_URI'], '/category/list' ) ) {
        return;
    }
    
    add_action( 'pre_get_posts', 'lf_pre_get_posts_list', 10, 1 );
    function lf_pre_get_posts_list( $query ) {
    
        if ( $query->is_main_query() ) {
    
            if ( 'popthisweek' == get_query_var( 'orderby', '' ) ) {
    
                $query->set( 'meta_key', 'lf_counter' );
                $query->set( 'orderby',  'meta_value_num date' );
                $query->set( 'order',    'DESC');
            }
        }
    }

    Code Snippet は Snippet 全体を eval() するので、最初の if 文のように return するとそれ以降のコードは実行されない。アクションフック ‘pre_get_posts’ はほぼ全ページで実行され得るので、当該ページの表示時のみコールバックを登録するようにした。

  • WordPress: LOTD の仕組み

    概要

    公開されたリスト投稿から適切なリストランダムに選んで LOTD とし、1日に1回配信する。

    詳細

    公開されたリスト投稿 とは……

    公開されたリスト投稿とは、次の条件をすべて満たす投稿(記事)。

    • 投稿タイプ: 投稿 (post)
    • カテゴリ: list
    • ステータス: 公開

    適切なリスト とは……

    適切なリストとは、公開されたリスト投稿のなかで次の条件をすべて満たす投稿。

    • 書籍から作成されている
      • 書籍が引用されていないリストは自作リストやまとめリストなど配信向きでないものが多いと考えたため。
      • システム的には “ASIN” タクソノミーのタームが関連付けられているリスト。
    • これまでにLOTDになった回数が少ない
      • 多様なリストを配信させたいため。
      • システム的には、LOTDとして選ばれた回数を投稿のカスタムフィールド lotd_count に保存し、最小回数のリストから選択する。
    • 運営者が配信向きだと判断した
      • 上記の条件を満たしていても、配信向きでないと判断したリストは配信しないようにする。
      • システム的には、先の lotd_count カスタムフィールドに ‘999’ をセットして候補から除外する。

    ランダムに選んで とは……

    上記の条件を満たすリスト投稿群を作成したうえで、ランダムに1つリストを選ぶ。ただし、LOTDとして適切かどうかを前日にチェックするため、翌日のLOTD候補を前日に決めておきたい。

    乱数生成器にシードを与えて初期化すると、生成される乱数はシードに対して一意に定まる(Random seed – Wikipedia)。そこで 20201231 のような日付を元にした情報をシードとして与えれば、母集団が変わらないかぎり、特定の日にどの投稿が選ばれるかを知ることができる。

    以下、処理の概要。

    1. まず既配信回数の最小値を求める。 公開されたリスト投稿の中で書籍から作成されているものを lotd_count カスタムフィールドの小さい順に並べ替えて、その先頭行を取得する。

    $lotd_ids_core = array(
    	'post_type'      => 'post',
    	'category_name'  => 'list',
    	'post_status'    => 'publish',
    	'posts_per_page' => 1, // 1件のみ
    	'tax_query'      => array( // ASIN が付与されている
    		array(
    			'taxonomy' => 'asin',
    			'operator' => 'EXISTS',
    		),
    	),
    );
    
    $query = new WP_Query( $lotd_ids_core + array(
    	'fields'   => 'ids', // IDのみ
    	'orderby'  => 'meta_value_num', // カスタムフィールドで並べ替え
    	'meta_key' => 'lotd_count', // 既配信回数
    	'order'    => 'ASC', // 昇順
    ) );
    
    $min_lotd_count = get_post_meta( $query->posts[0], 'lotd_count', true );

    2. 公開されたリスト投稿から適切なリストをランダムに選ぶ。

    $seed = wp_date( 'Ymd' );
    $lotd_post = new WP_Query( $lotd_ids_core + array(
    	'meta_query' => array( //既配信回数が最小の記事
    		array(
    			'key'     => 'lotd_count',
    			'value'   => $min_lotd_count,
    			'compare' => '=',
    		),
    	),
    	'orderby' => 'rand('.$seed.')', // YYYYMMDDをシードとして与えたうえでランダムに
    ) );
    
    // $lotd_post->posts[0] が YYYYMMDD における LOTD候補となる

    LOTD とは……

    システム上、LOTD とは次の条件を満たす投稿。

    • カテゴリ: 今日のリスト

    毎日 0:01 にバッチ処理を走らせ、「今日のリスト」カテゴリを空にしたうえで、上記のロジックで選んだリストを「今日のリスト」カテゴリに入れる。そしてカスタムフィールドに保存している配信カウンタをインクリメントしておく。

    1日1回配信 するには……

    メールでは

    毎日 6:05 にバッチ処理を走らせ、LOTD を エックスサーバーのメーリングリストに投稿する。

    Feed では

    WordPress の標準機能でカテゴリごとの feed が出力されるので、「今日のリスト」カテゴリのフィードが LOTD のフィードになる。

    X では

    メール配信処理の後で @listfreakポストする。

  • WordPress: シンプルなページ内目次


    概要:プラグインを使わない軽量なページ内目次

    *ListFreakのリストには目次は不要ですが、メンテナンス用のマニュアルに目次が欲しくなったので作りました。

    特徴1:構造的でない文書にも対応

    たとえば

    見出しレベル2の次に見出しレベル4が来たり、

    その次がレベル3だったりしても、

    大丈夫。

    特長2:文書中のレベルに合わせたインデント

    <h1>が使われておらず <h2>が最高位であれば、<h2>がインデント無し 、<h3>が1文字下げ、<h4>が2文字下げになります。

    ソースコード

    add_shortcode( 'kh_toc', function() {
    
        // ヘッダ行を取り出す
        if ( ! preg_match_all(
            '|<h(\d)(.*)>(.*)</h\1>|iU', // Caseless, Ungreedy
            get_the_content(),
            $headers,
            PREG_SET_ORDER
        ) ) {
            return '';
        }
    
        // ヘッダの最高レベルを取得
        // レベル部分のマッチング結果を取り出して最小値を求める
        $highest_level = min( array_column( $headers, 1 ) );
    
        // $h[0]=全体、$h[1]=ヘッダレベル、$h[2]=属性、$h[3]=見出し
        $out = '<ul style="list-style: none;"><li><strong>目次</strong></li>';
        foreach ( $headers as $h ) {
    
            // 属性にidがあれば見出しにリンクを付与(前提:id="a1" 形式)
            if ( preg_match( '|id="(.+?)"|', $h[2], $anchor ) ) {
                $h[3] = '<a href="#'.$anchor[1].'">'.$h[3].'</a>';
            }
    
            // 最高レベルの次から' 'を付して目次行を作成
            //(ex. h2が最高レベルならh3は' '、h4は'  ')
            $out .= '<li>'.str_repeat( ' ', $h[1] - $highest_level ).$h[3].'</li>';
        }
        return $out.'</ul><hr>';
    }

    ソースコード解説

    下記のような見出しが含まれた投稿があったとして、

    <h2 id="id1" class="class1">見出し1</h2>
    ...
    <h3>見出し2</h3>
    ...
    <h2 id="id3" class="class1">見出し3</h2>
    ...

    下記の正規表現で見出し行を取得し、さらに必要な要素に切り分けると、

    '|<h(\d)(.*)>(.*)</h\1>|iU'

    下記のような結果が得られる。

    Array (
        [0] => Array (
                [0] => <h2 id="id1" class="class1">見出し1</h2>
                [1] => 2
                [2] =>  id="id1" class="class1"
                [3] => 見出し1
        )
        [1] => Array (
                [0] => <h3>見出し2</h3>
                [1] => 3
                [2] => 
                [3] => 見出し2
        )
        [2] => Array (
                [0] => <h2 id="id3" class="class1">見出し3</h2>
                [1] => 2
                [2] =>  id="id3" class="class1"
                [3] => 見出し3
        )
    )