この記事は、筆者(Liquid Block)が開発している Chrome 拡張「LB TAB Layers」の検索窓で使っている仕組みを、ほかのアプリにも持ち帰れる形で解説するものです。載せているコードは、実物を読みやすく書き直したもので、一部は簡略化しています。
はじめに
筆者が開発している Chrome 拡張 LB TAB Layers のサイドパネルには、グループ名・保存したタブ・開いているタブ・今日の閲覧履歴をまとめて探す検索窓があります。
【画像:サイドパネルの検索窓に「めーる」と打つと Gmail のタブが出る様子(GIF・3〜5秒)】
この検索窓に、Gmail を探すつもりで「Gmail」ではなく「めーる」「メール」と打つ人がいます。YouTube を探すのに「ようつべ」と打つ人もいます。そこで「めーる → gmail」のような呼び名の辞書を足しました。すると今度は、「メール」で探すと「メルカリ」が出るようになりました。
この記事では、このバグを最小のコードで再現し、どう直したかを順に説明します。最終的にできたのは、外部ライブラリなし・本体約200行(コメント込み。呼び名辞書は別ファイル)の検索エンジンです。
この記事でわかること
- 全角/半角・カタカナ/ひらがな・長音の表記ゆれを、誤ヒットさせずにそろえる方法(正規化を2系統に分ける)
- 「ようつべ → YouTube」のような呼び名辞書の引き方と、その落とし穴
- 完全一致から飛び飛び一致までの段階スコアで、関連度順に並べる方法
- ブラウザ用のスクリプトに node:test で回帰テストを付ける方法
動作環境
| 項目 | 内容 |
|---|---|
| 言語 | 素の JavaScript(ES2017 以降。外部ライブラリなし) |
| テスト | Node.js v24.13.0 の node:test |
| 拡張機能として | Chrome 114 以上(Manifest V3) |
1. 最初の実装と「メール → メルカリ」
まず、問題が起きていたころの実装を、最小限に書き直して再現します。辞書は2件あれば足ります。
// 旧実装を最小限に書き直したもの(実物より簡略化)
const ALIASES = {
'める': ['mail', 'gmail'],
'めるかり': ['mercari'],
};
// 小文字化 → カタカナをひらがなへ → 長音を消す
function normalize(s) {
return s.toLowerCase()
.replace(/[ァ-ヶ]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0x60))
.replace(/[ーー]/g, '')
.trim();
}
function matchesOld(title, url, query) {
const q = query.toLowerCase(), t = title.toLowerCase(), u = url.toLowerCase();
if (t.includes(q) || u.includes(q)) return true;
const nq = normalize(query); // 'メール' → 'める'
for (const [key, aliases] of Object.entries(ALIASES)) {
if (key.includes(nq) || nq.includes(key)) { // 'めるかり'.includes('める') → true
if (aliases.some(a => t.includes(a) || u.includes(a))) return true;
}
}
return false;
}
matchesOld('メルカリ', 'https://jp.mercari.com/', 'メール'); // → true(誤ヒット)
処理を追うと、こうなります。
- 「メール」を正規化すると、長音が消えて「める」になる
- 辞書の見出しを部分一致で引いているので、
'めるかり'.includes('める')が true になる - 「めるかり」の別名
mercariが、URL のjp.mercari.comに含まれる → 一致
原因は1つではなく、①長音を消した形で比べたことと、②辞書の見出しを部分一致で引いたことの2つが重なっていました。
当時はほかにも、次のような症状がありました。
- 辞書に大文字で書いた見出し(
"DM"など)には、一度も当たっていませんでした。クエリは小文字にしてから比べるのに、見出しのほうは正規化していなかったためです - 画面ごとに探し方がバラバラで、一部の画面は単純な
includes()だけでした。並び順も、関連度ではなく登録順でした
ここで1つ区別しておきたいのは、「める」と打ってメルカリが出るのは正しいということです。「める」は「めるかり」の打ちかけかもしれないからです。新しい実装でも、「める」→ メルカリは 100.17 点で上位に出ます(辞書ではなく、正規化したタイトル「めるかり」への先頭一致です)。問題は、「メール」と打った人にメルカリを見せることでした(新しい実装では 0 点、つまり不一致)。
2. 正規化を「照合用」と「辞書用」の2系統に分ける
直し方の中心は、正規化を2つに分けることです。
// 照合用:NFKC → 小文字 → カタカナをひらがなへ → 空白を1つに。長音「ー」は残す
function normText(s) {
let t = String(s == null ? '' : s);
try { t = t.normalize('NFKC'); } catch (e) { /* normalize が無い古い環境 */ }
return t.toLowerCase()
.replace(/[ァ-ヶ]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0x60))
.replace(/\s+/g, ' ')
.trim();
}
// 辞書を引く専用:照合用の結果から、さらに長音を消す
function normKey(s) {
return normText(s).replace(/[ーー]/g, '').trim();
}
normText('メール'); // 'めーる'
normKey('メール'); // 'める'
normText('メール'); // 'めーる'
normText('GitHub'); // 'github'
| 入力 | normText(照合用) | normKey(辞書用) |
|---|---|---|
| メール | めーる | める |
| メール(半角カナ) | めーる | める |
| GitHub(全角英字) | github | github |
ポイントは3つあります。
NFKC を最初にかける。 NFKC 正規化は、全角英数字を半角に、半角カナを全角カナにそろえます。ガ のように濁点が別の文字になっている半角カナも、「ガ」の1文字になります。この後のカタカナ→ひらがな変換は全角カナの範囲しか見ないので、先に半角カナを全角にしておく必要があります。実際、NFKC をかけていなかった旧関数では、「メール」が「メル」のまま残り、辞書に届いていませんでした。
カタカナ→ひらがなは、コードポイントから 0x60 を引くだけ。 Unicode では、ァ(U+30A1)〜ヶ(U+30F6)と ぁ(U+3041)〜ゖ(U+3096)が同じ順番で並んでいるからです。
空白を1つにまとめる。 後でスペース区切りの AND 検索にするので、連続した空白や全角スペース(NFKC で半角になります)をここでそろえておきます。
そして一番大事なのは、normKey は辞書を引くときだけ使い、照合には使わないことです。照合を長音つきの「めーる」で行えば、「めーる」は「めるかり」に含まれないので、メルカリ問題は起きません。
カタカナ→ひらがなの範囲(ァ〜ヶ)には ヴ・ヵ・ヶ も含まれていて、それぞれ ゔ・ゕ・ゖ になります。また、NFKC でも「ヶ」と「ケ」はそろいません。Node v24.13.0 で確かめると、「霞ヶ関」は「霞ゖ関」、「霞ケ関」は「霞け関」になり、別の文字列のままでした。地名などで両方の表記がありうるなら、置き換えを別に足す必要があります。
3. 呼び名辞書を Map にして、完全一致で引く
辞書の中身
辞書は「見出し(ひらがなが基本)→ 別名の配列」のオブジェクトです。メール・チャット・SNS・動画・地図・AI など25の区分に Google の各サービスを足して、ソース上には 1,058 件の見出しが書かれています(「める」と「めーる」のように、正規化すると同じになるものをまとめると 986 件)。
値には英単語だけでなく、ホスト名やパスの断片も入れています。「みと」→ meet.google、「さちこ」→ search.google.com/search-console のように書いておけば、タイトルに目印の語が無いページでも URL で拾えます。
// 呼び名辞書(抜粋)。見出しはひらがなが基本。値は英単語でもホスト名の断片でもよい
const SEARCH_ALIASES = {
'める': ['mail', 'gmail', 'outlook', 'yahoo'],
'めーる': ['mail', 'gmail', 'outlook', 'yahoo'],
'ようつべ': ['youtube'],
'みと': ['meet.google'],
'さちこ': ['search.google.com/search-console'],
'DM': ['dm', 'direct', 'message'],
};
// 初回だけ Map<正規化した見出し, Set<正規化した別名>> を作る
let _index = null;
function aliasIndex() {
if (_index) return _index;
_index = new Map();
for (const [k, vals] of Object.entries(SEARCH_ALIASES)) {
const key = normKey(k); // 'DM' → 'dm'、'めーる' → 'める'
if (!key) continue;
const set = _index.get(key) || new Set(); // 同じ見出しになったものは合流させる
vals.forEach(v => { const n = normText(v); if (n) set.add(n); });
_index.set(key, set);
}
return _index;
}
見出しも別名も正規化してから Map に入れるので、大文字の "DM" も dm として引けます。「める」と「めーる」のように正規化後に同じになる見出しは、1つの Set に合流します。
引き方:完全一致と、3文字以上のときだけ前方一致
// 見出しは完全一致で引く。3文字以上のときだけ前方一致も許す(打ちかけ対応、最大6見出し)
function aliasesFor(tok) {
const key = normKey(tok);
if (!key) return [];
const idx = aliasIndex();
const out = new Set(idx.get(key) || []);
if (key.length >= 3) {
let n = 0;
for (const [k, set] of idx) {
if (k !== key && k.startsWith(key)) {
set.forEach(v => out.add(v));
if (++n >= 6) break;
}
}
}
out.delete(tok); // 打った語そのものは別名に数えない
return [...out];
}
// 3文字以下の短い別名(go / tv / ai など)は、単語の頭でしか当てない
function aliasHit(alias, text) {
if (!text) return false;
if (alias.length > 3) return text.includes(alias);
for (let k = text.indexOf(alias); k >= 0; k = text.indexOf(alias, k + 1)) {
if (isBoundary(text, k)) return true;
}
return false;
}
見出しは完全一致で引くのが基本です。ただ、インクリメンタルサーチでは「ぐーぐるま」のような打ちかけが必ず来るので、正規化後に3文字以上のときだけ前方一致も許しています(足す見出しは最大6件)。「ぐーぐるま」は「ぐぐるま」になり、「ぐーぐるまっぷ」の別名 maps.google が引かれます。
この「3文字以上」は、メルカリ問題に対する2つ目の守りにもなっています。「メール」を normKey にすると「める」の2文字なので、前方一致は働かず、「めるかり」の別名は引かれません。旧実装の key.includes(nq) || nq.includes(key) と比べると、引く範囲がずっと狭くなっています。
もう1つの工夫が aliasHit です。辞書には go・tv・ai のような短い別名もあります。これを includes で当てると、go がすべての google に当たり、「えーあい」の別名 ai が mail の中にも当たってしまいます。そこで3文字以下の別名は、次の章で説明する「単語の頭」でしか当てないようにしました。
落とし穴1:オブジェクトリテラルの重複キーは、黙って後勝ちになる
大きなオブジェクトリテラルに同じキーを2回書いても、エラーにはなりません。ES2015 以降は strict モードでも許されていて、後に書いた値で上書きされます。
この記事を書くにあたって辞書を数えてみると、見出しの重複が10件ありました。8件は同じ値どうしの重複でしたが、残りの2件では、先に書いた値の一部が消えていました。たとえば「あまぞんぷらいむ」は、先に書いた ["primevideo", "amazon"] が後の ["amazon", "prime"] で上書きされ、この見出しからは primevideo が消えていました(primevideo はほかの見出しには残っていました)。「とうろく」でも同じように subscribe が消えていました。
正規化後に重なる見出しは Map と Set で合流できますが、リテラルの段階で消えた値は、実行時には取り戻せません。こうした重複は、辞書ファイルをテキストとして読み、見出しを数えるテストで見つけられます。
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
// 簡略版:見出しをダブルクォートで書き、1行に1つずつ並べている辞書が前提
test('辞書の見出しが重複していない', () => {
const src = fs.readFileSync(require.resolve('../src/js/search-dict.js'), 'utf8');
const count = new Map();
for (const m of src.matchAll(/^\s*"([^"]+)"\s*:/gm)) {
count.set(m[1], (count.get(m[1]) || 0) + 1);
}
const dupes = [...count].filter(([, n]) => n > 1).map(([k]) => k);
assert.deepEqual(dupes, []); // 失敗すると、重複した見出しの一覧が差分に出る
});
見つけた10件は、次のように直しました(v2.8.73)。
- 値が消えていた2件は、前後の値をまとめました(例:「あまぞんぷらいむ」は
["primevideo", "amazon", "prime"]) - 同じ値どうしの8件は、後の行を消しました
実際のプロジェクトでは、上の簡略版の代わりに、シングルクォートやコメントにも対応した小さな字句解析で一番外側のキーだけを数えるテスト(tests/search-dict.test.js)を足しています。読み込んだあとの値からは重複が分からないので、ソースの字面を読むのがポイントです。直したあとの辞書では、このテストを含めて検索のテストがすべて通ります。
ESLint を使っているなら、no-dupe-keys ルール(recommended に含まれています)でも同じものを検出できます。
落とし穴2:スペースを含む見出しと、長音を消すと短くなる入力
- クエリはスペースで区切って AND 検索にする(後述)ので、
"LINE Pay"のようにスペースを含む見出しには、完全一致では届きません。いまは「line」と打ったときの前方一致で、たまたま拾われているだけです。見出しにはスペースを入れないか、入れるならテストで弾くのが安全です。 - 前方一致の「3文字以上」は、長音を消した後の長さで判定します。「ぐー」は「ぐ」、「ぐーぐ」は「ぐぐ」になるので、別名は1つも引かれません。正規化後に3文字になる「ぐーぐる」で、初めて
googleなどに広がります。
4. 段階スコアで関連度順に並べる
「当たったかどうか」だけでは、結果が登録順に並んでしまいます。そこで、1つの語が1つのフィールド(タイトルなど)にどう当たったかを、次の段階で採点します。
| 点数 | 条件 | 例(クエリ → 対象) |
|---|---|---|
| 100 | 完全一致 | google → Google |
| 90 | 先頭一致 | google → Google ドライブ |
| 75 | 単語の頭で一致 | google → 設定 - Google アカウント |
| 55 | 途中で一致 | go → algorithm |
| 50 | 辞書の別名で一致 | めーる → 受信トレイ - Gmail |
| 42 | 1文字違い(英数字4文字以上) | gogle → Google |
| 20〜40 | 飛び飛びに一致(英数字3文字以上) | ytb → YouTube |
このうち「辞書の別名」の 50 点は、次の章の score の中で付けます。それ以外は fieldScore が返します。
「単語の頭」の判定
const isAlnum = ch => /[a-z0-9]/.test(ch || '');
// text の i 文字目が「単語の頭」か
// - 先頭、または記号・空白(和文の約物を含む)の直後
// - 英数字と、英数字以外の境目("gmailの受信" の「の」など)
function isBoundary(text, i) {
if (i <= 0) return true;
const prev = text[i - 1];
if (/[\s\-_./:?#=&()[\]|・、。「」【】『』"'@+,]/.test(prev)) return true;
return isAlnum(prev) !== isAlnum(text[i]);
}
function fieldScore(tok, text) {
if (!text) return 0;
const i = text.indexOf(tok);
if (i === 0) return text.length === tok.length ? 100 : 90; // 完全一致 / 先頭一致
if (i > 0) {
// 最初の出現が途中でも、後ろに単語の頭での一致があればそちらを採る
for (let k = i; k >= 0; k = text.indexOf(tok, k + 1)) {
if (isBoundary(text, k)) return 75; // 単語の頭
}
return 55; // 途中
}
if (/^[a-z0-9]+$/.test(tok)) { // あいまい一致は英数字だけ(下の2つの関数)
const sub = tok.length >= 3 ? subsequenceScore(tok, text) : 0; // 飛び飛び 20〜40
const typo = tok.length >= 4 ? typoScore(tok, text) : 0; // 1文字違い 42
return Math.max(sub, typo);
}
return 0; // 日本語は飛び飛び一致をしない
}
isBoundary は、i 文字目の直前が区切りかどうかを見ます。区切りには、空白や URL の記号だけでなく、和文の約物(・、。「」【】『』)も入れています。さらに、英数字と英数字以外の境目も単語の頭とみなします。日本語の文には空白が無いので、この判定がないと「gmailの受信」の「の」や「設定のgoogle」の「g」が単語の頭になりません。
fieldScore では、最初に見つかった位置が単語の途中でも、すぐに 55 点にはしません。後ろにもう一度出てきて、そちらが単語の頭なら 75 点を採ります。
1文字違いを O(n) で判定する
// "gogle" → "google"(1文字抜け)、"googel" → "google"(隣り合う2文字の入れ替え)
function withinOneEdit(a, b) {
if (a === b) return true;
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > 1) return false;
let i = 0;
while (i < la && i < lb && a[i] === b[i]) i++; // 共通の先頭を飛ばす
if (la === lb) {
if (a.slice(i + 1) === b.slice(i + 1)) return true; // 置換
return a[i] === b[i + 1] && a[i + 1] === b[i] // 隣り合う2文字の入れ替え
&& a.slice(i + 2) === b.slice(i + 2);
}
return la > lb ? a.slice(i + 1) === b.slice(i) // a のほうが1文字多い
: a.slice(i) === b.slice(i + 1); // b のほうが1文字多い
}
// テキストを英数字の単語に分け、4文字以上の単語と1文字違いなら 42 点
function typoScore(tok, text) {
const words = text.split(/[^a-z0-9]+/);
return words.some(w => w.length >= 4 && withinOneEdit(tok, w)) ? 42 : 0;
}
編集距離を動的計画法で求めると O(n×m) かかりますが、「1文字違いかどうか」だけなら、共通の先頭を飛ばした後の残りを1回比べれば済みます。置換・挿入・削除に加えて、隣り合う2文字の入れ替え(Damerau 型)も1文字違いとして扱います。
飛び飛び一致の条件
飛び飛び一致は、ytb → youtube のように、クエリの文字が順番どおりに出てくれば一致とみなすものです。ゆるすぎるとゴミが混ざるので、次の条件をすべて満たすときだけ認めています。
- クエリが英数字だけで、3文字以上
- クエリの1文字目が、テキストの単語の頭にある
- 一致した範囲の長さが「2×クエリの長さ+2」以下。詰まっているほど高く、40 点から1文字広がるごとに 2 点ずつ下げ、最低 20 点
// 飛び飛びの一致("ytb" → "youtube")。1文字目が単語の頭にあり、詰まっているときだけ認める
function subsequenceScore(tok, text) {
let best = 0;
for (let start = text.indexOf(tok[0]); start >= 0; start = text.indexOf(tok[0], start + 1)) {
if (!isBoundary(text, start)) continue; // 1文字目は単語の頭に限る
let j = 1, i = start + 1;
for (; i < text.length && j < tok.length; i++) {
if (text[i] === tok[j]) j++; // 残りの文字を順番どおりに探す
}
if (j < tok.length) continue; // 最後の文字まで見つからなかった
const span = i - start; // 一致した範囲の長さ
if (span > tok.length * 2 + 2) continue; // 間延びしすぎ
best = Math.max(best, Math.max(20, 40 - (span - tok.length) * 2));
}
return best;
}
ytb を youtube に当てると、範囲は youtub の6文字なので 40−(6−3)×2=34 点です。
日本語にはあいまい一致を使わない
1文字違いと飛び飛び一致は、英数字のクエリにだけ使っています。日本語は1文字あたりの情報量が大きいので、飛び飛び一致を許すと無関係な結果が混ざります。たとえば「会予」で「会議室の予約」を探すと、日本語にも許した場合は 34 点で当たってしまいます。日本語は正規化と呼び名辞書で補い、あいまい一致はしない、と割り切りました。
5. score と rank:フィールドの重み、AND、同点処理
ここまでの部品をまとめて、1件のデータ(タイトル・URL・メモ)に点を付けるのが score、リストを並べ替えるのが rank です。
const WEIGHT = { title: 1.0, host: 0.9, url: 0.8, notes: 0.5, path: 0.4 };
// fields = { title, url, notes }。0 は「一致しない」
function score(fields, query) {
const prepared = prepare(query); // [{ tok, aliases }]
if (!prepared.length) return 1; // 空のクエリは全件一致
const title = normText(fields.title), notes = normText(fields.notes);
const { host, path } = splitUrl(fields.url);
const full = normText(fields.url);
let total = 0, allInTitle = true;
for (const { tok, aliases } of prepared) {
const t = fieldScore(tok, title) * WEIGHT.title;
if (t < 55) allInTitle = false;
let s = Math.max(t,
fieldScore(tok, host) * WEIGHT.host,
fieldScore(tok, notes) * WEIGHT.notes,
fieldScore(tok, path) * WEIGHT.path);
if (s < 50 && aliases.length) { // 直接の一致が弱いときだけ辞書を使う
if (aliases.some(a => aliasHit(a, title) || aliasHit(a, host))) s = Math.max(s, 50);
else if (aliases.some(a => a.length > 3 && full.includes(a))) s = Math.max(s, 50 * WEIGHT.url);
}
if (s <= 0) return 0; // AND:1語でも外れたら除外
total += s;
}
// 全語がタイトルに当たれば +10。同点ならタイトルが短いほうを上に(1点未満の加点)
return total + (allInTitle ? 10 : 0) + 1 / (2 + title.length);
}
// 同じクエリで続けて呼ばれたら、正規化と別名の展開を使い回す
let _lastQ = null, _lastPrepared = null;
function prepare(query) {
if (query === _lastQ && _lastPrepared) return _lastPrepared;
const tokens = normText(query).split(' ').filter(Boolean); // スペース区切り → AND
_lastQ = query;
_lastPrepared = tokens.map(tok => ({ tok, aliases: aliasesFor(tok) }));
return _lastPrepared;
}
// URL をホスト(先頭の www. を除く)と、パス以降に分ける
function splitUrl(url) {
const m = String(url || '').match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)(.*)$/i);
if (!m) return { host: '', path: normText(url) };
return { host: normText(m[1]).replace(/^www\./, ''), path: normText(m[2]) };
}
function rank(list, query, toFields) {
if (!prepare(query).length) return list.slice();
return list
.map((item, i) => ({ item, i, s: score(toFields(item), query) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s || a.i - b.i) // 同点は元の順
.map(x => x.item);
}
ルールを整理すると、次のとおりです。
- フィールドの重み:タイトル 1.0、ホスト 0.9、URL 全体 0.8、メモ 0.5、パス 0.4
- 辞書は補欠:直接の一致が 50 点未満のときだけ別名を使います。タイトルかホストに別名が当たれば 50 点、4文字以上の別名が URL 全体に含まれれば 40 点(50×0.8)です。「さちこ」→ Search Console は、この 40 点の経路で当たります
- AND:スペースで区切った語のうち、1語でも当たらなければ、その項目は除外します
- ボーナス:すべての語がタイトルに 55 点以上で当たれば +10 点
- 同点処理:1/(2+タイトルの長さ) を足します。1点未満の小さな加点で、タイトルが短い(的を射ている)ほうが上に来ます
-
並べ替え:点数が同じなら元の順番
iで比べるので、結果が毎回同じ順に並びます
prepare は、直前と同じクエリならキャッシュを返します。rank は1回のキー入力でリストの全件に score を呼ぶので、クエリの正規化と別名の展開を件数分やり直さないためです。
テストで使っている4件を「google」で並べると、点数は次のようになります。
| タイトル | URL | 点数 |
|---|---|---|
https://www.google.com/ |
110.13 | |
| Google ドライブ | https://drive.google.com/ |
100.08 |
| 設定 - Google アカウント | https://myaccount.google.com/ |
85.05 |
| 検索結果 | https://example.com/search?q=google |
30.17 |
同点処理の加点は1点未満ですが、「順位が逆転することはない」とまでは言えません。重みを掛けた後の差が 0.3 点しかない組み合わせ(ホストの1文字違い 42×0.9=37.8 点と、メモの単語の頭での一致 75×0.5=37.5 点)では、タイトルの長さしだいで順位が入れ替わります。実害はほとんどないので、いまはそのままにしています。
6. node:test で回帰テストを書く
この検索エンジンは、ブラウザでは <script> で読み込むクラシックスクリプトで、バンドラは使っていません。それでも Node の node:test でテストできるように、ファイルの末尾で次のように書き出しています。
// ---- search.js ----
const LBSearch = (() => {
/* normText / normKey / score / matches / rank …… */
return { normText, normKey, score, matches, rank };
})();
// ブラウザには module が無いので、この if は何もしない
if (typeof module !== 'undefined' && module.exports) {
module.exports = { LBSearch };
}
// ---- search-dict.js ----
if (typeof module !== 'undefined' && module.exports) {
globalThis.SEARCH_ALIASES = SEARCH_ALIASES; // Node のときだけ globalThis にも置く
module.exports = { SEARCH_ALIASES };
}
ブラウザでは、クラシックスクリプトのトップレベルの const は、別の <script> からも名前で参照できます(window のプロパティにはなりません)。一方、Node の require ではファイルごとにスコープが分かれるので、search.js から辞書が見えません。そこで search.js 側では、typeof SEARCH_ALIASES !== 'undefined' なら名前で、そうでなければ globalThis.SEARCH_ALIASES を、という順に辞書を探しています。
テストは、過去に踏んだ不具合をそのまま固定する形で書いています(抜粋して1つの describe にまとめました)。
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
require('../src/js/search-dict.js'); // globalThis.SEARCH_ALIASES を用意
const { LBSearch: S } = require('../src/js/search.js');
const g = (title, url, notes) => ({ title, url, notes });
describe('LBSearch', () => {
it('照合用は長音を残し、辞書用は長音を消す', () => {
assert.equal(S.normText('メール'), 'めーる');
assert.equal(S.normKey('メール'), 'める');
});
it('「メール」で「メルカリ」に当たらない(旧実装の誤ヒット)', () => {
assert.equal(S.matches(g('メルカリ', 'https://jp.mercari.com/'), 'メール'), false);
});
it('大文字の辞書見出し(DM)も引ける', () => {
assert.ok(S.matches(g('Instagram', 'https://www.instagram.com/direct/inbox/'), 'DM'));
});
it('短い別名は単語の頭でしか当たらない', () => {
assert.equal(S.matches(g('Algorithm Notes', 'https://notes.example.com/algorithm'), 'ごー'), false);
});
it('日本語は飛び飛び一致をしない', () => {
assert.equal(S.matches(g('会議室の予約', 'https://a.com'), '会予'), false);
});
});
検索のテストは全部で15本あります。執筆時点で Node v24.13.0 で実行した結果は次のとおりです。
$ node --test tests/search.test.js
(各テストの結果は省略)
ℹ tests 15
ℹ suites 4
ℹ pass 15
ℹ fail 0
(以下略)
7. Chrome 拡張で使う
chrome.bookmarks.search を使わず、全件を自前で採点する
Chrome には chrome.bookmarks.search という検索 API がありますが、文字列で探すだけなので、カナの統一も呼び名辞書も効きません。実際、拡張のダッシュボード画面で「メール」と探しても、ふつうのブックマークに入っている Gmail は出てきませんでした。そこで、getTree で全ブックマークを取り出し、タブと同じ rank にかけています。
// chrome.bookmarks.search は単純な文字列一致なので、「めーる」で Gmail は出ない。
// 全件を取り出して、タブと同じ rank() で採点する
// (実物は、拡張自身が管理するフォルダなどを除いてから採点している)
async function searchBookmarks(query) {
const tree = await chrome.bookmarks.getTree();
const pool = [];
(function walk(n) {
if (n.url) pool.push(n);
(n.children || []).forEach(walk);
})(tree[0]);
return LBSearch.rank(pool, query, b => ({ title: b.title, url: b.url })).slice(0, 200);
}
閲覧履歴も同じ考え方です。chrome.history.search で今日の0時以降の履歴を最大200件取り、同じ rank で並べています。保存したタブ・開いているタブ・履歴・ブックマークと、探す対象が違っても採点の基準は1つなので、どの画面でも同じ語で同じものが上に来ます。
読み込み順の落とし穴
クラシックスクリプトはグローバルスコープを共有するので、読み込み順が大事です。このプロジェクトでは、HTML の <script> を utils.js → search-dict.js(辞書)→ search.js(検索エンジン)→ 検索を使う画面のスクリプト、の順に並べています。
以前、辞書のファイルを読み込み忘れたページがあり、辞書を直接参照していた古いコードが ReferenceError で止まりました。いまは前述のとおり、typeof で存在を確かめてから使っています。
8. まだできていないこと
本体約200行で実用にはなっていますが、まだ手を入れられる点が残っています。同じものを作る方への改善案として挙げておきます。
- 入力のたびに全件を正規化し直している。 デバウンスも、データ側の事前の正規化もありません。件数が多いなら、正規化済みの文字列をデータと一緒にキャッシュする、入力を 100ms 程度まとめてから検索する、といった対策が効くはずです(筆者はまだ計測していません)
-
IME で変換中の入力でも再描画している。
compositionstartからcompositionendまでは検索を止める、といった扱いが考えられます -
一致した箇所の強調表示がない。 いまの
fieldScoreは点数しか返さないので、一致した位置も返すようにすれば、タイトルの中の一致部分を太字にできます
おわりに
まとめると、次の3点です。
- 正規化は「照合用(長音を残す)」と「辞書用(長音を消す)」に分け、混ぜない
- 呼び名辞書は正規化した Map にして完全一致で引き、前方一致は3文字以上、短い別名は単語の頭だけに絞る
- 段階スコアとフィールドの重みで関連度順に並べ、過去の誤ヒットはテストで固定する
最後に、この検索が動いている拡張機能を1段落だけ紹介させてください。LB TAB Layers は、案件ごとのタブのまとまりを Chrome のブックマークフォルダとして保存する、サイドパネル型の拡張機能です。Alt+T(ほかの拡張機能とキーが重なって割り当てられていないときは、ツールバーのアイコン)でサイドパネルを開き、検索窓に「めーる」と打つと、この記事の検索を試せます。
次回は、この拡張が保存先にしている chrome.bookmarks をデータの置き場所として使う設計(拡張機能を消してもデータが残る仕組みと、再インストールで戻るもの・戻らないもの)について書く予定です。