ブログ用タグ: WordPress

  • Block Bindings API を試す

    概要

    Block Bindings APIを試してみた。レイアウトが決まっているページの情報だけを書き換えるなら使えそう。

    試すに至った背景

    そもそも Block Bindings API とは

    ブロックの内容や属性を動的に書き換えるAPI。

    Bindings – Japanese Team – WordPress.org 日本語

    試したかった理由

    当サイトではブロックの内容を書き換えたり、動的に表示させたりしている。現在はブロックの名前をキーにして、ブロックを描画するときに書き換えているが、より公式なやり方があればそれに準拠したい。

    試したこと

    準備

    段落ブロックで試してみた。まずはビジュアルエディターで段落を作った後コードエディターに切り替えてバインド情報を書き込む。

    <!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"listfreak/blockbinings-test","args":{"test_key":"test_value"}}}}} -->
    <p>(ブロックバインディング)</p>
    <!-- /wp:paragraph -->

    バインドする関数とバインドされる関数、つまり表示内容を実際に作る関数を定義する。以下のようなファイルを作って自作プラグインから呼び出してみた。

    <?php
    class LF_BlockBinings_Test {
    
    	/**
    	 * 初期設定をする
    	 */
    	public static function init() {
    
    		// ブロックと表示ロジックを結び付ける関数を呼び出す
    		add_action( 'init', [ __CLASS__, 'bind' ] );
    	}
    
    	/**
    	 * ブロックと表示ロジックを結び付ける
    	 */
    	public static function bind() {
    		register_block_bindings_source( 'listfreak/blockbinings-test', [
    			'label'              => __METHOD__,
    			'get_value_callback' => [ __CLASS__, 'get_value' ],
    		] );
    	}
    
    	/**
    	 * 表示する内容を作る
    	 */
    	public static function get_value( $source_args, $block_instance, $attribute_name ) {
    
    		$value = __METHOD__.' ('.__LINE__.') '.print_r( $source_args, true );
    		return $value;
    	}
    }
    LF_BlockBinings_Test::init();

    実施

    バインドが成功すると、ブロックには「(ブロックバインディング)」でなくregister_block_bindings_source()で指定したラベルである「LF_BlockBinings_Test::bind」が表示された。これによってバインドされていることが示される。

    エディタで定義するソース名 (listfreak/blockbinings-test) と、それを使ってバインドする関数の対応関係を管理するのが面倒そうなので、メソッド名をラベルに使うアイディアは悪くないと感じた。

    このブロックを含むページを表示させると、ブロック部分には下記が表示された。

    LF_BlockBinings_Test::get_value (28) Array ( [test_key] => test_value )

    試して学んだこと

    • 思ったより簡単。バインド情報をコードエディターで書き込むので内容を忘れるのではないかと不安だったが、ちゃんとビジュアルエディターでも識別できるようになっている。
    • 対象ブロックの値だけを書き換えるので、get_value()が空文字を出力しても<p></p>は出力される。マージンなどの書式設定を施していればそれも画面に表示される。出力する内容がなければ<p></p>ごと表示させないでおこうと思うと、レンダー時点で書き換える現行方式の方が柔軟。
    • 特定ページでしか発生しないBindingでも、バインド関数をinitフックに登録しなければならない。バインディングをいくつも登録していくにつれてメモリの容量も増えることになる。まあ大きなサイズではないだろうが……。
  • X(Twitter)への投稿字数を調整する

    概要

    LOTD(今日のリスト)を X(Twitter) に投稿する際、140字に切り詰める処理を実装した。

    背景

    これまで外部サービスを使っていたLOTDのXへの自動投稿を、お手製のプログラムに切り替えました。下記のような内容を、メール配信と同じタイミングで投稿します。

    [今日のリスト] 【タイトル】: 【抜粋】
    【パーマリンク】
    【ハッシュタグ化したタグ】

    たとえば「4つの元型(ユング)」であれば次のような感じです。

    [今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生するとされる。』
    https://listfreak.com/list/20747
    #ユング #人格 #元型 #無意識 #脳科学

    しかし、これをTwitter APIに送ると403エラーが返ってきます。不親切なことに「字数が長いよ」ではなく「投稿は許されない」という抽象的なエラーメッセージだったので、字数オーバーであること自体に気づくのに時間がかかりました。

    投稿が長い場合には、抜粋で調整しようと思います。たとえば下記のように。

    [今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生す…
    https://listfreak.com/list/20747
    #ユング #人格 #元型 #無意識 #脳科学

    内容

    「字数」と「重み付き字数」

    Xでは日本語140字(characters)、英語280字まで投稿できると言われます。これは日本語1字に2字ぶんの「重み」(weight)が付けられているからです。

    なので「あいuえお」の字数(length)は5字ですが、Xの数え方では9字です。ややこしいので、Xの数え方に従った字数を「重み付き字数」と称します。「あいuえお」の字数は5字、「重み付き字数」は9字です。

    全角=2字、半角=1字というわけでもない

    PHPには半角文字を 1、全角文字を 2 として字幅を数える mb_strwidth() や、同じ数え方で指定した字幅で文字列を丸める mb_strimwidth() があります。しかしPHPでは字数 1 と判定される「…」が重み付き字数では2になるなど、字の数え方が X とすこし異なります。

    twitter-text がルールブック

    X の公式サイトによると、ポストの字数制限は twitter-text というオープンソースライブラリで提供しているとのこと。しかし残念ながらPHPのライブラリはありません。そこで構成ファイルだけを借りて軽量なクラスを作りました。

    class LF_Twitter_Text {
    
      // twitter-text configuration
      private static $tt = null;
    
      // 初期化
      public static function init() {}
    
      // 構成内容の取得
      public static function get() {}
    
      // 文字列の重み付き字数を数える
      public static function count_str( $string ) {}
    
      // 文字列の後ろを指定された重み付き字数ぶん切り落とす
      public static function truncate( $string, $length ) {}
    
      // 渡された1文字の重み付き字数を返す
      private static function count_char( $char ) {}
    }

    初期化メソッドは次のようにしました。構成情報をファイルに置くと置き場所を考えるのが面倒だしめったに変わる情報ではないのでハードコード。versionが上がったらJSON部分を入れ替えます。

    public static function init() {
    
      // 重複して呼ばれ得るので、すでに値が入っていればそれを返す
      if ( self::$tt ) {
        return self::$tt;
      }
    
      self::$tt = json_decode( '
    {
      "version": 3,
      "maxWeightedTweetLength": 280,
      "scale": 100,
      "defaultWeight": 200,
      "emojiParsingEnabled": true,
      "transformedURLLength": 23,
      "ranges": [
        {
          "start": 0,
          "end": 4351,
          "weight": 100
        },
        {
          "start": 8192,
          "end": 8205,
          "weight": 100
        },
        {
          "start": 8208,
          "end": 8223,
          "weight": 100
        },
        {
          "start": 8242,
          "end": 8247,
          "weight": 100
        }
      ]
    }'
      );
    }

    字数カウントの核となる、重み付き字数のカウント部分はこのような感じにしました。大部分が日本語、次が英数字などいわゆる半角文字なので、その順番にヒットして処理を終了できるようにちょっと工夫をしました。

    private static function count_char( $char ) {
    
      // UTF-8のコードポイントを取得
      if ( false === $code = mb_ord( $char, 'UTF-8' ) ) {
        return false;
      }
    
      // 大部分を占める日本語をまず判定
      // ranges 配列の最後のメンバーのendとまず比較する
      if ( end( self::$tt->ranges )->end < $code ) {
        return self::$tt->defaultWeight / self::$tt->scale;
      }
    
      // 次に多いと思われるメジャーな半角文字から判定する
      foreach ( self::$tt->ranges as $range ) {
        if ( $range->start <= $code && $code <= $range->end ) {
          return $range->weight / self::$tt->scale;
        }
      }
    
      // 残りは全て2字幅
      return self::$tt->defaultWeight / self::$tt->scale;
    }

    これらのメソッドを使って下記のような感じで投稿する文字列を作成しました。

    // Twitter-Text configuration の内容を取得する
    $tt = LF_Twitter_Text::get();
    
    // パート1: [今日のリスト] タイトル: 抜粋
    $xpost_part1       = '[今日のリスト] '.$post->post_title.': '.$post->post_excerpt;
    $width_part1       = LF_Twitter_Text::count_str( $xpost_part1 );
    
    // パート2: (改行)permalink(改行)ハッシュタグ
    $xpost_part2       = PHP_EOL.$post_url.PHP_EOL.$hash_tags_str;
    // パート2の文字幅を計算する。URLは固定長文字列をダミーとしてあてがう
    $xpost_part2_dummy = PHP_EOL.str_repeat( '-', $tt->transformedURLLength ).PHP_EOL.$hash_tags_str;
    $width_part2       = LF_Twitter_Text::count_str( $xpost_part2_dummy );
    
    // 必要ならパート1の文字数を削る
    if ( 0 < $over_width = $width_part1 + $width_part2 - $tt->maxWeightedTweetLength ) {
      $xpost_part1 = LF_Twitter_Text::truncate( $xpost_part1, $over_width );
    }
    
    // X投稿
    $xpost = $xpost_part1.$xpost_part2;
  • Xに投稿する機能を作る

    概要

    LOTDをX(Twitter)に自動ポストするために使っていたサービスが停止されたので代替機能を作りました。

    経緯

    LOTD(今日のリスト)は、システム的には「lotd というカテゴリに入っている(唯一の)投稿」であり、毎日 0:00 に入れ替えています。このような仕様にしたのは、WordPressがカテゴリごとに RSS フィードを作ってくれるからでした。

    当サイトをWordPressに乗せ換えた当時はフィードをツイートしてくれる無料サービスの選択肢が多かったため、LOTDだけのフィードさえ作れれば、追加の開発なしで LOTD の tweet bot が実現するのは容易でした。

    dlvr.it はそのような配信サービスの一つでした。毎日いい感じに巡回してきてくれて、フィードが更新されていたらツイートしてくれました。Twitter が X になり、ツイートがポストと呼ばれるようになっても、ちゃんと動いてくれました。しかし残念ながら運営者から「1週間後に free plan はおしまいにするよ」というメールが来ました。ずいぶん急な話です。

    いくつかプラグインを検討したのですが、特定のカテゴリの投稿だけを毎日ポストしてくれる軽量プラグインが見あたらず、自作することにしました。

    Twitter APIライブラリのインストール

    PHPでXに投稿するためのライブラリとしては TwitterOAuth が超定番のようです。公式ページによれば composer という依存関係管理ツールで次のようにインストールするのがおすすめとのこと。

    composer require abraham/twitteroauth

    しかしこの方法では 0.5.4 という、かなり古いバージョンの TwitterOAuth がインストールされてしまいます。どうも composer が参照するPHPのバージョンが古く、そのバージョンのPHPで動くバージョンの TwitterOAuth がインストールされている模様。さすが依存関係管理ツール。

    当サイトはレンタルサーバー上で動いているので、/usr/bin の下にはいろいろなバージョンのPHPが混在しています。古いバージョンでも機能すればよいのですが、このバージョンは Twitter API v2 に対応していないようなので、新しい TwitterOAuth をインストールしなければならないようです。

    あれこれ調べた結果、構成ファイル composer.json を以下のように作り、composer install することで、新しいバージョンの TwitterOAuth をインストールできました。

    {
        "config": {
            "platform": {
                "php": "8.2"
            }
        },
        "require": {
            "abraham/twitteroauth": "^7.0"
        }
    }

    実のところもっとも時間がかかったのは、この新しいバージョンの TwitterOAuth をインストールする作業でした。いったん環境が整えば、次のようにかなり少ない手数でポストできます。

    // TwitterOAuthインスタンスの作成
    $X = new TwitterOAuth(
      '(API Key)', 
      '(API Key Secret)',
      '(Access Token)',
      '(Access Token Secret)'
    );
    
    // API v2 を指定
    $X->setApiVersion( '2' );
    
    // 投稿内容
    $post = '[今日のリスト] ';
    
    // ツイートを投稿
    $response = $X->post( 'tweets', [ 'text' => $post ] );
    
    // 成功なら 201 (Created) が返ってくる
    if ($connection->getLastHttpCode() !== 201) {
    	// エラー処理
    }

    この処理を、LOTDのメール配信の次に実施することにしました。投稿内容もRSSフィードの転送でなくゼロから組み立てるので、リストに付けたタグをXのハッシュタグにするなど、工夫もしやすくなりました。

    [lf_load_syntax_highlighter]

  • 広告 (Google AdSense) を動的に非表示にする

    概要

    当サイトのリストURLに文字列を付けると Google AdSense が表示されないようにする。

    https://listfreak.com/list/NNN   → 広告あり
    https://listfreak.com/list/NNN/x → 広告なし

    背景

    当サイトはいくつかのページを除いて Google AdSense が配信する広告を表示しています。しかし、リストを誰かに紹介するときには広告を外したい。そこでURLの末尾に /x のような文字列を付けることで Google AdSense の表示を抑制できないかどうか調べました。

    内容

    Google AdSense の表示を動的に抑制する

    広告を表示させているのは、<head> 部の <!– Google AdSense スニペット (Site Kit が追加) –> の次の行にある JavaScript です。この行が挿入される行を調べてみると、’template_redirect’ フックに登録されている ‘Google\S\M\AdSense->register_tag()’ というコールバックらしいことがわかりました。

    そこで AdSense::register_tag() を見てみると、スニペット書き出し前にこんなif分がありました。

    if ( $tag->is_tag_blocked() ) {
    return;
    }

    is_tag_blocked() の実体は Module_Web_Tag::is_tag_blocked() です。その中身は次のようにフィルターフックで出力の有無を切り替えられるようになっています。

    何のためにブロックしているのかわかりませんが、ありがたく使わせていただくことに。

    return (bool) apply_filters( "googlesitekit_{$this->module_slug}_tag_blocked", false );

    URLへの追加文字列を認識する

    list/NNN/x の x を認識させるためにはリライトルールの追加が必要です。

    add_rewrite_rule(
    '^list/([0-9]{1,})/x$',
    'index.php?category_name=list&p=$matches[1]&x=yes',
    'top'
    );

    この x をWP_Queryで認識させます

    add_filter( 'query_vars', function($qvars){
    $qvars[] = 'x';
    return $qvars;
    }, 10, 1 );

    これらは ‘init’ アクションフックに登録しなければなりません。

    まとめるとこんな感じで動きました。

    add_action( 'init', function(){

    add_rewrite_rule(
    '^list/([0-9]{1,})/x$',
    'index.php?category_name=list&p=$matches[1]&x=yes',
    'top'
    );

    add_filter( 'query_vars', function($qvars){
    $qvars[] = 'x';
    return $qvars;
    }, 10, 1 );
    }, 10, 0 );

    add_action( 'wp', function( $wp ){

    if ( 'yes' === get_query_var( 'x' ) ) {
    add_filter( 'googlesitekit_adsense_tag_blocked', '__return_true', 10, 1 );
    }
    }, 10, 1 );
  • WordPress: ブロックパターンを簡単に作る


    定義ファイルの追加によってブロックパターンを作成する

    ブロックパターンはこれまで次のように作成していました。

    add_action( 'init', 'lf_register_bp', 10, 0 );
    function lf_register_bp() {
    
        $content = <<<'BP'
    <!-- wp:shortcode -->
    [lf_load_syntax_highlighter]
    <!-- /wp:shortcode -->
    BP;
    
        register_block_pattern( 'listfreak/syntax_highlighter', [
            'title'      => 'シンタックスハイライター',
            'content'    => str_replace( "\r\n", "", $content ),
            'categories' => [ 'listfreak' ],
            'keywords'   => [ 'syntax highlighter' ],
        ] );
    }
    

    WordPress 6.0 からは、テーマディレクトリの下に作成しておいた /patterns ディレクトリに次のような PHP ファイルを置くことによってもブロックパターンを作れるようになりました。

    <?php
    /**
     * Title: シンタックスハイライター
     * Slug: listfreak/syntax-highlighter
     * Categories: listfreak
     * Keywords: syntax highlighter
     */
    ?>
    <!-- wp:shortcode -->
    [lf_load_syntax_highlighter]
    <!-- /wp:shortcode -->
    

    できることはどちらでも同じようです。ただ従来の方法では関数の中にブロックパターンの実体であるHTMLを埋め込まざるを得ず、メンテナンス性があまりよくありませんでした。新しく作るなら後者のやり方のほうがよさそうです。

    参考:”Place Patterns in the subfolder /patterns of your theme” (New features for working with patterns and themes in WordPress 6.0 – Make WordPress Core)

    新しい固定ページを作成する際に「ページ作成パターン」選択ダイアログを表示させる

    当サイトではリスト投稿に「まえがき」「リスト」「あとがき」といった統一的な見出しを付けているため、新規ページ用のブロックパターンを作成しています。

    WordPress 6.0 からは、このようなページ単位でのパターン(ページ作成パターン)を登録しておくと、新規作成時に選択ダイアログが表示されるようになりました。

    あるブロックパターンがページ作成パターンであるかどうかの識別は、ブロックタイプが core/post-content かどうかによって行われます。具体的には、先に紹介した後者のやり方でいえば

     Block types: core/post-content
    

    という行をコメント欄に書いておけばOK。

    ただし現在のところこの機能が適用されるのは固定ページのみ。

    参考:Page creation patterns in WordPress 6.0 – Make WordPress Core

  • ショートコード内で出力するHTMLをブロックエディタでデザインするために再利用ブロックを活用する

    投稿の中にHTMLをダイナミックに挿入するショートコードをいくつか作っています。たとえば [ lf_bookinfo asin=XXX] というショートコードは ASIN(Amazon.co.jp における書籍ID)をパラメータとし、書影や書籍情報、関連するリストなどを出力します。

    これまではショートコードに登録したコールバックの中で素朴にHTMLを書いていました。しかしブロックエディタには「縦積み」「横並び」のようなデザインブロックがあって、複雑なレイアウトを簡単に作れます。

    この恩恵に与りたいと思い、ブロックエディタが生成したコードをショートコードのコールバック内にコピーしたりしてみました。が、ブロックエディタが生成する複雑なクラスなどに追随するのが大変で断念。新しく専用ブロックを作成して既存の投稿を全置換するのが正道。しかしブロックの作成もまた面倒でした。

    試行錯誤の末、表示したいHTMLを再利用ブロックとして登録し、ショートコードの中から呼び出す方法に落ち着きました。

    表示レイアウトをデザインして再利用ブロックとして保存

    表示させたい情報をブロックエディタでレイアウトし、ダミーのテキストや画像を挿入しておきます。

    下部の見出しとリストはデータがあれば表示し、なかったら表示しないのですが、とりあえず表示させる前提でデザインしておきます。これを再利用ブロックとして保存すると、 <!-- wp:block {"ref":NNN} /--> のようなコードが挿入されます。

    ショートコード内から再利用ブロックを呼び出す

    WordPress はブロックを解析(パース)して表示用のHTMLを描画(レンダー)し、その後でショートコードをコールバックの出力で置き換えます。ショートコードが処理される時点では、本体のブロックパース&レンダリングは終わってしまっています。よって再利用ブロックのパースとレンダリングは手動で行わねばなりません。

    再利用ブロックは 投稿タイプ wp_block の投稿なので、それを呼び出してブロックにパースします。パースも標準関数があるので簡単です。

    // 再利用ブロックを取得
    $q = new WP_Query( [
        'post_type' => 'wp_block',
        'p'         => NNN,
    ] );
    
    // HTMLをブロックにパースする
    $parsed_blocks = parse_blocks( $q->posts[0]->post_content );

    ブロックの内容を置き換える

    内容を置き換えるブロックには、再利用ブロックを作るときに固有のCSSクラス名を振っておきます。意味合い的にはIDがふさわしいところですが、IDはブロックの属性情報としては保持されていない(出力HTMLにのみ書き込まれている)ので扱いづらい。

    次のようなイメージで、ブロックの内容を置き換えていきます。

    // 特定のクラスのブロックの内容を置き換える
    block_replace( $parsed_blocks[0], [
        'class' => 'lf-attribute-as-id',
        'from'  => '<li>(attribute)</li>',
        'to'    => '<li>1行目</li><li>2行目</li>',
    ] );

    ブロックの内容を置き換える処理はこんな感じ。ブロックおよびブロック内ブロックを再帰的に探索し、クラス名が一致したブロックの innerContentを置き換えます。パースして得られたブロックを直接置き換えていくために参照渡しにしています。

    function block_replace( &$block, $args ) {
    
        // 渡されたブロックを直接書き換えるのでリファレンス渡し
        foreach ( $block as &$b ) {
    
            // 枝葉
            if( ! is_array( $b ) ) {
                continue;
            }
    
            // 置換対象のブロックなら
            $cn = $b['attrs']['className'] ?? '';
            if ( $args['class'] === $cn ) {
    
                // このブロックの innerContent を置き換える
                $b['innerContent'][0] = str_replace(
                    $args['from'],
                    $args['to'],
                    $b['innerContent'][0]
                );
    
                break;
            }
    
            // 直下に innerBlocks か添字配列があれば再帰呼び出し
            if ( array_key_exists( 'innerBlocks', $b )
                || array_key_exists( 0, $b ) ) {
    
                block_replace( $b, $args );
            }
        }
    }

    再帰ってすばらしい。

    array_walk_recursive() でも

    置き換える元となる文字列(この例では ‘<li>(attribute)</li>’)を一意にしておけば、array_walk_recursive() で置換する方法もあります。クラス指定も要らないし楽かも。

    array_walk_recursive( $parsed_blocks, 'block_replace', [
        'from'  => [
            '<li>(attribute)</li>',
            '(more-heading)',
        ],
        'to'    => [
            '<li>1行目</li><li>2行目</li>',
            $more_heading_to,
        ],
    ] );
    
    function block_replace( &$item, $key, $args ) {
    
        $item_replaced = str_replace( $args['from'], $args['to'], $item, $count );
        if ( 0 !== $count ) {
            $item = ( '' !== $args['to'] ) ? $item_replaced : '';
        }
    }

    array_walk_recursive() のコールバック関数の中で、置換先の文字列が空のときにブロック要素を丸ごと消している($item に ’’ を代入している)のは、<h>や<ul>などの外側のHTMLを含めてすべて削除するためです。

    HTML出力

    そのような処理を置き換えたいブロックについて実施した後、HTMLにレンダーしてコールバック終了。

    return render_block( $parsed_blocks[0] );

    少々面倒ではありましたが、既存の投稿を変更しなくてよい点、ブロックエディタでデザインできる点、そして再利用ブロックなので変更を一斉に及ぼせる点はメリットです。

  • WordPress: ショートコードのブロック化

    例えば下記のページは、固定ページにショートコードを埋め込んでコンテンツを動的に生成している。

    その編集画面は下図(Before)。ショートコードの中で見出しやリストなどすべてのコンテンツの書き出しを行っているのでブロックエディタの恩恵を被れない。

    カスタムブロックを作ってみたが、開発環境が複雑で維持できる自信がない。そこでカスタムブロックを作ることなく、ブロックエディタでレイアウトする自由を享受する方法を考えた。

    具体的には、下図(After)のようにページのデザインをブロックエディタで行い、表示時にブロックの内容の一部を置き換えることにした。

    Before
    After

    これをするためには、下記の処理が必要。

    • 置換したいブロックに一意なクラス名を指定する
    • render_block フィルターフックでそのクラス名を拾ってブロックの内容を置き換える

    ブロックを一意に特定するならば意味合い的にはID(HTMLアンカー)が適切だが、IDはブロックの属性として定義されていないため、キーとして使いづらい。

    コードは次のような感じで準備して、プラグインファイルから LF_Page_Taginfo::init() を呼んでおく。

    class LF_Page_Taginfo {
    
        // ブロックのクラス名 => 置換情報テーブル
        private static array $blocks = [];
    
        /**
         * 初期化
         */
        public static function init() {
            add_action( 'wp', [ __CLASS__, 'wp' ] );
        }
    
        /**
         * ディスパッチャー
         */
        public static function wp() {
    
            // 対象ページでなければリターン
            if ( ! is_page() || 'taginfo' !== get_query_var( 'pagename' ) ) {
                return;
            }
    
            // パラメーターが無ければリターン
            if ( '' === $tag_slug = get_query_var( 'tag' ) ) {
                return;
            }
    
            // タームオブジェクトが作れなければリダイレクト
            if ( false === $term = get_term_by( 'slug', $tag_slug , 'post_tag' ) ) {
                wp_safe_redirect( home_url(), 302 );
                exit;
            }
    
            // ブロックのクラス名 => 置換情報テーブル
            // クラス名はブロックエディタで一意に指定しておく
            self::$blocks = [
                'lf-page-taginfo-header-cotagged' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-header-tagcloud' => [
                    'from' => '(共起タグ)',
                    'to'   => LF_Page_Tag_CoTagged::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-header-list' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-list' => [
                    'from' => '<li>(リスト)</li>',
                    'to'   => LF_Tax_Tag::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-header-book' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-book' => [
                    'from' => '<li>(書籍)</li>',
                    'to'   => LF_Page_Tag_Book::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-misc' => [
                    'from' => [ 'TAG', '(タグ)' ],
                    'to'   => [ urlencode( $term->name ), $term->name ],
                ],
            ];
    
            // ページタイトル <title> の変更(コールバック関数は省略)
            add_filter( 'document_title_parts', [ __CLASS__, 'document_title' ], 10, 1 );
    
            // ページ説明の追加(コールバック関数は省略)
            add_action( 'wp_head', [ __CLASS__, 'wp_head' ] );
    
            // 投稿タイトルの変更(コールバック関数は省略)
            add_filter( 'the_title', [ __CLASS__, 'the_title' ], 10, 2 );
    
            // 投稿抜粋欄に説明を表示する(コールバック関数は省略)
            add_filter( 'get_the_excerpt', [ __CLASS__, 'excerpt' ], 10, 2 );
    
            // ブロックを置換する
            add_filter( 'render_block', [ __CLASS__, 'render_block' ], 10, 3 );
        }
    
        /**
         * ブロックを置換する
         */
        public static function render_block( $block_content, $parsed_block, $instance ) {
    
            $class = $parsed_block['attrs']['className'] ?? '';
    
            // 置換対象のクラスなら置換
            if ( '' !== $class && array_key_exists( $class, self::$blocks ) ) {
                $block_content = str_replace(
                    self::$blocks[$class]['from'],
                    self::$blocks[$class]['to'],
                    $block_content
                );
            }
    
            return $block_content;
        }
    }
    
    • 表示しようとしているページが処理対象かどうかを知るには ‘init’ アクションフックではタイミングが早すぎるので、URLパラメータの処理が終わった ‘wp’ で行っている。
    • wp() の最初の3つの if 文が対象ページかどうかの判定とパラメータ検証。
    • self::$blocks の定義が肝。ブロック名をキー、置換元先情報を値とする連想配列を定義しておく。置換されない部分についてはできるだけブロックエディタのほうに記述する。置換する部分がエディタ上でわかりやすいようにカッコでくくってみた。
    • 置換は str_replace() で行うので 複数要素の置換も可能。いちばん最後の要素ではリンク先のURLとリンク文字列の置換を行っている。
    • ‘render_block’ フィルターフックのコールバックに置換処理を登録。このフックはブロック描画の最後に設置されており、ヘッダーやフッターも含めて全ブロックで発火する。よってブロック名ごとに発火できる ‘render_block_{$this->name}’ を使うほうが少し軽いのかも。
    • render_block() で置換。クラス名が存在し、かつ置換情報テーブルのキーと一致したら置換。ブロックエディタでクラス名が1つだけ登録されていることを前提としているので、もしカスタムクラス名を2つ登録したくなった場合には改変が必要。
    • $block_content には表示するHTMLがそのまま入っている。置換元のコードはコードエディターに切り替えるなどして確認する。

    ちょっとスペーサーを入れてみたりするような見栄えの調整がずいぶん楽になった。

  • WordPress: タグ同士のつながりを可視化する

    概要

    タグ同士の関連性(つながり)を定義することによって、似た投稿をたどっていけるようにする。

    設計

    第一に、あるタグと他のタグとの関連の強さは、共にタグ付けされている投稿の数として定義する。

    たとえば「コミュニケーション」タグは91の投稿にタグ付けされている。このうち、

    • 「コミュニケーション」と「会話」の両方でタグ付けされた投稿は13
    • 「コミュニケーション」と「人間関係」の両方でタグ付けされた投稿は9

    である。このとき「会話」は「人間関係」よりも「コミュニケーション」との関連性が高い(つながりが強い)。

    第二に、関連の強さが同じタグ同士は、カウント(投稿に付けられている数)が多いタグを前に表示する。

    たとえば「リーダーシップ」も「交渉」も、それぞれ5つの投稿において「コミュニケーション」と共にタグ付けされていて、「コミュニケーション」との関連性の高さは等しい。このときリーダーシップのカウントが40、交渉のカウントが30とすると、リーダーシップのほうが先に表示される。

    実装

    あるタグの関連タグを、関連性の高い順に並べる

    関連性を調べたいタグ(以下ターゲットタグ)のIDを $term_id とする。

    // 1 $post_query->posts = array{ post_id }
    $post_query = new WP_Query( [
        'post_type'      => 'post',
        'post_status'    => 'publish', 
        'category_name'  => 'list',
        'tag_id'         => $term_id,
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ] );
    
    // 2 $term_query->terms = array{ term object }
    $term_query = new WP_Term_Query( [ 
        'taxonomy'               => 'post_tag',
        'object_ids'             => $post_query->posts,
        'exclude'                => $term_id,
        'fields'                 => 'all_with_object_id',
        'update_term_meta_cache' => false,
    ] );
    
    // 3 $postcount_by_tag = array{ term_id => postcount }
    $postcount_by_tag = array_count_values( 
        array_column( $term_query->terms, 'term_id' )
    );
    
    // 4 $related_tags = array{ term_id => term object (w/postcount) }
    foreach ( $term_query->terms as $term ) {
        if ( ! isset( $related_tags[$term->term_id] ) ) {
            $term->postcount = $postcount_by_tag[$term->term_id];
            $related_tags[$term->term_id] = $term;
        }
    }
    
    // 5
    usort( $related_tags, function( $a, $b ) {
        if( 0 === $ret = $b->postcount - $a->postcount ) {
            $ret = $b->count - $a->count;
        }
        return $ret;
    } );
    1. $term_id でタグづけされた公開リスト投稿のIDをすべて取得する。 $post_query->posts に投稿IDの配列が入る。あらかじめ何らかの投稿に付けられたタグについて処理が行われるので $term_id の存在チェックや投稿IDが返されないチェックは省略。
    1. 取得した全投稿に付けられたタグをすべて(ただしターゲットタグ自体は除いて)取得する。
      'fields' => 'all_with_object_id' によって、タグ付けされた投稿数だけタグオブジェクトが生成される(例えば、あるタグが5つの投稿にタグ付けされていれば5つのタグオブジェクトが生成される。それぞれのオブジェクトは object_id メンバによって区別される)。$term_query->terms にタグオブジェクトの配列が入る。
    2. タグIDをキー、タグIDの重複回数を値とした配列を作成。重複回数がNのとき、当該タグとそのタグはN個の投稿で共に使われていることを意味する。
    3. ソートするための配列を作成する。タグオブジェクトは重複しているので、タグIDにつき一つだけ利用する。またソートキーとして 重複回数をプロパティに加える。
    4. 重複回数の降順、タグカウントの降順でソートする。

    関連タグをタグクラウドで表現する

    // 1
    array_walk( $related_tags, function( &$term, $key ){
        $term->link = get_term_link( $term, 'post_tag' );
        $term->id   = $term->term_id;
    } );
    
    // 2
    add_filter( 'tag_cloud_sort', function( $tags, $args ) use( $related_tags ){
        return $related_tags;
    }, 10, 2 );
    
    // 3
    $cloud = wp_generate_tag_cloud( current( $related_tags ), [
        'number'    => 0, // all
        'smallest'  => 1, 
        'largest'   => 1.6,
        'unit'      => 'em', 
        'separator' => '',
    ] );
    
    1. タグクラウドを生成するのに必要な情報を追加する。
    2. 並べ替えた順序でタグクラウドを表示するためにフィルターフックにタグ配列を渡す。
    3. タグクラウドを生成する。2で渡す配列と同じ配列を渡すとフィルターフックが機能しない。空配列を渡しても機能しないため、ダミーとして2で渡す配列の先頭の要素だけを渡している。
  • WordPress: 直近7日間のアクセス数をカウントする

    今日を含む直近7日間のアクセス数をカウントするアクセスカウンターを作りました。「最近読まれているリスト」ページは、このアクセス数の多い順に並んでいます。

    概要

    今日を含む直近の7日間分のアクセス履歴とアクセス数の合計を保存する。

    アクセス履歴は日付をキー、アクセス数を値に持つ連想配列とし、投稿メタ(カスタムフィールド)に保存する。

    アクセス数の合計も別の投稿メタとして保存する。これを降順にソートして投稿を取り出すことで「最近読まれているリスト」を表示できるようにする。

    クローラーのアクセスは簡易ロジックによって除外する。ただしアクセス履歴の更新だけを行わせてメンテナンスを手伝ってもらう。

    2022/1/7、未アクセス時
    Array (
        [20211230] => 10
        [20220101] => 20
        [20220105] => 30
    )
    アクセスカウンター: 60
    2022/1/7、1アクセス後
    Array (
        [20220101] => 20
        [20220105] => 30
        [20220107] => 1
    )
    アクセスカウンター: 51
    2022/1/7、1アクセス後(botによるアクセス)
    Array (
        [20220101] => 20
        [20220105] => 30
    )
    アクセスカウンター: 50

    コード

    add_action( 'wp_footer', 'LF_Access_Count::access_count', 30 );
    
    class LF_Access_Count {
        public static function access_count() {
            // 管理者からのアクセス、およびlistカテゴリ以外へのアクセスを除外
            if ( current_user_can( 'administrator' ) ||
                 ! has_category( 'list' )
            ) {
                return;
            }
    
            // カスタムフィールドからカウント履歴を取得
            $count_history = get_post_meta( get_the_ID(), 'count_history', true );
    
            // 7日前の YYYYMMDD を取得
            $expdate = (int)wp_date( 'Ymd', strtotime( '-7 days' ) );
    
            // 7日前の日付以前の記録を削除(全削除なら空配列が返る)
            $count_history = array_filter(
                // アクセス履歴が無ければget_post_meta()が空文字を返すのでarrayにキャスト
                (array)$count_history, 
                function( $k ) use( $expdate ) {
                    return ( $expdate < $k ) ? true : false;
                }, 
                ARRAY_FILTER_USE_KEY 
            );
    
            // ユーザーエージェントを取得(未設定なら空文字)
            $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    
            // 簡易クローラー判定。bot、bots、spiderで終わる単語が含まれる
            // 場合はクローラーと見なす。クローラーでなければカウントアップ
            if ( 1 !== preg_match( '/.*(bots?|spider)\b/i', $ua ) ) {
                // 今日の日付をYYYYMMDD形式の整数として取得
                $today = (int)wp_date( 'Ymd' );
                // 今日の日付をキーに持つ要素がアクセス履歴に存在すれば
                // 値をインクリメント、なければ1
                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 ) );
        }
    }
  • WordPress: イベントのスケジュールとcron

    イベントのスケジュール

    全体で8つの定期イベントがあり、バラバラに登録しているとデバッグが面倒なので、1ファイルにまとめている。

    class LF_Events {
        private static $events = [
            [ // LOTD更新(毎日0:01)
                'hook'       => 'lf_event_renew_lotd',
                'args'       => [],
                'timing'     => 'tomorrow 0:01',
                'recurrence' => 'daily',
                'callback'   => [ 'LF_LOTD', 'renew' ],
            ],
            [ // 以下同様
            ],
        ]
    
        public static function init() {
            foreach ( self::$events as $event ) {
    
                if ( ! wp_next_scheduled( $event['hook'], $event['args'] ) ) {
            
                    $etime = new DateTimeImmutable( $event['timing'], wp_timezone() );
            
                    $ret = wp_schedule_event(
                        $etime->getTimeStamp(),
                        $event['recurrence'],
                        $event['hook'],
                        $event['args'],
                        true
                    );
            
                    if ( ! $ret || is_wp_error( $ret ) ) {
                        $errmsg['location'] = __METHOD__.' '.__LINE__;
                        $errmsg['$event']   = $event;
                        $errmsg['$ret']     = $ret;
                        error_log( print_r( $errmsg, true ) );
                        continue;
                    }
                }
            
                add_action( $event['hook'], $event['callback'], 10, count( $event['args'] ) );
            }
        }
    }
    

    cron

    ユーザーアクセスによる疑似cronではなくサーバーのcronを使う。10分おきに wp-cron.php を起動。

    (/wp-config.php)
    define( 'DISABLE_WP_CRON', true );
    
    (crontab -l)
    */10 * * * * /usr/bin/php7.x /home/(...snip...)/wp-cron.php