快轉到主要內容
  1. 教學文章/

Python unicodedata 實戰:文字正規化、搜尋與去重

·6 分鐘· loading · loading · ·
Python Unicodedata Unicode Text-Normalization Search Standard-Library
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 102: 本文

featured

你明明看見兩段一模一樣的文字,Python 卻說它們不相等。

left = "Café"
right = "Cafe\u0301"

print(left == right)  # False

不是 Python 壞掉,也不是字型在惡作劇。 畫面上的「é」可以是一個字元,也可以由 e 加上一個組合重音符號構成。 人眼看到同一件事,電腦看到的 code point 序列卻不同。

這種差異會偷偷跑進:

  • 使用者名稱去重
  • 搜尋與自動完成
  • CSV 匯入清理
  • 跨平台檔名
  • API payload 比對
  • 測試資料與快取 key

今天拍拍君要用標準庫 unicodedata,把這些「看起來一樣」的文字整理成可預期的資料。

一. 安裝:不用裝,它就在標準庫裡
#

unicodedata 是 Python 標準庫的一部分。 直接 import 就能使用:

import unicodedata

print(unicodedata.unidata_version)

unidata_version 會告訴你目前 Python 內建的 Unicode Character Database 版本。 不同 Python 版本可能綁定不同 Unicode 資料版本,所以資料處理服務升級 Python 時,也要把 Unicode 行為放進回歸測試。

先準備一個小工具,看看字串裡到底放了什麼:

import unicodedata


def inspect_text(text: str) -> None:
    for char in text:
        code = f"U+{ord(char):04X}"
        name = unicodedata.name(char, "<unknown>")
        print(f"{char!r:6} {code:10} {name}")


inspect_text("é")
inspect_text("e\u0301")

第一段只有一個 code point:

'é'    U+00E9     LATIN SMALL LETTER E WITH ACUTE

第二段則有兩個:

'e'    U+0065     LATIN SMALL LETTER E
'́'     U+0301     COMBINING ACUTE ACCENT

這就是問題的入口:視覺相同,不代表底層表示相同。

二. normalize:先把等價文字放到同一條軌道
#

unicodedata.normalize() 支援四種常見形式:

形式 核心概念 常見用途
NFC canonical decomposition 後再組合 一般文字儲存與比較
NFD 保留 canonical 分解形式 分析組合符號
NFKC compatibility 分解後再組合 搜尋鍵、帳號比對
NFKD 保留 compatibility 分解形式 低階文字分析

先從最常用的 NFC 開始:

import unicodedata

left = "Café"
right = "Cafe\u0301"

normalized_left = unicodedata.normalize("NFC", left)
normalized_right = unicodedata.normalize("NFC", right)

print(normalized_left == normalized_right)  # True

NFC 會把 canonical equivalent 的表示整理成一致形式。 對一般應用程式來說,它是很好的預設值:

def normalize_text(text: str) -> str:
    return unicodedata.normalize("NFC", text)

在資料進入系統邊界時做一次,比每次比較前才補救更可靠:

def clean_display_name(raw: str) -> str:
    text = unicodedata.normalize("NFC", raw)
    return text.strip()

這裡故意只做兩件事:Unicode 正規化與去除首尾空白。 不要順手把所有標點、大小寫與空格都刪掉,否則你可能會改變使用者真正想顯示的名稱。

三. NFC、NFD、NFKC 到底差在哪裡?
#

NFC 與 NFD 處理的是 canonical equivalence。 NFKC 與 NFKD 還會處理 compatibility equivalence。

看一個全形字元:

import unicodedata

text = "ABC123"

print(unicodedata.normalize("NFC", text))
print(unicodedata.normalize("NFKC", text))

輸出:

ABC123
ABC123

NFC 保留全形外觀。 NFKC 則把 compatibility characters 轉成較常見的形式。

再看另一個例子:

samples = ["①", "K", "ffi"]

for sample in samples:
    converted = unicodedata.normalize("NFKC", sample)
    print(repr(sample), "->", repr(converted))

