0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「メール」で探したら「メルカリ」が出た ── 依存ゼロ・本体約200行で作る日本語あいまい検索(ひらがな・呼び名・関連度順)

0
Posted at

この記事は、筆者(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件あれば足ります。

old-search.js
// 旧実装を最小限に書き直したもの(実物より簡略化)
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(誤ヒット)

処理を追うと、こうなります。

  1. 「メール」を正規化すると、長音が消えて「める」になる
  2. 辞書の見出しを部分一致で引いているので、'めるかり'.includes('める') が true になる
  3. 「めるかり」の別名 mercari が、URL の jp.mercari.com に含まれる → 一致

原因は1つではなく、①長音を消した形で比べたことと、②辞書の見出しを部分一致で引いたことの2つが重なっていました。

当時はほかにも、次のような症状がありました。

  • 辞書に大文字で書いた見出し("DM" など)には、一度も当たっていませんでした。クエリは小文字にしてから比べるのに、見出しのほうは正規化していなかったためです
  • 画面ごとに探し方がバラバラで、一部の画面は単純な includes() だけでした。並び順も、関連度ではなく登録順でした

ここで1つ区別しておきたいのは、「める」と打ってメルカリが出るのは正しいということです。「める」は「めるかり」の打ちかけかもしれないからです。新しい実装でも、「める」→ メルカリは 100.17 点で上位に出ます(辞書ではなく、正規化したタイトル「めるかり」への先頭一致です)。問題は、「メール」と打った人にメルカリを見せることでした(新しい実装では 0 点、つまり不一致)。

2. 正規化を「照合用」と「辞書用」の2系統に分ける

直し方の中心は、正規化を2つに分けることです。

normalize.js
// 照合用: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 で拾えます。

aliases.js
// 呼び名辞書(抜粋)。見出しはひらがなが基本。値は英単語でもホスト名の断片でもよい
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文字以上のときだけ前方一致

aliases.js(続き)
// 見出しは完全一致で引く。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 で合流できますが、リテラルの段階で消えた値は、実行時には取り戻せません。こうした重複は、辞書ファイルをテキストとして読み、見出しを数えるテストで見つけられます。

dict-dupes.test.js(簡略版)
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 が返します。

「単語の頭」の判定

score.js
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) で判定する

typo.js
// "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 点
subsequence.js
// 飛び飛びの一致("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 です。

score.js(続き)
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);
}
score.js(続き)
// 同じクエリで続けて呼ばれたら、正規化と別名の展開を使い回す
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 点数
Google 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・search-dict.js(末尾・簡略化)
// ---- 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 にまとめました)。

tests/search.test.js(抜粋)
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 にかけています。

dashboard.js(簡略化)
// 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 をデータの置き場所として使う設計(拡張機能を消してもデータが残る仕組みと、再インストールで戻るもの・戻らないもの)について書く予定です。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?