WEBサイトに検索機能を実装したいけど、サーバー環境の都合上、バックエンドのプログラムが使えない。そんな状況を解決するために、フロントエンドで検索できる方法を探していたらFuse.jsが良さそうだたったのでサンプルサイトを作ってみました。

Fuse.jsとは?

Fuse.js はJavaScriptで書かれた軽量の全文検索ライブラリです。ブラウザ上でもNode.js環境でも動作し、あいまい検索(fuzzy search)を簡単に実装できるのが特徴です。

あいまい検索とは、大文字・小文字の区別、全角・半角の区別、スペルミス、ハイフンのありなし等の表記揺れ、同義語等を完全一致でなくても検索できることを言います。

デモページ

iframeで埋め込んでおります。

ソースコード

コードは8割くらいChatGPTに書いてもらってます。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Fuse.js 楽天ランキング検索(PCパーツ)</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      margin: 2rem;
    }

    .bar {
      display: grid;
      gap: .5rem;
      grid-template-columns: 1fr 120px;
      align-items: center;
    }

    @media (max-width: 640px) {
      .bar {
        grid-template-columns: 1fr;
      }
    }

    input[type="search"],
    button {
      padding: .6rem .8rem;
      font-size: 16px;
    }

    button {
      cursor: pointer;
      border: none;
      border-radius: 8px;
      background: #2563eb;
      color: white;
    }

    button:hover {
      background: #1e40af;
    }

    .meta {
      color: #666;
      font-size: 13px;
      text-align: right;
      margin-top: .5rem;
    }

    .results {
      margin-top: 1rem;
      display: grid;
      gap: 1rem;
    }

    .card {
      border: 1px solid #ddd;
      border-radius: 12px;
      padding: 1rem;
      display: grid;
      grid-template-columns: 96px 1fr;
      gap: .8rem;
    }

    .thumb {
      width: 96px;
      height: 96px;
      object-fit: cover;
      border-radius: 8px;
      background: #f3f4f6;
    }

    .title {
      font-weight: 700;
      margin: 0 0 .25rem;
      line-height: 1.35;
    }

    .shop {
      color: #555;
      font-size: 14px;
      margin-bottom: .25rem;
    }

    .cap {
      color: #333;
      font-size: 14px;
      margin-top: .3rem;
    }

    .desc {
      color: #333;
      font-size: 14px;
      margin-top: .3rem;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    mark {
      background: #fff3b0;
      padding: 0 2px;
      border-radius: 3px;
    }

    a {
      color: #1d4ed8;
      text-decoration: none;
    }

    a:hover {
      text-decoration: underline;
    }
  </style>
  <script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.js"></script>
</head>

<body>
  <h1>Fuse.js 楽天ランキング検索(PCパーツ)</h1>

  <div class="bar">
    <input id="q" type="search" placeholder="キーワード(例:SSD/HDD/メモリ…)" autocomplete="off">
    <button type="button" id="searchBtn">検索</button>
  </div>

  <div class="meta" id="count">rakuten_ranking.json を読み込み後、キーワードを入力して検索してください。</div>
  <div class="results" id="results"></div>

  <script>
    const qEl = document.getElementById("q");
    const resultsEl = document.getElementById("results");
    const countEl = document.getElementById("count");
    const btn = document.getElementById("searchBtn");
    let raw = [], fuse;

    // カタカナ→ひらがな+長音削除
    function normalizeKanaChar(ch) {
      let code = ch.charCodeAt(0);
      let c = ch;
      if (code >= 0x30A1 && code <= 0x30F6) c = String.fromCharCode(code - 0x60);
      if (c === "ー" || c === "゙" || c === "゚") return "";
      return c.toLowerCase();
    }

    function normalizeKana(str) {
      let out = "";
      for (const ch of String(str ?? "")) out += normalizeKanaChar(ch);
      return out;
    }

    function buildKanaMap(original) {
      const mapping = [];
      let normIndex = 0;
      const text = String(original ?? "");
      for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        const normCh = normalizeKanaChar(ch);
        if (!normCh) continue;
        mapping[normIndex] = i;
        normIndex++;
      }
      return mapping;
    }

    // JSON読み込み
    fetch("rakuten_ranking.json")
      .then(r => r.json())
      .then(json => {
        raw = (json.Items || []).map(w => {
          const i = w.Item || {};
          const firstImg = (i.mediumImageUrls && i.mediumImageUrls[0] && i.mediumImageUrls[0].imageUrl) || "";
          return {
            name: i.itemName || "",
            shop: i.shopName || "",
            url: i.itemUrl || "#",
            catchcopy: i.catchcopy || "",
            caption: i.itemCaption || "",
            img: firstImg,
            _norm: {
              name: normalizeKana(i.itemName),
              shop: normalizeKana(i.shopName),
              catchcopy: normalizeKana(i.catchcopy),
              caption: normalizeKana(i.itemCaption)
            }
          };
        });

        if (!raw.length) {
          countEl.textContent = "rakuten_ranking.json に商品データがありません。";
          return;
        }

        fuse = new Fuse(raw, {
          includeScore: true,
          includeMatches: true,
          ignoreLocation: true,
          threshold: 0.3,
          minMatchCharLength: 2,
          keys: [
            { name: "_norm.name", weight: 0.45 },
            { name: "_norm.shop", weight: 0.15 },
            { name: "_norm.catchcopy", weight: 0.20 },
            { name: "_norm.caption", weight: 0.20 }
          ]
        });

        countEl.textContent = "データを読み込みました。検索できます。";
      })
      .catch(err => {
        countEl.textContent = "rakuten_ranking.json の読み込みに失敗しました。";
        console.error(err);
      });

    btn.addEventListener("click", run);
    qEl.addEventListener("keypress", e => { if (e.key === "Enter") run(); });

    function run() {
      if (!fuse) return;
      const query = qEl.value.trim();
      if (!query) {
        resultsEl.innerHTML = "";
        countEl.textContent = "検索キーワードを入力してください。";
        return;
      }

      const qNorm = normalizeKana(query);
      let results = fuse.search(qNorm);
      results.sort((a, b) => (a.score ?? 1) - (b.score ?? 1));
      render(results);
    }

    function render(results) {
      resultsEl.innerHTML = "";
      countEl.textContent = `${results.length} 件`;
      if (!results.length) {
        resultsEl.innerHTML = "<p>該当する商品がありません。</p>";
        return;
      }

      for (const r of results) {
        const { item, matches, score } = r;
        const nm = matches?.filter(m => m.key === "_norm.name") || [];
        const sm = matches?.filter(m => m.key === "_norm.shop") || [];
        const cm = matches?.filter(m => m.key === "_norm.catchcopy") || [];
        const dm = matches?.filter(m => m.key === "_norm.caption") || [];

        const nameHTML = highlightFromIndices(item.name, nm);
        const shopHTML = highlightFromIndices(item.shop, sm);
        const capHTML = highlightFromIndices(item.catchcopy, cm);
        const descHTML = highlightFromIndices(item.caption, dm);

        const card = document.createElement("article");
        card.className = "card";
        card.innerHTML = `
          <img class="thumb" src="${escapeAttr(item.img)}" alt="" onerror="this.style.display='none'">
          <div>
            <h2 class="title"><a href="${escapeAttr(item.url)}" target="_blank" rel="noopener noreferrer">${nameHTML}</a></h2>
            ${item.shop ? `<div class="shop">${shopHTML}</div>` : ""}
            ${item.catchcopy ? `<div class="cap">${capHTML}</div>` : ""}
            ${item.caption ? `<div class="desc">${descHTML}</div>` : ""}
            <div class="meta">スコア: ${typeof score === "number" ? score.toFixed(4) : "―"}</div>
          </div>
        `;
        resultsEl.appendChild(card);
      }
    }

    function highlightFromIndices(originalText, matchesForField) {
      if (!matchesForField?.length) return escapeHTML(originalText ?? "");
      const orig = String(originalText ?? "");
      const mapping = buildKanaMap(orig);
      const normRanges = [];
      for (const m of matchesForField) if (Array.isArray(m.indices)) normRanges.push(...m.indices);
      if (!normRanges.length) return escapeHTML(orig);
      normRanges.sort((a, b) => a[0] - b[0]);
      const mergedNorm = [];
      for (const [s, e] of normRanges) {
        if (!mergedNorm.length || s > mergedNorm[mergedNorm.length - 1][1] + 1) mergedNorm.push([s, e]);
        else mergedNorm[mergedNorm.length - 1][1] = Math.max(mergedNorm[mergedNorm.length - 1][1], e);
      }
      const origRanges = [];
      for (const [ns, ne] of mergedNorm) {
        const os = mapping[ns], oe = mapping[ne];
        if (os != null && oe != null) origRanges.push([os, oe]);
      }
      origRanges.sort((a, b) => a[0] - b[0]);
      const mergedOrig = [];
      for (const [s, e] of origRanges) {
        if (!mergedOrig.length || s > mergedOrig[mergedOrig.length - 1][1] + 1) mergedOrig.push([s, e]);
        else mergedOrig[mergedOrig.length - 1][1] = Math.max(mergedOrig[mergedOrig.length - 1][1], e);
      }
      let out = "", cur = 0;
      for (const [s, e] of mergedOrig) {
        out += escapeHTML(orig.slice(cur, s));
        out += "<mark>" + escapeHTML(orig.slice(s, e + 1)) + "</mark>";
        cur = e + 1;
      }
      out += escapeHTML(orig.slice(cur));
      return out;
    }

    function escapeHTML(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
    function escapeAttr(s) { return escapeHTML(s).replace(/"/g, """); }
  </script>
</body>
</html>

解説

CDNからfuse.jsを読み込みます。

<script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.js"></script>

Fuse.jsはそもそも英語環境で作られたものなので、日本語には対応しておらず、カタカナ・ひらがなの区別ができないため追加します。

    // カタカナ→ひらがな+長音削除
    function normalizeKanaChar(ch) {
      let code = ch.charCodeAt(0);
      let c = ch;
      if (code >= 0x30A1 && code <= 0x30F6) c = String.fromCharCode(code - 0x60);
      if (c === "ー" || c === "゙" || c === "゚") return "";
      return c.toLowerCase();
    }

    function normalizeKana(str) {
      let out = "";
      for (const ch of String(str ?? "")) out += normalizeKanaChar(ch);
      return out;
    }

    function buildKanaMap(original) {
      const mapping = [];
      let normIndex = 0;
      const text = String(original ?? "");
      for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        const normCh = normalizeKanaChar(ch);
        if (!normCh) continue;
        mapping[normIndex] = i;
        normIndex++;
      }
      return mapping;
    }

