URL のスラッグ化: Unicode・発音区別符号・衝突

タイトルを URL セーフなスラッグに変える方法、つまり小文字化・正規化・翻字のパイプライン、発音区別符号と非ラテン文字がなぜそれを壊すか、そして衝突をどう扱うかを整理します。

スラッグは、タイトルから導く、人が読めて URL セーフな識別子です。"Café René!"cafe-rene になります。URL が %XX のエスケープなしに打てて・共有できて・ 索引できて・覚えられるように、そしてパス自体が意味を運ぶように存在します。よくある 場合では作るのが拍子抜けするほど単純で、入力が ASCII を離れた瞬間にエッジケースで あふれます。この記事はパイプラインを段階ごとにたどった後、実際に本番でつまずく 2 つを扱います。素朴な手法が翻字できないスクリプトと、衝突です。

スラッグは何のためか

スラッグは、読めて十分に不透明な、安定した鍵です。3 つの特性が重要です。

  • URL セーフ: すべての文字が非予約集合 (A-Z a-z 0-9 - _ . ~) にあり、 パーセントエンコードが要りません。
  • 読める: /posts/cafe-rene は、人と検索クローラにページが何かを伝えます。 /posts/8a3f は伝えません。
  • 安定: いったん発行されたら決して変わりません。変えると、それを指すすべての リンクとすべての被リンクが壊れるからです。

最後の特性が人々の間違えるところで、下に独立した節で扱います。

パイプライン、段階ごとに

スラッグ生成器は小さな順序のある変換です。順序が重要です。剥がす前に正規化し、 たたむ前に剥がしてください。

"Café René! — 100% done" 
  1. 小文字化             → "café rené! — 100% done"
  2. NFKD 正規化          → "café rené! — 100% done"   (é が今や e + ´)
  3. 結合マークを剥がす   → "cafe rene! — 100% done"
  4. 残りを翻字           → "cafe rene! - 100% done"   (— → -)
  5. 非英数字 → ハイフン  → "cafe-rene---100--done"
  6. たたんで整える       → "cafe-rene-100-done"

各段階の擬似コードです。

import re, unicodedata

def slugify(text, maxlen=80):
    text = text.lower()
    text = unicodedata.normalize("NFKD", text)      # 分解
    text = "".join(c for c in text                  # 結合マークを除去
                   if unicodedata.category(c) != "Mn")
    text = transliterate(text)                       # ø, ł, ß, CJK, …
    text = re.sub(r"[^a-z0-9]+", "-", text)          # 残りすべて → -
    text = re.sub(r"-{2,}", "-", text).strip("-")    # たたんで整える
    return text[:maxlen].rstrip("-")

category(c) != "Mn" が要を担う行です。NFKD は é を素の e の後に U+0301 (結合アキュートアクセント) へ割り、これは Unicode カテゴリ Mn (Mark, nonspacing) を持ちます。Mn のマークを剥がすと、素の基底文字が残ります。NFD は アクセントに同じく働きます。NFKD はさらに互換形をたたみます (fi、全角 数字 → ASCII 数字)。これはたいていスラッグに望むことです。

発音区別符号の問題は発音区別符号より大きい

正規化してから剥がすやり方は、アクセント付きのラテン文字が基底 + マークへ 分解 されるから働きます。大きな一群の文字はまったく分解されず、それらに対して パイプラインは静かに何も生みません。

入力 NFKD + 剥がした後 理由
é à ü ñ e a u n 基底 + Mn マークに分解
ø ø 分解なし、原子的な文字
ł ł 原子的なポーランド語のストローク付き L
ß ß 原子的、特別扱いが必要 → ss
Æ Œ Æ Œ 原子的な合字 → ae oe
日本語 日本語 CJK、ラテン形がまったくない
Привет Привет キリル、ローマ字化が必要
مرحبا مرحبا アラビア、ローマ字化が必要

右の 2 行のどれも正規化を素通りした後、[^a-z0-9]+ の段階で消されます。失敗は 静かで完全です。

slugify("Smørrebrød")  → "smrrebrd"     (ø が落ち、翻字されない)
slugify("Łódź")        → "d"            (ł と ó 剥がしがゴミに衝突)
slugify("日本語")       → ""             ← 空のスラッグ
slugify("Привет мир")  → ""             ← 空のスラッグ

空のスラッグは本物のバグです。/posts/ や、ほかのすべての翻字不能なタイトルと 衝突するルートになります。解は、マークを剥がした に回り、原子的な文字を 明示的に対応づける翻字の段階です。

TRANSLIT = {"ø": "o", "ł": "l", "ß": "ss", "æ": "ae",
            "œ": "oe", "đ": "d", "þ": "th", "ð": "d"}

