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?

「わらう」「ぴえん」「ばんざい」で絵文字を引きたい — Unicode CLDR の公式日本語タグが堅すぎたので 107 個分手書きした話

0
Posted at

Slack / Discord / Twitter の絵文字検索が :joy: :pleading_face: で出てくる時代、「わらう」と打って 😂 が出る検索が欲しい場面がある。Unicode 公式の CLDR には日本語 annotations があるけれど、["うれしなき", "かお", "かおえもじ"] のように 形式語すぎて検索キーワードと噛み合わない。107 個の絵文字に口語含む日本語タグを 5〜9 個ずつ手書きで付けて、約 200 行のブラウザ検索ツールにした。

emoji-search-jp の画面: 検索ボックスに「わらう」と入力した状態。下に 7 個の笑顔系絵文字 (😀😃😄😁😆🤣😂) がカード状に並び、各カードに絵文字名 (にっこり顔・目を見開いた笑顔…) とタグプレビュー (わらう · 笑う · 笑顔) が表示

🌐 デモ: 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"
}

タグ選定のガイドライン:

  1. 平仮名と漢字を両方入れる — 「ねこ」も「猫」も両方入れる。ユーザーが IME を確定したかどうかは事前に決められない
  2. 口語と formal を混ぜる — 「ばんざい」と「万歳」、「ぴえん」と「切ない」
  3. CLDR にあれば借用する — 公式 annotations をベースに、足りない口語語を上乗せ
  4. 英語タグも一握り入れる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 を入れることがある。normalizeNFKC + 小文字 + trim を 1 行でかける:

export function normalize(s) {
  return String(s).normalize("NFKC").toLowerCase().trim();
}

LOLlol わらう わらうPienpien、全部同じトークンに収束する。テストで固定:

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 一覧 から。

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?