✍️

Next.jsのブログにAlgoliaで検索機能を導入する

2021/07/22に公開

自身のブログにAlgoliaを使って検索機能を実装したときのメモです。

前提

# versions
- Next: ^10.0.0
- algoliasearch: ^4.9.1
- react-instantsearch-dom: ^6.10.3

ブログの記事データはすべてマークダウンファイルで管理されていて、Static Generationで読み込んでいます。詳しいディレクトリ構造などはソースコードを見てもらえればと思います。

Algoliaダッシュボードでインデックスを登録

Algoliaに登録をして、インデックスの登録などを行っていきます。

Create indexでインデックスを作成します。僕はkenzo_blogみたいな感じでサイト単位で登録しています。

作成されたインデックスにレコードを登録していくのですが、やり方が3つあります。まずは手動で登録して動作確認をするのが良いと思います。APIを使った登録は後述します。

  • JSONファイルをアップロード
  • APIから登録
  • 手動で登録

僕の場合検索結果をリンクとして表示させたいので、タイトル・スラグなどをAlgoliaに登録しています。

次に検索対象のカラムをSearchable Attributesで設定します。

必要なパッケージをインストール

ここから実装側の対応になります。

npm install algoliasearch
npm install react-instantsearch-dom

検索用コンポーネントの作成

Algoliaの検索フォームを表示させるコンポーネントです。このコンポーネントを各ページで表示させています。

components/search.tsx
import React, { useState } from "react";
import algoliasearch from "algoliasearch/lite";
import Link from "next/link";
import {
  SearchBox,
  Hits,
  Highlight,
  InstantSearch,
} from "react-instantsearch-dom";

// Algoliaの管理画面から取得できる環境変数を設定
// APIキーは「Search-Only API Key」の方
const algoliaSettings = {
  searchClient: algoliasearch(
    `${process.env.ALGOLIA_APP_ID}`,
    `${process.env.ALGOLIA_API_KEY}`
  ),
  indexName: "kenzo_blog",
};

// ヒットした項目をリンクとして表示
// Highlightを入れておくと検索と一致したところにスタイルを当てられる
const Hit = ({ hit }: any) => {
  return (
    <Link href={`/posts/${hit.slug}`}>
      <a>
        <div className="p-7">
          <div className="hitName">
            <Highlight attribute="title" tagName="span" hit={hit} />
          </div>
        </div>
      </a>
    </Link>
  );
};

const SearchResult = () => {
  return <Hits hitComponent={Hit} />;
};

const Search: React.FC = () => {
  const [suggestDisplay, toggleDisplay] = useState("hidden");

  return (
    <>
      <InstantSearch
        searchClient={algoliaSettings.searchClient}
        indexName={algoliaSettings.indexName}
      >
        <div
          onFocus={() => toggleDisplay("block")}
          onBlur={() =>
            setTimeout(() => {
              toggleDisplay("hidden");
            }, 300)
          }
        >
          <SearchBox translations={{ placeholder: "記事を検索" }} />
        </div>
        <div className={`relative ${suggestDisplay}`}>
          <div className="bg-white search-result p-3 shadow-lg absolute w-full z-10 h-96 overflow-y-scroll">
            <SearchResult />
          </div>
        </div>
      </InstantSearch>
    </>
  );
};

export default Search;

スタイルについて

Algoliaの検索フィールドをよしなにスタイルしてくれるinstantsearch.cssというものがあります。僕はスタイルを自分で色々変えたかったのと、instantsearch.cssをカスタマイズが少しやりづらかったので入れていません。

Customize an Existing Widget | Building Search UI | Guide | Algolia Documentation

API経由でAlgoliaにレコードを登録する

AlgoliaのAPIを使うとレコードの登録が手軽にできるので便利です。
Add Objects | Indexing | Method | API Reference | Algolia Documentation