非ラテンのスクリプトには、スクリプトごとのローマ字化テーブルが必要です。日本語nihongo (またはローマ字化の選択により ri-ben-yu)、キリルは GOST/BGN の テーブルで、アラビアは標準の翻字で。唯一の正解はありません。日本語だけでもヘボン 式対訓令式があり、中国語は声調記号のあるなしのピンインがあります。これらの テーブルを同梱するライブラリ (さまざまな slugify/unidecode 系) はあなたの 代わりに選択をし、あなたの読者が別のものを期待するまでは問題ありません。正直な 立場は、任意の Unicode の完全網羅の翻字は文字列の整形の問題ではなくローカライズの 問題であり、スラッグ生成器はそれを近似できるだけだ、ということです。

翻字が空の結果を生むなら、空のスラッグを出すのではなく、生成した識別子 (短い ハッシュやカウンタ) にフォールバックしてください。

衝突

異なるタイトルが同じスラッグへ日常的に崩れます。スラッグ化は設計上、損失がある からです。

slugify("C++")        → "c"
slugify("C#")         → "c"
slugify("C")          → "c"
slugify("Node.js")    → "node-js"
slugify("Node JS")    → "node-js"

変換をより賢くして衝突を防ぐことはできません。入力を区別していた情報が、まさに あなたが捨てる句読点だからです。代わりに書き込み時に扱ってください。

  • 確認して接尾辞: 候補のスラッグを照会し、占有されていれば -2-3、… を 1 つ空くまで足します。読みやすいですが、一意性のチェックと再試行のループが 要ります。
  • 短いハッシュの接尾辞: 一意な鍵のハッシュの数文字を足します (node-js-7f3a)。 一発で常に一意ですが、少し見た目が劣ります。
  • 一意制約 + 再試行: データベースに一意性を強制させ、衝突時に再試行します。 並行性のもとで唯一、競合に安全な選択肢です。

読みやすさが重要で書き込みがまれなコンテンツには確認して接尾辞を、並行的な何かには 制約 + 再試行を選んでください。

安定性: 一度発行、決して再生成しない

最も有害なスラッグのバグは、タイトルが編集されるたびにスラッグを再生成すること です。誰かが "Cafe Rene""Café René" の誤字を直すと、あなたのコードが スラッグを再計算し、URL が cafe-rene から別のものへ静かに変わり、すべての外部 リンクと積み上がったすべての SEO シグナルが今や 404 を指します。

ルールはこうです。レコードが作られるときにスラッグを独自の列として保存し、不変 として扱ってください。 タイトルの編集はスラッグに触れません。本当にスラッグを 変える必要があるなら、新しいものを発行し、古いものを保ち、古いものから新しいものへ 301 リダイレクトを提供してください。スラッグはタイトルの派生ビューではなく、 あなたの URL 契約の一部です。

長さ、数字、予約語、ストップワード

ひと握りの小さな決定が実際の実装を仕上げます。

  • 長さ: スラッグを制限し (60〜80 文字が典型)、ハイフンの境界で整えた後、 rstrip("-") で決してハイフンで終わらないようにしてください。
  • 先頭の数字: "2026 review"2026-review は URL には問題ありませんが、 スラッグがプログラミングの識別子 (アンカー ID、生成された変数名) として使われる なら先頭の数字は無効なので、その場合は接頭辞を付けてください。
  • 予約語: あなた自身のルート (neweditadminapi) と衝突する スラッグを禁止してください。「New」というタイトルの記事が、ルーターがすでに所有 するパスへスラッグ化されてはいけません。
  • ストップワード: theaofand を剥がすとスラッグが短くなり CMS でよく行われますが、トレードオフです。短いタイトルの読みやすさを損ない ("The Office"office)、1 つの言語にしか当てはまりません。ほとんどの現代の スラッグ生成器はストップワードを残します。

スラッグ vs パーセントエンコーディング

スラッグとパーセントエンコーディングは、重なる問題を別々に解きます。元の タイトルをパスに保つと、URL エンコーダが安全でないバイトをエスケープします。 Café RenéCaf%C3%A9%20Ren%C3%A9 になります。正しく、損失なしですが、 読めず、共有プレビューで見栄えが悪いです。スラッグは代わりに非予約集合 の中に とどまり、エスケープが決して起きませんが、損失があります。2 つは同じ取引の両端 です。忠実さを保ってエスケープするか、読みやすさを保って捨てるかです。 パーセントエンコーディングの記事 がエスケープ側を 詳しく扱い、Base64 は暗号化ではない を 不思議に思ったことがあるなら同じ主題です。バイトを転送に安全にすることは、その 意味を減らすことと同じではありません。

一度きりの変換には、私たちの URL スラッグ生成器 がこの パイプライン全体を回し (正規化、剥がし、翻字、たたみ)、タイトルを貼り付けて何に 解決されるかを正確に見られます。非ラテンのタイトルでなければ何もないものへ スラッグ化されていた場合も含めて。