Slack / Discord / Twitter の絵文字検索が
:joy::pleading_face:で出てくる時代、「わらう」と打って 😂 が出る検索が欲しい場面がある。Unicode 公式の CLDR には日本語 annotations があるけれど、["うれしなき", "かお", "かおえもじ"]のように 形式語すぎて検索キーワードと噛み合わない。107 個の絵文字に口語含む日本語タグを 5〜9 個ずつ手書きで付けて、約 200 行のブラウザ検索ツールにした。
🌐 デモ: https://sen.ltd/portfolio/emoji-search-jp/
📦 GitHub: https://github.com/sen-ltd/emoji-search-jp
CLDR の日本語タグはなぜ使えないのか
Unicode は CLDR (Common Locale Data Repository) で 各絵文字 × 各言語の annotations を公式提供している。日本語版は common/annotations/ja.xml に入っている。
中身を覗くと:
| 絵文字 | CLDR の ja annotations |
|---|---|
| 😂 | うれしなき / かお / かおえもじ |
| 🥺 | かお / かおえもじ / けんめい / もの欲しそう |
| 🙏 | お辞儀 / かたを下げる / かんしゃ |
| 🎉 | クラッカー / ハッピー / パーティー / 紙ふぶき |
| 🐶 | いぬ / おもしろい / かお / どうぶつ |
良い点: 公式・網羅的・複数言語で同じスキーマ。
問題点: 検索キーワードとして使う観点では、口語語彙が決定的に足りない。
- 😂 を「わらう」「lol」「草」で引けない
- 🥺 を「ぴえん」で引けない
- 🙏 を「お願い」「ありがとう」「ごめん」で引けない (
お辞儀のみ) - 🎉 を「おめでとう」で引けない
- 🐶 を「わんこ」「ワン」で引けない
絵文字を 読み上げて何の絵か説明する ための語彙であって、ユーザーが連想する語彙 ではない、というのが問題の本質。CLDR は a11y や IME 候補で使う前提なので、本来この用途には合っている。検索キーワード集ではないから合わない。
解決: 口語タグを手書きする
107 個の絵文字に対して、tags 配列に 実際にユーザーが打ちそうな語 を 5〜9 個ずつ書いた。
{
"char": "😂",
"name_en": "face with tears of joy",
"name_ja": "嬉し泣きの顔",
"tags": ["わらう", "大爆笑", "笑い泣き", "嬉し泣き", "lol"],
"category": "face"
},
{
"char": "🥺",
"name_en": "pleading face",
"name_ja": "うるうる目の顔",
"tags": ["ぴえん", "かわいい", "うるうる", "おねがい", "切ない"],
"category": "face"
},
{
"char": "🙏",
"name_en": "folded hands",
"name_ja": "合掌",
"tags": ["お願い", "おねがい", "ありがとう", "祈る", "感謝", "ごめん"],
"category": "gesture"
}
タグ選定のガイドライン:
- 平仮名と漢字を両方入れる — 「ねこ」も「猫」も両方入れる。ユーザーが IME を確定したかどうかは事前に決められない
- 口語と formal を混ぜる — 「ばんざい」と「万歳」、「ぴえん」と「切ない」
- CLDR にあれば借用する — 公式 annotations をベースに、足りない口語語を上乗せ
-
英語タグも一握り入れる —
lol/ok/loveのような英語スラングも入力候補に
「全網羅」ではなく 「現代の日本語ユーザーが打つ語のトップ 5〜9」 を狙う。107 個 × 7 タグ = 約 750 タグエントリ。手書きで 1 〜 2 時間。
スコアリング: 5 段階
検索は単純な線形スキャン + 重み付きマッチ。トークンごとに以下の優先順位でスコアを付ける:
const SCORE = {
TAG_EXACT: 10, // "わらう" === "わらう"
TAG_PREFIX: 7, // "わら" は "わらう" の前方一致
TAG_SUBSTRING: 4, // "らう" は "わらう" に含まれる (非前方)
NAME_JA_SUBSTRING: 3, // "顔" が name_ja "嬉し泣きの顔" に含まれる
NAME_EN_SUBSTRING: 1, // 英語名のフォールバック
};
export function scoreToken(emoji, token) {
if (!token) return 0;
let best = 0;
for (const tag of emoji.tags) {
const t = normalize(tag);
if (t === token) return SCORE.TAG_EXACT; // exact が一番強い
if (t.startsWith(token)) best = Math.max(best, SCORE.TAG_PREFIX);
else if (t.includes(token)) best = Math.max(best, SCORE.TAG_SUBSTRING);
}
if (best > 0) return best;
if (normalize(emoji.name_ja).includes(token)) return SCORE.NAME_JA_SUBSTRING;
if (normalize(emoji.name_en).includes(token)) return SCORE.NAME_EN_SUBSTRING;
return 0;
}
「タグの方が name_ja より重い」というのが効く。顔 で検索すると 顔 を tag に持っている絵文字が上位、name_ja に偶然 顔 が含まれる絵文字が下位、になる。
マルチトークン: AND 一致
検索クエリを空白分割し、すべてのトークンが何かしらマッチした絵文字だけ 残す。各トークンのスコアの和で並べる:
export function scoreEmoji(emoji, tokens) {
if (tokens.length === 0) return 0;
let sum = 0;
for (const tok of tokens) {
const s = scoreToken(emoji, tok);
if (s === 0) return -1; // 不一致 → ドロップ
sum += s;
}
return sum;
}
例: "わらう 顔" → 😂 はタグ "わらう" でマッチ (+10) + name_ja "嬉し泣きの顔" に "顔" を含む (+3) = 13 点。🐱 は name_ja "猫の顔" で "顔" にマッチ (+3) するが "わらう" にマッチするタグ/名前が無いので -1 でドロップ。
NFKC で全半角・大小を吸収
ユーザーは IME の確定ミスで全角 LOL を入れることがある。normalize で NFKC + 小文字 + trim を 1 行でかける:
export function normalize(s) {
return String(s).normalize("NFKC").toLowerCase().trim();
}
LOL → lol、 わらう → わらう、Pien → pien、全部同じトークンに収束する。テストで固定:
test("search is case- and width-insensitive (NFKC)", () => {
const results = search(SAMPLE, "LOL"); // 全角 LOL
assert.equal(results[0].emoji.char, "😂");
});
stable sort の効能
同点のときに 入力順を保持する ように比較関数を書く:
matches.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.idx - b.idx;
});
これで「顔 で引いた結果」が キュレートした順 (😂 → 🥺 → 🐱 …) に出る。Array.prototype.sort は V8 で stable だが (ECMA-2019 以降)、スコアが完全同点になる ケースが多い ので、明示的に idx を tie-breaker に入れる方が安全。
テストで形を固定:
test("search is stable: equal-scoring matches keep input order", () => {
// "顔" は 3 つすべてに NAME_JA_SUBSTRING (3 点) でマッチ。同点。
const results = search(SAMPLE, "顔");
const chars = results.map((r) => r.emoji.char);
assert.deepEqual(chars, ["😂", "🥺", "🐱"]); // 入力順
});
やらなかったこと
- Trie / 転置インデックス — 107 件 × 7 タグなら線形スキャンが 0.1 ms 未満で終わる。インデックスはエントリが 10,000 件超えてから考えれば良い
- Levenshtein マッチ — タイポ許容を入れるとスコアの設計が一段複雑になる。前方一致 + 部分一致だけで 80% カバー
- 絵文字のスキントーン / 性別バリアント — 一気に件数が増える + 検索のしやすさが下がる。スキントーンが必要なら native の絵文字ピッカーを使えば良い
- 読み上げ用 a11y アノテーション — CLDR が既にカバーしている領域、本ツールの責務外
触る
https://sen.ltd/portfolio/emoji-search-jp/ で 「わらう」「ぴえん」「ばんざい」「猫」「ハート」「ハンバーガー」 等を試せる。クリックでクリップボードにコピー、/ キーで検索フォーカス。
ソース: https://github.com/sen-ltd/emoji-search-jp — MIT、合計 ~200 行 (JS) + 107 件の手書きデータ、18 ユニットテスト、ビルド不要、依存ゼロ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
