Algoliaの検索結果のSnippetにHighlight用のタグが付かないのをどうにかする。

このブログの全文検索は Algolia を使用しています。
Algolia には Snippet 用の API があり、ヒットしたワードに Highlight 用のタグを付けて返してくれます。

attributesToSnippet API parameter | Algolia

value (string): Markup text with occurrences highlighted and optional ellipsis indicators. The tags used for highlighting are specified via highlightPreTag and highlightPostTag.

Snippet ではなく Highlight された全文を取得することもできます。

attributesToHighlight API parameter | Algolia

attributesToSnippet, attributesToHighlight ではそれぞれ次の json をレスポンスとして返してくれます。

{
  "objectID": "225",
  // attributesToSnippet
  "_snippetResult": {
    // 検索結果を省略して、ヒットしたキーワードを __ais-highlight__ で挟んでくれる。
    "content": {
      "value": "… がいいところや、一旦変数にしたほうがいい箇所はもろもろ対応するときに直します。 v3.1からイベント__ais-highlight__設定__/ais-highlight__画面で入力可能な要素を全て__ais-highlight__設定__/ais-highlight__できるようにしました。自分で使ってすごく便利だったの …",
      "matchLevel": "partial"
    }
  },
  // attributesToHighlight
  "_highlightResult": {
    // 検索結果の全文を取得して、ヒットしたキーワードを __ais-highlight__ で挟んでくれる。
    "content": {
      "value": "<略>がいいところや、一旦変数にしたほうがいい箇所はもろもろ対応するときに直します。 v3.1からイベント__ais-highlight__設定__/ais-highlight__画面で入力可能な要素を全て__ais-highlight__設定__/ais-highlight__できるようにしました。自分で使ってすごく便利だったの<略>",
      "matchLevel": "partial",
      "fullyHighlighted": false,
      "matchedWords": ["設定"]
    }
  }
}

このブログでは Snippet の方を利用したいのですが、キーワードによっては Highlight された結果が返ってこないことがあります。

{
  "objectID": "128",
  // attributesToSnippet
  "_snippetResult": {
    // 期待と異なり、検索結果の省略はされるが、** キーワードがヒットしていない。 **
    "content": {
      "value": "Tutorial: OAuth – Google Chrome chrome_ex_oauth.jsがTwitterで使えなくなっていたので、OAuth.ioのoauth-jsを利用してChrome拡張からTwitterのAccessTokenとSecretを取得してみました。 作業自体は10分あれば …",
      "matchLevel": "none"
    }
  },
  // attributesToHighlight
  "_highlightResult": {
    // 期待通り、検索結果の全文を取得して、ヒットしたキーワードを __ais-highlight__ で挟んでくれる。
    "content": {
      "value": "<略>Consumer Keyとsecretを__ais-highlight__設定__/ais-highlight__する。 <略>",
      "matchLevel": "partial",
      "fullyHighlighted": false,
      "matchedWords": ["設定"]
    }
  }
}

どうも_snippetResult_highlightResultmatchLevelが異なるようで、違う結果が返ってきてしまうようです。 結果を同じにする方法を探したのですが、どうしてこうなるのかわからなかったのと、ドキュメントを読む限り無理そうなので、_highlightResultからキーワード付近の文字列を切り出して力業で Snippet にします。

使用する UI Library は React InstantSearch v7 で、tsx を使って実装します。 クライアント側の処理になるのでサーバーサイドで使用するライブラリは import していません。

import algoliasearch from "algoliasearch/lite";
import { InstantSearch, SearchBox, PoweredBy, Hits } from "react-instantsearch";
import React from "react";
import type { Hit as AlgoliaHit } from "@algolia/client-search";

// 自分のレコードで使用しているattributeを定義する
type HitProps = {
  hit: AlgoliaHit<{
    content: string;
    slug: string;
    title: string;
  }>;
};

const searchClient = algoliasearch(
  import.meta.env.PUBLIC_ALGOLIA_APPID,
  import.meta.env.PUBLIC_ALGOLIA_APIKEY
);

// Snippet全体の文字数
const snippetLength = 140;
// ヒットした文字列の前から抜き出したい文字数
const frontLength = 30;

const HitCompoment = ({ hit }: HitProps) => {
  const contentStr = hit._highlightResult?.content?.value ?? "";
  // ヒットしたキーワードの周辺の文字列を切り出す
  // React InstantSearchではヒットした箇所は<mark>タグで囲まれるので<mark>を探す
  const snippetContent = contentStr
    .substring(contentStr.search(/<mark>/) - frontLength) // <mark>が見つからない場合に全文を抜き出したいのでsubstringを使う
    .slice(0, snippetLength);

  // dangerouslySetInnerHTMLなしで<mark>タグをパースしたいのでDOMParserを呼び出す
  const parser = new DOMParser();

  return (
    <>
      <h3>
        <a href={`${hit.slug}`}>{hit.title}</a>
      </h3>
      <p>
        <>... </>
        {[
          ...parser.parseFromString(snippetContent, "text/html").body
            .childNodes,
        ].map((child, i) => {
          // <mark>タグだけhtmlとして処理する
          if (child.nodeName.toLowerCase() === "mark")
            return <mark key={i}>{child.textContent}</mark>;
          return <React.Fragment key={i}>{child.textContent}</React.Fragment>;
        })}
        <> ...</>
      </p>
    </>
  );
};

const indexName = "";
const AlgoliaSearchBox = () => {
  return (
    <InstantSearch searchClient={searchClient} indexName={indexName}>
      <SearchBox />
      <PoweredBy />
      <Hits hitComponent={HitCompoment} />
    </InstantSearch>
  );
};

export default AlgoliaSearchBox;

相当インチキ臭い実装になってしまいました。あくまでも応急処置(のつもり)なので、Algolia 側の改善を待ちたいと思います。

なお、実際に書いたコードはこれに色々処理を追加しているのでだいぶ異なります。

astro-blog/src/layouts/Search.tsx at 32a4dd07fa9d238b959914d496f8e455c737def0 · retrorocket/astro-blog