JSONファイルを読み込みます。
サンプルデータとして楽天市場のPCパーツカテゴリランキングAPIを使用しました。
WEBサーバーにキャッシュしてcronで更新しています。

また、ここでfuse.jsで検索する際のオプションを設定しています。

includeScore

検索スコアを結果に含めるかどうか。
0が完全一致、1が完全不一致です。0に近いほど検索条件にマッチしています。

includeMatches

一致した文字列を検索結果に含めるかどうか。検索キーワードにマッチした文字列を強調表示するために使います。

ignoreLocation

true の場合、検索ワードの位置と距離が無視されるため、ワードが文字列内のどこに出現してもヒットします。

threshold

検索結果のしきい値。1に近いほどあいまいになり、1だとどんなワードでも一致します。

minMatchCharLength

この数字以上の文字列がヒットします。

keys

JSON内の検索対象となるオブジェクトを指定します。またweightにより重要度を指定します。

    // JSON読み込み
    fetch("rakuten_ranking.json")
      .then(r => r.json())
      .then(json => {
        raw = (json.Items || []).map(w => {
          const i = w.Item || {};
          const firstImg = (i.mediumImageUrls && i.mediumImageUrls[0] && i.mediumImageUrls[0].imageUrl) || "";
          return {
            name: i.itemName || "",
            shop: i.shopName || "",
            url: i.itemUrl || "#",
            catchcopy: i.catchcopy || "",
            caption: i.itemCaption || "",
            img: firstImg,
            _norm: {
              name: normalizeKana(i.itemName),
              shop: normalizeKana(i.shopName),
              catchcopy: normalizeKana(i.catchcopy),
              caption: normalizeKana(i.itemCaption)
            }
          };
        });

        if (!raw.length) {
          countEl.textContent = "rakuten_ranking.json に商品データがありません。";
          return;
        }

        fuse = new Fuse(raw, {
          includeScore: true,
          includeMatches: true,
          ignoreLocation: true,
          threshold: 0.3,
          minMatchCharLength: 2,
          keys: [
            { name: "_norm.name", weight: 0.45 },
            { name: "_norm.shop", weight: 0.15 },
            { name: "_norm.catchcopy", weight: 0.20 },
            { name: "_norm.caption", weight: 0.20 }
          ]
        });

        countEl.textContent = "データを読み込みました。検索できます。";
      })
      .catch(err => {
        countEl.textContent = "rakuten_ranking.json の読み込みに失敗しました。";
        console.error(err);
      });

