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;
}
こんな感じでキーワードが黄色くハイライトされます。
