株式会社ライブキャストロゴ 株式会社ライブキャスト

Algolia、話題になっていますよね。
当サイトに導入してみようか、ということで使ってみました。

Alogliaって何?
インクリメンタル検索、、、検索条件を入力する欄にキーボードをタイプしていくと、ajaxでDBを曖昧検索してヒットした結果をリアルタイムで表示する、みたいなやつのハイパフォーマンス版です(ものすごくざっくり言いますと)。
何がすごいかっていうと、曖昧検索の柔軟性がものすごく高いのと、キーボード入力してから結果が表示されるまでの時間が本当に短いというところです。同じようなことをやろうとしても到底ここまではできません。

ただ、導入時の注意点ということでいくつか気がついたことがありますので、備忘録を残しておきたいと思います。

●環境
・Linux
・WordPress
・PHP7
Algolia

導入するにあたってやらないといけないことは大きく分けると2つあります。

  1. Algolia側のインデックス(indices インデックスの複数形)の構築
  2. フロントエンドの実装

1.は、簡単に言うとDBの構築みたいなものです。
2.は1で構築したDBを検索するフロント側の仕組みをJavaScriptで実装する。
というようなことになります。

本記事は、前者インデックスの構築についてまとめています。2については後日まとめることにします。

Setting Up Algolia | WordPress | Algolia Documentation

今のところ日本語のリファレンスがないのですが、基本的にはこのサイトの手順に従ってやっていけば大体インデックスができあがると思います。

構築までの手順を簡単にまとめると。

  1. pluginを作る
  2. Algoliaフレームワークのインストール
  3. プラグインの中にコマンドで実行する処理を実装
  4. 記事容量が大きいときに分割して格納するための仕組みを実装
  5. インデックス構築

というような流れになります。それでは早速すすめていきたいと思います。

プラグインを作る

WordPressのプラグインを作ります。プラグインディレクトリの下にプラグインのディレクトリを作ります。

mkdir algolia-custom-integration

algolia-custom-integration.phpというファイルを作成して次のコードを貼り付けて保存します。WordPressプラグインのデフォルトの書式ですね。

<?php

/**
 * Plugin Name:     Algolia Custom Integration
 * Description:     Add Algolia Search feature
 * Text Domain:     algolia-custom-integration
 * Version:         1.0.0
 *
 * @package         Algolia_Custom_Integration
 */

// Your code starts here.

一応、WordPressの管理画面でプラグインを有効にしておきましょう。

Algoliaフレームワークのインストール

当方の環境にはcomposerがインストールされていなかったので、指示にあるようにgithubからzipファイルをダウンロードしてすすめたのですが途中でうまくいかないところが出てきたため、急遽composerをインストールしてやり直しました。ここではcomposerのインストールは割愛します。インストールされている前提ですすめます。
Introduction – Composer

[ec2-user@ip-172-30-0-116 algolia-custom-integration]$ composer require algolia/algoliasearch-client-php
Using version ^2.7 for algolia/algoliasearch-client-php
./composer.json has been created
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 4 installs, 0 updates, 0 removals
– Installing psr/simple-cache (1.0.1): Downloading (100%)
– Installing psr/log (1.1.3): Downloading (100%)
– Installing psr/http-message (1.0.1): Downloading (100%)
– Installing algolia/algoliasearch-client-php (2.7.0): Downloading (100%)
algolia/algoliasearch-client-php suggests installing guzzlehttp/guzzle (If you prefer to use Guzzle HTTP client instead of the Http Client implementation provided by the package)
Writing lock file
Generating autoload files

これだけです。後は、先ほど作成したalgolia-custom-integration.phpに以下のコードを追記します。

require_once __DIR__ . '/vendor/autoload.php';

global $algolia;

$algolia = \Algolia\AlgoliaSearch\SearchClient::create("YourApplicationID", "YourAdminApiKey");

YourApplicationIDとYourAdminApiKeyはApllication IDとAdmin API Keyです。Algoliaの管理コンソールからコピペしてください。

プラグインの中にコマンドで実行する処理を実装

2ページ目に入りました。

Importing Existing Content | WordPress | Algolia Documentation

プラグインの中身を実装してきます。最初にインデックスを構築する際にはコマンドラインから実行するという仕組みになっています。また、wp-cliがインストールされていることが前提になっています。

wp algolia reindex_post

このコマンドを実行してインデックスを構築します。これを可能にするためにwp-cli.phpを作成して、以下のコードを追記します。WordPressから記事を取得して、Algoliaのインデックスに登録する処理です。

<?php

if (!(defined('WP_CLI') && WP_CLI)) {
    return;
}