検索結果をHTMLに出力します。

      for (const r of results) {
        const { item, matches, score } = r;
        const nm = matches?.filter(m => m.key === "_norm.name") || [];
        const sm = matches?.filter(m => m.key === "_norm.shop") || [];
        const cm = matches?.filter(m => m.key === "_norm.catchcopy") || [];
        const dm = matches?.filter(m => m.key === "_norm.caption") || [];

        const nameHTML = highlightFromIndices(item.name, nm);
        const shopHTML = highlightFromIndices(item.shop, sm);
        const capHTML = highlightFromIndices(item.catchcopy, cm);
        const descHTML = highlightFromIndices(item.caption, dm);

        const card = document.createElement("article");
        card.className = "card";
        card.innerHTML = `
          <img class="thumb" src="${escapeAttr(item.img)}" alt="" onerror="this.style.display='none'">
          <div>
            <h2 class="title"><a href="${escapeAttr(item.url)}" target="_blank" rel="noopener noreferrer">${nameHTML}</a></h2>
            ${item.shop ? `<div class="shop">${shopHTML}</div>` : ""}
            ${item.catchcopy ? `<div class="cap">${capHTML}</div>` : ""}
            ${item.caption ? `<div class="desc">${descHTML}</div>` : ""}
            <div class="meta">スコア: ${typeof score === "number" ? score.toFixed(4) : "―"}</div>
          </div>
        `;
        resultsEl.appendChild(card);
      }
    }