NFKC 很適合建立搜尋鍵與比對鍵,因為它能減少一些只有排版形式不同的變體。 但它不是無損轉換。 原始字元的視覺或語意細節可能被折疊,所以不要直接覆蓋使用者輸入的展示文字。

拍拍君習慣把「展示值」與「比對值」分開:

from dataclasses import dataclass
import unicodedata


@dataclass(frozen=True)
class Label:
    display: str
    key: str


def make_label(raw: str) -> Label:
    display = unicodedata.normalize("NFC", raw).strip()
    key = unicodedata.normalize("NFKC", display).casefold()
    return Label(display=display, key=key)

這樣畫面仍顯示原本合理的字形,比對時則使用穩定 key。

四. casefold:跨語言大小寫比對的好搭檔
#

只用 .lower() 做不分大小寫比對,對英文字母通常夠用,但跨語言時不一定完整。 casefold() 是專門為 caseless matching 設計的轉換。

print("Straße".lower())
print("Straße".casefold())

輸出大致會是:

straße
strasse

把 NFKC 與 casefold() 組合起來,可以建立實用搜尋鍵:

import unicodedata


def search_key(text: str) -> str:
    normalized = unicodedata.normalize("NFKC", text)
    return " ".join(normalized.casefold().split())

這個版本做了三件事:

  1. 用 NFKC 折疊 compatibility variants。
  2. casefold() 做 Unicode-aware 大小寫折疊。
  3. split() 再 join,把連續空白整理成單一空格。
values = [
    "  Café  Latte ",
    "Cafe\u0301 Latte",
    "CAFÉ   LATTE",
]

for value in values:
    print(search_key(value))

請注意:正規化不會自動移除重音。 cafécafe 是否要視為相同,是產品規則,不是 Unicode 替你決定的事。

五. 組合字元:移除重音前先想清楚
#

有些搜尋功能希望輸入 cafe 也能找到 café。 可以先做 NFD 分解,再移除 combining marks:

import unicodedata


def remove_diacritics(text: str) -> str:
    decomposed = unicodedata.normalize("NFD", text)
    filtered = "".join(
        char for char in decomposed
        if not unicodedata.combining(char)
    )
    return unicodedata.normalize("NFC", filtered)


print(remove_diacritics("Café"))  # Cafe

unicodedata.combining(char) 會回傳 canonical combining class。 一般字元通常是 0,組合符號則可能是其他數值。

但不要把這段函式套在所有資料上。 在某些語言裡,附加符號不是可有可無的裝飾;移除後可能造成資訊損失、誤配或冒犯性的結果。

比較安全的做法是:

  • 原始值保留 NFC 版本
  • 只有搜尋索引額外建立 accent-insensitive key
  • UI 明確顯示真正命中的原文
  • 帳號唯一性與安全識別不要只靠去重音 key
def accent_insensitive_key(text: str) -> str:
    folded = unicodedata.normalize("NFKC", text).casefold()
    return remove_diacritics(folded)

這是搜尋體驗工具,不是萬用身份驗證規則。

六. category:用 Unicode 屬性理解字元
#

unicodedata.category() 會回傳 Unicode General Category。 第一個字母是大類,第二個字母是子類。

import unicodedata

for char in "A9! 中\n":
    print(repr(char), unicodedata.category(char))

常見大類包括:

前綴 類型 例子
L Letter A
M Mark 組合重音
N Number 9、羅馬數字
P Punctuation !
S Symbol 貨幣、數學符號
Z Separator 空白分隔
C Other 控制字元、未指派字元

如果要拒絕不可見控制字元,可以針對政策做檢查:

def find_control_characters(text: str) -> list[str]:
    return [
        char for char in text
        if unicodedata.category(char).startswith("C")
        and char not in {"\n", "\t"}
    ]

這裡沒有粗暴地刪除所有 C 類字元。 因為換行與 tab 在某些欄位是合法的,而格式控制字元也可能出現在正當文字中。 應用程式應該先定義欄位政策,再決定接受、警告或拒絕。

七. 實戰:建立搜尋與去重管線
#

假設我們要匯入一批文章標籤,希望保留顯示文字,同時找出正規化後的重複項目。