class Algolia_Command {
    public function reindex_post($args, $assoc_args) {
        global $algolia;
        $index = $algolia->initIndex('index_name');

        $index->clearObjects()->wait();

        $paged = 1;
        $count = 0;

        do {
            $posts = new WP_Query([
                'posts_per_page' => 100,
                'paged' => $paged,
                'post_type' => 'post'
            ]);

            if (!$posts->have_posts()) {
                break;
            }

            $records = [];

            foreach ($posts->posts as $post) {
                if ($assoc_args['verbose']) {
                    WP_CLI::line('Serializing ['.$post->post_title.']');
                }
                $record = (array) apply_filters('post_to_record', $post);

                if (!isset($record['objectID'])) {
                    $record['objectID'] = implode('#', [$post->post_type, $post->ID]);
                }

                $records[] = $record;
                $count++;
            }

            if ($assoc_args['verbose']) {
                WP_CLI::line('Sending batch');
            }

            $index->saveObjects($records);

            $paged++;

        } while (true);

        WP_CLI::success("$count posts indexed in Algolia");
    }
}

WP_CLI::add_command('algolia', 'Algolia_Command');

algolia-custom-integration.phpに次のコードを追記します。

require_once __DIR__ . '/wp-cli.php';

これで、

wp algolia reindex_post

が実行できるようになりました。

3ページ目に入りました。
Serializing Content | WordPress | Algolia Documentation

記事投稿時にもインデックスされるよう使用しているテーマのfunctions.phpに処理を追記します。

function algolia_post_to_record(WP_Post $post) {
    $tags = array_map(function (WP_Term $term) {
        return $term->name;
    }, wp_get_post_terms($post->ID, 'post_tag'));

    return [
        'objectID' => implode('#', [$post->post_type, $post->ID]),
        'title' => $post->post_title,
        'author' => [
            'id' => $post->post_author,
            'name' => get_user_by('ID', $post->post_author)->display_name,
        ],
        'excerpt' => $post->post_excerpt,
        'content' => strip_tags($post->post_content),
        'tags' => $tags,
        'url' => get_post_permalink($post->ID),
        'custom_field' => get_post_meta($post->id, 'custom_field_name'),
    ];
}
add_filter('post_to_record', 'algolia_post_to_record');

この処理は、先ほどプラグインに書いたreindex_postの処理からも呼び出されています。

            $record = (array) apply_filters('post_to_record', $post);

以上で基本的な処理の実装は完了です。

記事容量が大きいときに分割して格納するための仕組みを実装

Splitting Large Records | WordPress | Algolia Documentation

Algoliaにはインデックスの1レコードに対する容量の制限があります。これはおそらく有料プランでも同じかと思います。なので、記事などの本文が長い場合などには分割してインデックスに登録する必要があるのです。また、インデックス登録後には表示上、タイトルなどが重複しないよう同じ投稿であるように見せるためdistinctの設定をしてやる必要があります。

まずは分割する処理を実装していきましょう。以下のコード(クラス)をalgolia-custom-integration.phpに追記してやりましょう。

namespace Algolia;

use DOMDocument;

class HtmlSplitter
{
    protected $level1 = 'h2';
    protected $level2 = 'h3';
    protected $contentLimit = 1000;

    /**
     * Splits the given value.
     *
     * @param  object $searchable
     * @param  string $value
     *
     * @return array
     */
    public function split(\WP_Post $post) {
        $dom = new DOMDocument();
        $dom->loadHTML( $this->get_sanitized_content($post) );
        $rootNodes = $dom->getElementsByTagName('body')->item(0)->childNodes;
        $values = $split = [];

        foreach($rootNodes as $node) {
            $values[] = [$node->tagName => $this->get_node_content($node)];
        }

        $current = [];

        foreach ($values as $entry) {
            foreach ($entry as $tag => $value) {
                if ($tag == $this->level1) {
                    $split[] = $current;
                    $current = [
                        'subtitle' => $value,
                        'subtitle-2' => [],
                        'content' => [],
                    ];
                } elseif ($tag == $this->level2) {
                    $current['subtitle-2'][] = $value;
                } else {
                    $current['content'][] = $value;
                }

                if (!empty($current['content']) && $this->isContentLargeEnough($current['content'])) {
                    $split[] = $current;
                    $current = [
                        'subtitle' => '',
                        'subtitle-2' => [],
                        'content' => [],
                    ];
                }
            }
        }

        foreach ($split as $key => $piece) {
            $split[$key]['content'] = implode("\n\n", $piece['content']);
        }

        return $split;
    }

    private function get_sanitized_content(\WP_Post $post) {
        $the_content = apply_filters('the_content', $post->post_content);

        // Remove <script> tags
        $the_content = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $the_content);
        // Remove \n characters
        $the_content = preg_replace('/\n/', '', $the_content);