僕はブログの記事リストをJSON化して、それをAlgoliaのAPIでアップロードするみたいな手順を踏んでいます。
ただ、注意点としてAlgolia側でレコードの重複をチェックしてくれないので、記事のレコードを登録する際には差分だけを追加する必要があります。

Uploading a file will add records to this index; it will not erase previous records.
Algoliaのダッシュボードより

なので僕の場合は、以下のロジックでスクリプトを書いています。

  1. 「すべての記事リスト」のJSONファイル作成
  2. 「すべての記事リスト」と「現在のすべての記事リスト」の差異を抽出したJSONファイルを作成し、これをAlgoliaに登録
  3. 「すべての記事リスト」を現在のすべての記事に更新
builders/algolia.ts
// 色々必要なものをimport
import fs from "fs";
import { PostData } from "../types";
// 現在の記事一覧をオブジェクトの配列として返す関数
import { getSortedPostsData } from "../lib/posts";
import algoliasearch from "algoliasearch";

// algoliaのappid,apikeyを.envから読み込むために必要
require("dotenv").config();

// 変数を色々定義
const basicPath = "./data/";
const allArtilcesPath = basicPath + "all-articles.json";

// こちらのKeyはAdmin API Key
const client = algoliasearch(
  `${process.env.ALGOLIA_APP_ID}`,
  `${process.env.ALGOLIA_ADMIN_KEY}`
);
const index = client.initIndex("kenzo_blog");

// タイムスタンプを含んだファイル名を生成して返す関数
const generateFilename = () => {
  const today = new Date();
  const timeStamp =
    today.getFullYear() +
    String(today.getMonth() + 1).padStart(2, "0") +
    String(today.getDate()).padStart(2, "0") +
    "-" +
    String(today.getTime());
  return basicPath + timeStamp + "-algolia.json";
};

// 既存の「すべての記事」ファイルを読み込んでStringで返す
const generatePastJsonString = () => {
  const pastPostsArray = JSON.parse(fs.readFileSync(allArtilcesPath, "utf8"));
  return JSON.stringify(pastPostsArray);
};

// 既存の「すべての記事」ファイルと
// 現在の「すべての記事」を比較して
// 差分を返す関数
const generatePostsGap = () => {
  const currentAllPostsArray = getSortedPostsData();
  const pastAllPostsString = generatePastJsonString();

  let postsGap: PostData[] = [];
  currentAllPostsArray.forEach((post: PostData) => {
    const stringPost = JSON.stringify(post);

    if (!pastAllPostsString.includes(stringPost)) {
      postsGap.push(post);
    }
  });
  return postsGap;
};

const updateAllArticles = () => {
  const allArtilces = getSortedPostsData();
  fs.writeFile(allArtilcesPath, JSON.stringify(allArtilces), (err) => {
    if (err) throw err;
    console.log(allArtilcesPath + " への書き込みが完了しました。");
  });
};

const createJson = () => {
  const newFile = generateFilename();
  const data = generatePostsGap();

  // 差分があったときのみタイムスタンプ付きのファイルを生成して書き込み & Algoliaへレコードを登録する
  if (data.length !== 0) {
    fs.writeFile(newFile, JSON.stringify(data), (err) => {
      if (err) throw err;
      console.log(newFile + " への書き込みが完了しました。");
    });
    updateAllArticles();
    index
      .saveObjects(data, { autoGenerateObjectIDIfNotExist: true })
      .catch((err) => {
        console.log(err);
      });
  } else {
    console.log(
      "差分が検出されなかったため、JSONファイルは作成されませんでした。"
    );
  }
};

createJson();

最後にこのスクリプトを実行するために必要なファイルを作ります。

tsconfig.builder.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "dist",
    "noEmit": false
  },
  "exclude": ["node_modules"],
  "include": ["builders/*.ts"]
}
# 実行
npx ts-node --project tsconfig.builder.json ./builders/algolia.ts

終わりに

Algolia便利ですね。

Discussion