from dataclasses import dataclass
import unicodedata


@dataclass(frozen=True)
class NormalizedTag:
    display: str
    key: str


def normalize_tag(raw: str) -> NormalizedTag:
    display = unicodedata.normalize("NFC", raw).strip()
    if not display:
        raise ValueError("tag 不能是空字串")

    key = unicodedata.normalize("NFKC", display).casefold()
    key = " ".join(key.split())

    return NormalizedTag(display=display, key=key)

接著做去重:

def deduplicate_tags(values: list[str]) -> list[NormalizedTag]:
    seen: set[str] = set()
    result: list[NormalizedTag] = []

    for raw in values:
        tag = normalize_tag(raw)
        if tag.key in seen:
            continue
        seen.add(tag.key)
        result.append(tag)

    return result

測試資料故意混入不同表示:

tags = [
    "Python",
    "Python",
    "Café",
    "Cafe\u0301",
    "  Machine   Learning ",
]

for tag in deduplicate_tags(tags):
    print(tag)

這個設計有幾個好處:

  • UI 可以使用 display
  • 資料庫 unique index 可以使用 key
  • 原始匯入資料仍可另外保存供稽核
  • 規則集中在一個函式,不會散落在 route、form 與 SQL 裡

如果正規化規則將來改變,記得為既有資料安排 migration,而不是只改新資料的寫入邏輯。

八. 寫測試:把肉眼看不出的差異固定下來
#

Unicode bug 很適合用參數化測試處理。

import pytest


@pytest.mark.parametrize(
    ("left", "right"),
    [
        ("Café", "Cafe\u0301"),
        ("Python", "Python"),
        ("Straße", "STRASSE"),
    ],
)
def test_search_key_equivalence(left: str, right: str) -> None:
    assert search_key(left) == search_key(right)

也要測試「不應該相等」與 idempotence:

def test_search_key_keeps_meaningful_differences() -> None:
    assert search_key("resume") != search_key("résumé")


def test_search_key_is_idempotent() -> None:
    value = "  Cafe\u0301   Latte  "
    once = search_key(value)
    twice = search_key(once)
    assert once == twice

九. 常見陷阱
#

  • 以為 normalize 會處理所有相似字: 不同 script 裡長得像的字元,仍可能是完全不同的 code point;normalization 不是帳號防冒充系統。
  • 正規化後直接丟掉原始值: 搜尋 key 與展示文字需求不同,保留 NFC display value 通常更容易除錯與遷移。
  • 整個系統只用一種規則: 人名、密碼、搜尋字串與 SKU 不該共用同一個 aggressive normalizer。
  • 把正規化當安全消毒: 它不會阻止 SQL injection、XSS、路徑穿越或釣魚名稱。
  • 升級 Python 不跑回歸測試: Unicode database 會演進,重要的比對 key 應保留代表性 fixture。

結語
#

unicodedata 的 API 不多,但它處理的是很深的資料邊界問題。

今天最值得帶走的做法是:

  1. 一般展示文字先用 NFC 統一 canonical representation。
  2. 搜尋與去重可以考慮 NFKC 加 casefold()
  3. 原始展示值與 comparison key 分開保存。
  4. 去重音、刪控制字元與檔名清理都要由欄位政策決定。
  5. 用看不出差異的測試案例,固定你真正想要的行為。

文字看起來一樣,不代表資料真的一樣。 先把正規化規則寫清楚,之後的搜尋、去重與跨平台交換就會少很多玄學 bug。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 102: 本文

相關文章

Python shlex 實戰:安全拆解 Shell 參數、Quote 與迷你指令語法
·6 分鐘· loading · loading
Python Shlex Shell Cli Security Standard-Library
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools
Python secrets 實戰:安全產生 Token、密碼與一次性連結
·7 分鐘· loading · loading
Python Secrets Security Token Standard-Library Web
Python zoneinfo 實戰:時區、DST 與排程時間處理完全攻略
·8 分鐘· loading · loading
Python Zoneinfo Datetime Timezone DST Standard-Library