検索した文字列をハイライトさせます。

    function highlightFromIndices(originalText, matchesForField) {
      if (!matchesForField?.length) return escapeHTML(originalText ?? "");
      const orig = String(originalText ?? "");
      const mapping = buildKanaMap(orig);
      const normRanges = [];
      for (const m of matchesForField) if (Array.isArray(m.indices)) normRanges.push(...m.indices);
      if (!normRanges.length) return escapeHTML(orig);
      normRanges.sort((a, b) => a[0] - b[0]);
      const mergedNorm = [];
      for (const [s, e] of normRanges) {
        if (!mergedNorm.length || s > mergedNorm[mergedNorm.length - 1][1] + 1) mergedNorm.push([s, e]);
        else mergedNorm[mergedNorm.length - 1][1] = Math.max(mergedNorm[mergedNorm.length - 1][1], e);
      }
      const origRanges = [];
      for (const [ns, ne] of mergedNorm) {
        const os = mapping[ns], oe = mapping[ne];
        if (os != null && oe != null) origRanges.push([os, oe]);
      }
      origRanges.sort((a, b) => a[0] - b[0]);
      const mergedOrig = [];
      for (const [s, e] of origRanges) {
        if (!mergedOrig.length || s > mergedOrig[mergedOrig.length - 1][1] + 1) mergedOrig.push([s, e]);
        else mergedOrig[mergedOrig.length - 1][1] = Math.max(mergedOrig[mergedOrig.length - 1][1], e);
      }
      let out = "", cur = 0;
      for (const [s, e] of mergedOrig) {
        out += escapeHTML(orig.slice(cur, s));
        out += "<mark>" + escapeHTML(orig.slice(s, e + 1)) + "</mark>";
        cur = e + 1;
      }
      out += escapeHTML(orig.slice(cur));
      return out;
    }

こんな感じでキーワードが黄色くハイライトされます。