Skip to content

Commit 4e65392

Browse files
JSKittyclaude
andcommitted
add: Unicode emoji frequency rankings as search tiebreaker
The iamcal shortcodes settle the canonical-match cases (search 'heart' → ❤️ wins over 💖). What was left: when multiple emojis tie on score and keyword-position, source order decided — so 'smile' would put 😼 (rare) near the top alongside 😄 (popular). Pulls Unicode's published logarithmic-bucket ranking from home.unicode.org/emoji/emoji-frequency/ (rank 0 = 😂 ❤️, rank 16 = niche) covering 1453 of our 1912 emojis. Stored as a `freq` field on each entry, applied as a sort tiebreaker after score and matched-keyword position. Unranked emojis fall back to 99 so they sort after every ranked one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 674f707 commit 4e65392

2 files changed

Lines changed: 1508 additions & 1460 deletions

File tree

scripts/build-emoji.mjs

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const SVG_OVERRIDES_DIR = join(__dirname, 'emoji-svg-overrides');
2727
const EMOJI_TEST_URL = 'https://unicode.org/Public/emoji/latest/emoji-test.txt';
2828
const CLDR_ANNOTATIONS_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-full@48.1.0/annotations/en/annotations.json';
2929
const CLDR_DERIVED_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-derived-full@48.1.0/annotationsDerived/en/annotations.json';
30+
// Unicode publishes a logarithmic-bucket frequency ranking of ~1454 emojis
31+
// at this URL (rank 0 = most-used pair 😂 ❤️, higher = less-used). Used as
32+
// a tiebreaker when ranking-tier scores match, so e.g. searching "smile"
33+
// promotes 😄 (popular) over 😼 (rare) on equal score.
34+
const UNICODE_FREQUENCY_URL = 'https://home.unicode.org/emoji/emoji-frequency/';
3035
// emojibase-data ships the iamcal shortcode set (the same Slack/Discord-style
3136
// canonical aliases like :heart: → ❤️, :pray: → 🙏, :fire: → 🔥). Loaded
3237
// from the local devDependency so the build is offline-friendly once
@@ -80,12 +85,13 @@ function toTwemojiFilename(codepoints) {
8085

8186
async function downloadAll() {
8287
console.log('Phase 1: Downloading Unicode data...');
83-
const [emojiTestRaw, cldrRaw, cldrDerivedRaw] = await Promise.all([
88+
const [emojiTestRaw, cldrRaw, cldrDerivedRaw, frequencyRaw] = await Promise.all([
8489
cachedFetch(EMOJI_TEST_URL, 'emoji-test.txt'),
8590
cachedFetch(CLDR_ANNOTATIONS_URL, 'cldr-annotations.json'),
8691
cachedFetch(CLDR_DERIVED_URL, 'cldr-annotations-derived.json'),
92+
cachedFetch(UNICODE_FREQUENCY_URL, 'unicode-emoji-frequency.html'),
8793
]);
88-
return { emojiTestRaw, cldrRaw, cldrDerivedRaw };
94+
return { emojiTestRaw, cldrRaw, cldrDerivedRaw, frequencyRaw };
8995
}
9096

9197
// ── Phase 2: Parse emoji-test.txt ───────────────────────────────────────
@@ -356,6 +362,29 @@ function cldrLookup(cldrMap, char) {
356362
return null;
357363
}
358364

365+
function parseFrequencyTable(html) {
366+
// Unicode's frequency page is a WordPress-rendered HTML table with rows
367+
// like: <td...>RANK</td><td...>EMOJI EMOJI ...</td>
368+
// We extract grapheme clusters from the emoji column to handle ZWJ
369+
// sequences and variation selectors correctly.
370+
const rowRe = /<td[^>]*>(\d+)\s*<\/td>\s*<td[^>]*>([^<]+)<\/td>/g;
371+
const ranks = new Map();
372+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
373+
let m;
374+
while ((m = rowRe.exec(html)) !== null) {
375+
const rank = parseInt(m[1], 10);
376+
const emojiText = m[2];
377+
for (const seg of segmenter.segment(emojiText)) {
378+
const e = seg.segment;
379+
if (!e || /^\s+$/.test(e)) continue;
380+
const prev = ranks.get(e);
381+
if (prev === undefined || rank < prev) ranks.set(e, rank);
382+
}
383+
}
384+
console.log(` ${ranks.size} emojis in Unicode frequency table`);
385+
return ranks;
386+
}
387+
359388
function loadIamcalShortcodes() {
360389
if (!existsSync(IAMCAL_SHORTCODES_PATH)) {
361390
throw new Error(`Missing emojibase-data. Run: npm install --save-dev emojibase-data`);
@@ -383,12 +412,23 @@ function lookupIamcal(iamcalMap, codepoints) {
383412
return null;
384413
}
385414

386-
function buildKeywords(emojis, cldrMap, customMap, iamcalMap) {
415+
function buildKeywords(emojis, cldrMap, customMap, iamcalMap, frequencyMap) {
387416
console.log(' Merging keywords (iamcal + CLDR + emoji-test.txt + custom)...');
388417

418+
// Unranked emojis fall back to a value larger than the highest observed
419+
// rank, so they sort behind every ranked one without affecting score-tier
420+
// ordering.
421+
const UNRANKED = 99;
422+
let freqHits = 0;
389423
let cldrHits = 0;
390424
let iamcalHits = 0;
391425
for (const emoji of emojis) {
426+
// Look up frequency rank — try with and without FE0F, since the
427+
// Unicode page uses fully-qualified forms inconsistently.
428+
let rank = frequencyMap.get(emoji.char);
429+
if (rank === undefined) rank = frequencyMap.get(emoji.char.replaceAll('️', ''));
430+
if (rank !== undefined) freqHits++;
431+
emoji.freq = rank !== undefined ? rank : UNRANKED;
392432
const words = new Set();
393433

394434
// Layer 0: iamcal shortcode (Discord/Slack canonical alias). Stored
@@ -441,7 +481,7 @@ function buildKeywords(emojis, cldrMap, customMap, iamcalMap) {
441481
emoji.display = (cldr && cldr.tts) ? cldr.tts : emoji.name;
442482
}
443483

444-
console.log(` CLDR matched ${cldrHits}/${emojis.length} emojis, iamcal matched ${iamcalHits}/${emojis.length}`);
484+
console.log(` CLDR matched ${cldrHits}/${emojis.length} emojis, iamcal matched ${iamcalHits}/${emojis.length}, frequency matched ${freqHits}/${emojis.length}`);
445485
}
446486

447487
// ── Phase 5: Output ─────────────────────────────────────────────────────
@@ -477,7 +517,8 @@ function writeEmojiJs(emojis) {
477517
const safeDisplay = (emoji.display || '').replace(/'/g, "\\'");
478518
const safeShortcode = (emoji.shortcode || '').replace(/'/g, "\\'");
479519
const shortcodeField = safeShortcode ? `, shortcode: '${safeShortcode}'` : '';
480-
lines.push(` { emoji: '${emoji.char}', name: '${safeKeywords}', display: '${safeDisplay}'${shortcodeField} },`);
520+
const freqField = (typeof emoji.freq === 'number' && emoji.freq < 99) ? `, freq: ${emoji.freq}` : '';
521+
lines.push(` { emoji: '${emoji.char}', name: '${safeKeywords}', display: '${safeDisplay}'${shortcodeField}${freqField} },`);
481522
}
482523

483524
// Remove trailing comma from last entry
@@ -499,7 +540,7 @@ async function main() {
499540
console.log('=== build-emoji.mjs ===\n');
500541

501542
// Phase 1: Download
502-
const { emojiTestRaw, cldrRaw, cldrDerivedRaw } = await downloadAll();
543+
const { emojiTestRaw, cldrRaw, cldrDerivedRaw, frequencyRaw } = await downloadAll();
503544

504545
// Phase 2: Parse
505546
const emojis = parseEmojiTest(emojiTestRaw);
@@ -512,6 +553,7 @@ async function main() {
512553
// Phase 4: Keywords
513554
const cldrMap = parseCLDR(cldrRaw, cldrDerivedRaw);
514555
const iamcalMap = loadIamcalShortcodes();
556+
const frequencyMap = parseFrequencyTable(frequencyRaw);
515557

516558
let customMap;
517559
if (!existsSync(CUSTOM_KEYWORDS_FILE)) {
@@ -520,7 +562,7 @@ async function main() {
520562
customMap = loadCustomKeywords();
521563
}
522564

523-
buildKeywords(available, cldrMap, customMap, iamcalMap);
565+
buildKeywords(available, cldrMap, customMap, iamcalMap, frequencyMap);
524566

525567
// Phase 5: Output
526568
writeEmojiJs(available);

0 commit comments

Comments
 (0)