        return $the_content;
    }

    private function get_node_content(\DOMElement $node) {
        if (in_array($node->tagName , ['ul', 'ol'])) {
            $text = [];
            foreach ($node->childNodes as $li) {
                $text[] = $li->nodeValue;
            }
            return ' - '.implode("\n - ", $text);
        }

        return $node->textContent;
    }

    private function isContentLargeEnough($content) {
        if (is_array($content)) {
            $content = implode(' ', $content);
        }

        return mb_strlen($content, 'UTF-8') > $this->contentLimit;
    }
}

記事投稿時にインデックスに登録する処理にもsplitを呼び出す処理を書いてやります。functions.phpのalgolia_post_to_recordを以下のコードに変更してください。

function algolia_post_to_record(WP_Post $post) {
    $tags = array_map(function (WP_Term $term) {
        return $term->name;
    }, wp_get_post_terms($post->ID, 'post_tag'));

    // Prepare all common attributes and add a new `distinct_key` property
    $common = [
        'distinct_key' => implode('#', [$post->post_type, $post->ID]),
        'title' => $post->post_title,
        'author' => [
            'id' => $post->post_author,
            'name' => get_user_by( 'ID', $post->post_author )->display_name,
        ],
        'excerpt' => $post->post_excerpt,
        'content' => strip_tags($post->post_content),
        'tags' => $tags,
        'url' => get_post_permalink($post->ID),
    ];

    // Split the records on the `post_content` attribute
    $splitter = new \Algolia\HtmlSplitter;
    $records = $splitter->split($post);

    // Merge the common attributes into each split and add a unique `objectID`
    foreach ($records as $key => $split) {
        $records[$key] = array_merge($common, $split, [
            'objectID' => implode('-', [$post->post_type, $post->ID, $key]),
        ]);
    }

    return $records;
}

それから、先ほどwp-cli.phpに記述したreindex_postは記事が分割されていないので(30〜42行目)、次のように変えてやる必要があります。

	        foreach ($posts->posts as $post) {
	            if ($assoc_args['verbose']) {
	                WP_CLI::line('Serializing ['.$post->post_title.']');
	            }
	            $split = (array) apply_filters('post_to_record', $post);

	            // if (!isset($record['objectID'])) {
	            //     $record['objectID'] = implode('#', [$post->post_type, $post->ID]);
	            // }

	            // $records[] = $record;
	            $records = array_merge($records, $split);
	            $count++;
	        }

これで一通りの実装が完了しましたが、いくつか注意点があります。

まず、algolia-custom-integration.phpのnamespaceの記述ですが、このままコピペすると位置がおかしいことがありますので先頭の方に移動してやってください。

それからsplitファンクションの2行目ですが、このままだと日本語が文字化けしてしまっていたので、次のように変更しました。

        $dom->loadHTML( mb_convert_encoding($this->get_sanitized_content($post), 'HTML-ENTITIES', 'UTF-8') ); 

同じくsplitファンクションの中で、contentが空でなく1000文字以上の場合、分割する。みたいな処理があります。

                if (!empty($current['content']) && $this->isContentLargeEnough($current['content'])) {
                    $split[] = $current;
                    $current = [
                        'subtitle' => '',
                        'subtitle-2' => [],
                        'content' => [],
                    ];
                }

これだとcontentが空でなく、1000文字に達していない記事はスルーされてしまいますのでインデックスされなくなってしまいます。この対策のためにこの後の処理を、次のようにしました。

        if (!empty($split)) {
            foreach ($split as $key => $piece) {
                $split[$key]['content'] = implode("\n\n", $piece['content']);
            }
        }
        else {
            $split[] = $current;
        }

それから、get_node_contentの引数ですが、記事のマークアップがおかしい(タグを書いて閉じていないなど)とエラーになってしまいます。本来は、記事の中のマークアップを見直すべきですが、件数が多くとても修正しきれませんでしたのでget_node_contentの引数を修正しました。

    // private function get_node_content(\DOMElement $node) {
    private function get_node_content($node) {

インデックス構築

ここまでできあがりましたら

wp algolia reindex_post

コマンドを実行してやりましょう。

Fatal error: Class ‘DOMDocument’ not found

いきなりエラーが出てしまいました。こちらはphp-xmlというモジュールがインストールされていないために発生していたものでした。yumでインストールしてあげれば解決するものだったのですが、環境の問題もありなかなかうまくいかなかったのですが、次のコマンドを実行することで解決しました。

yum install –disablerepo=amzn-main –enablerepo=remi-php71 php-xml

ここまでのまとめ

無事コマンドを実行することができるようになりました!AlgoliaのインデックスにWordPressのデータが投入されました。

コマンドの実行では、Warningは残ってしまったのですが(注意点でも書いたマークアップが正しくない件です)、WordPressの全データがもれなくAlogliaのインデックスに登録されています。

次は、フロントエンドの検索機能の実装です。この後実装していきたいと思います。できあがりましたら、またこのブログで紹介していきますので、よろしくお願いいたします!