Emoji 組合的意思從哪來:投稿、去重、排行與檢舉機制
🥺👉👈 是什麼意思?💀💀💀 為什麼是笑死?我在顏文字花園加了一個新區塊Emoji 組合 ↗:每一組 emoji 都寫了意思和使用時機,點一下就複製;登入之後也能投稿自己的組合,排行看的是「有多少不同的人複製過」,不是按讚數。
這是顏文字花園第一個有伺服器與資料庫的功能(Cloudflare Worker 加 D1),也是我第一次在工具站做使用者投稿。這篇記錄幾個設計決定:意思從哪來、怎麼判定兩組 emoji 是同一組、排行怎麼防灌票,以及投稿不先審核的情況下,靠什麼擋住垃圾內容。
先講清楚:這不是 Emoji Kitchen
中文搜尋「emoji 組合」,很多人要找的其實是 Google 的 Emoji Kitchen,把兩個 emoji 合成一張貼圖。那產出的是圖片,不能當文字貼上。這裡的 Emoji 組合是純文字:兩個以上的 emoji 排在一起,合起來表達一個意思,複製之後在哪裡都能貼。
意思從哪來
冷啟動的問題很現實:投稿功能剛上線時沒有人投稿,排行也就沒有東西可排。所以上線那天,站上就先放了 168 組站方整理的組合,分成 8 個主題(害羞告白、笑死、難過崩潰、可愛、美感、無言尷尬、讚美打氣、節日季節),每個主題 21 組。
每一組有兩段文字:
- 標題:卡片上那一行,最多 24 字,例如 🥺👉👈「害羞戳手指」、👀🍿「搬椅子看戲」。
- 說明:意思與使用時機,例如 🙂👍「微笑比讚,其實是冷淡的『好喔』。有陰陽怪氣的味道,小心使用。」
這些說明不是查字典查得到的。emoji 的意思跟世代、平台綁得很緊:自由時報 2026 年 6 月一篇談 Threads「EMOJI 聊天法」的報導就提到,😂 在年輕人眼中已經過時,🙂 則帶有冷淡或陰陽怪氣的意思(自由時報報導 ↗)。所以種子內容是先整理這類在地觀察,再跟 AI 協作起草標題與說明,一條一條校過才上線的。中文與英文各寫一份,簡體中文由繁體轉換產生。
使用者投稿則是這樣:標題必填(2 到 24 字),另外可以附一段 60 字以內的說明,兩者都不能含連結。站方可以替值得收錄的投稿補上主題與中英文的標題、說明,補齊之後它才會出現在可以被 Google 收錄的主題頁。主題頁之所以值得被收錄,靠的就是這些說明文字;只有一排 emoji 的頁面,對搜尋引擎來說是典型的薄頁。
同一組 emoji,怎麼判定重複
「自動去重」聽起來是比對字串,但 emoji 比想像中複雜:
- 👨👩👧 看起來是一個 emoji,其實是 5 個碼位用零寬連接符(ZWJ)串起來的。
- 👍🏽 是 👍 加一個膚色修飾字元。
- ❤ 與 ❤️ 差在一個看不見的變體選擇符(VS16)。
所以輸入的文字要先經過一條固定的流程:
- 做 NFC 正規化,去掉空白與零寬空格(但保留 ZWJ,它是很多序列的一部分)。
- 拿站內 Emoji 頁那份清單當白名單,用「最長比對」一個一個切出 emoji。清單只收到 Emoji 15.1,太新的 emoji 在舊裝置上會變成方框,一律不收。
- 數量要 2 到 12 個,總長不超過 50 個碼位。只有一個 emoji 的,去 Emoji 頁就好。
- 換成標準寫法:補齊變體選擇符、拿掉膚色。
- 去重的鍵是標準寫法再拿掉變體選擇符,存在資料庫的 UNIQUE 欄位上。
結果是 👍 和 👍🏽 算同一組;順序有意義,🥺👉👈 和 👈👉🥺 是兩組;ZWJ 會保留,👨👩👧 不會被拆成 👨👩👧。
切 emoji 我沒有用瀏覽器的 Intl.Segmenter,而是對白名單做最長比對。一方面 Firefox 125 以後才支援它;另一方面,一個不在白名單裡的字元反正都要被拒絕,白名單本來就是最後的判斷依據。這套規則寫成一支純函式,瀏覽器的即時檢查與 Worker 的最終檢查用同一份,不會出現「畫面說可以、伺服器說不行」。
輸入時會即時查重。已經有人投過的組合,會直接帶你到那一則,改成收藏或去留言,不會建立新的一筆。兩個人幾乎同時送出同一組時,資料庫的 UNIQUE 限制會擋下後到的那一個,回傳既有那一則。被隱藏或退回的組合仍然佔著它的鍵,所以被移除的內容不能換個時間再投一次。
排行:數的是「不同的人」
排行看複製次數,但瀏覽器送來的複製事件很容易被腳本灌,所以計數的單位是「每天不重複的複製者」:
- 瀏覽器端:同一個分頁、同一組只回報一次,攢著用
sendBeacon批次送出。回報失敗不影響複製本身。 - 伺服器端:用「當天隨機產生的金鑰」對「IP 位址前段」(IPv4 取整個位址,IPv6 取前 64 位元)做 HMAC-SHA256,取前 8 個位元組當作今天的訪客代碼。同一個代碼對同一組、同一天只算一次。
- 兩天後刪掉那天的金鑰。金鑰沒了,任何人(包括我)都算不回某個代碼來自哪個位址。裝置上也不存任何識別碼。
一開始的設計把瀏覽器的 User-Agent 也算進代碼裡,安全審查時拿掉了:User-Agent 是送出的人自己填的,換一個就能多算一票。代價是同一個網路出口後面的人會被算成同一個,數字偏低,但排行只需要相對順序。
另一道關卡是「進不進得了排行」。投稿要在兩個不同的日子都有人複製過,才會進入首頁排行(種子與站方整理過的組合例外)。同一天灌十萬次,也只算一天。買一批代理伺服器能換到的,是同一天的大量次數,而那正是排行不看的東西。
不先審核,靠什麼擋
上線第一版是「立刻顯示、標示未審核,審核過才進排行與收錄」。上線隔天我改了主意:在以使用者投稿為主的情境下,分類與編輯挑選沒有那麼重要,排行應該交給複製數決定。所以現在投稿不再事前審核:送出後立刻公開,可以複製、留言,出現在「最新」。
少了人工審核,防線就要換一組:
- 身分與機器人:投稿、留言、檢舉都要先用 Google 登入,投稿與留言再過 Cloudflare Turnstile。
- 數量上限:每個帳號每天最多 5 則投稿;另外每個網路位址每天也只有 5 則的額度,因為新的 Google 帳號很便宜,網路位址比較貴。
- 文字規則:標題、說明、留言與暱稱一律不收連結,也擋掉會讓文字方向顛倒的控制字元。另有一份擋垃圾訊息、色情與歧視字眼的關鍵字清單,比對前會把全形字,以及長得跟拉丁字母一模一樣的西里爾、希臘字母折回來,不然把 a 換成西里爾字母的 а 就能繞過。標題與說明命中就直接拒絕;留言命中則先保留,等站方看過。
- 檢舉自動隱藏:被三個「註冊超過一天」的帳號檢舉,就自動隱藏並等站方處理。只算註冊超過一天的帳號,是因為開三個新帳號幾乎不用成本。
- 不被收錄:每一則投稿自己的頁面都標
noindex,只有補齊說明的組合才會進入主題頁。 - 會過期:投稿 180 天內沒有人複製、沒有人檢舉、也沒有被整理,就自動退回。
- 緊急開關:一個不用重新部署就能關掉投稿的設定。
有一個 bug 值得記下來。自動隱藏的 SQL 用了一個相關子查詢,裡面寫了裸的 id。SQLite 會先在最內層找這個欄位,而 users 表也有 id,條件就變成「檢舉人就是被檢舉的對象」,永遠不成立,自動隱藏永遠不會觸發。後來是對本機 Worker 跑整套 API 冒煙測試時抓到的。之後這類子查詢一律替外層的表取別名,欄位也寫完整。
隱私:只留做事需要的最少資料
- 登入:Google 登入只用 client ID,Worker 用 Google 公開的金鑰驗證 ID token 的簽章,整套設計沒有 client secret。資料庫只存 Google 給的帳號識別碼與你自己取的暱稱,不存 email、姓名與大頭照。
- 按需載入:Google 的登入程式與 Turnstile 只在按下登入或送出時才載入,一般瀏覽不會載入。
- session:一個 30 天的 HttpOnly cookie,資料庫只存它的雜湊值。
- 刪除帳號:刪掉 session、收藏、留言與你送出的檢舉;投稿保留成匿名,但還沒進入排行的投稿會一併隱藏,因為卡片上顯示的那行標題是你打的字。被停權的帳號例外:刪除後會留下帳號識別碼的雜湊,避免刪掉帳號就能重新開始。
這些規則都寫在顏文字花園的隱私權頁,文字與程式對得上,是我對這類功能最在意的事。
小結
- 意思要有來源:種子內容先做在地觀察再撰寫,投稿的說明要經過整理才進可收錄的頁面。
- 去重要先有標準寫法:白名單切 emoji、拿掉膚色與變體選擇符,UNIQUE 欄位擋最後一道。
- 排行只數不同的人:每天換金鑰的雜湊,兩天後連我也算不回來。
- 不先審核,就把防線做成一整組:登入、Turnstile、數量上限、文字規則、檢舉、過期,缺一道都會被繞過。
想試試看的話,Emoji 組合 ↗不用登入就能瀏覽與複製。整個 Emoji 與顏文字工具的介紹在Emoji・顏文字工具介紹,顏文字花園那顆會眨眼的 Logo 怎麼做,可以看這篇。
