にせねこメモ

はてなダイアリーがUTF-8じゃないので移ってきました。

Twitterハッシュタグの仕様について

f:id:nixeneko:20210130202127p:plain

まえがき

Twitterに"#᥋ᵍᶜₒ𝖛ịⅆ"というハッシュタグが流れているのを見た。内部的には"#5GCovid"と等しいようだった。タグの指す内容は置くとして、これが同一視されてるのは奇妙な気もした。
これは、他にも同様のハッシュタグを作れるのでは?と思って、変換器を作った。
nixeneko.github.io

作成する際にTwitterハッシュタグの仕様を探って、なんとなく雰囲気がつかめたのでメモとして残しておく。

Twitterハッシュタグの仕様

(2021/01/29現在のため、変更される可能性がある)

  • #で始まり、ハッシュタグに使えない文字が来るかツイート末まで続く
  • #はツイート頭にあるか、#の前にはスペースが必要
  • $で始まるハッシュタグもあるが、ASCII英字6文字まで、とか制限が強い

ハッシュタグに使える文字

  • Unicode 7.0に存在する文字である必要がある(8.0以降で追加された文字はハッシュタグにならない)
  • ハッシュタグに使える文字は、基本的には、次のgeneral categoryをもつUnicode文字であるようだ:
    • Lu……Letter, Uppercase
    • Ll……Letter, Lowercase
    • Lt……Letter, Titlecase
    • Lm……Letter, Modifier
    • Lo……Letter, Other
    • Mn……Mark, Non-Spacing
    • Mc……Mark, Spacing Combining
    • Me……Mark, Enclosing
    • Nd……Number, Decimal Digit
  • このほかにも個別に追加されていると思われる記号がある。例:
    • 「_」アンダースコア
    • 「・」中黒
    • 「゠」ダブルハイフン U+30A0 Katakana-Hiragana Double Hyphen
  • 数字やマークだけではハッシュタグにはならない

ハッシュタグの同値判定の仕様の推測

  • 大文字小文字を区別しない
  • 文字を同一視するためにUnicode正規化が施されているはず。
    • 結合文字を処理する都合から互換分解(NFKD)だと思われる。ツイート自体にはNFCが使われている
    • あるいは、Unicode Collation Algorithmとかを一部利用しているのかも?
  • ある種の結合文字(combining marks)は無視される
    • "#天デ部"と"#天テ\u0301\u3099部"(Combining Acute + Combining濁点)が同一視される(\uXXXXはUnicode文字を表す)
    • 無視される結合文字の一覧は後掲する
    • ダイアクリティカルマーク合成済み文字の場合は、正規化により分解されて、マークが除去される
  • ひらがな・カタカナの、濁点・半濁点つきの文字とそうでない清音の文字は区別される。また、ひらがなとカタカナも区別される
    • (Unicode正規化の互換分解で半角濁点U+FF9Eは合成用濁点U+3099に変換されるので以下の話は当然なので消した)
    • 清音の仮名の次に半角濁点・半濁点が来ると、濁音・半濁音の仮名として扱われる(ひらがなカタカナともに) (間に一部の結合文字が挟まっててもよさそう)
    • 仮名と(半角または合成用)濁点・半濁点の間にU+034F COMBINING GRAPHEME JOINERが入ると濁点が消えるっぽい。なぜ…?
  • ある種の10進数字は、ASCII文字の数字と同一視される
    • Decimal digit valueの示す数字と同一視されるのかと思ったが、Decimal digit valueが存在していてもASCII数字と同一視されないものがあった。次である:
      • U+0de6-U+0def SINHALA LITH DIGIT
      • U+a9f0-U+a9f9 MYANMAR TAI LAING DIGIT
      • U+104a0-U+104a9 OSMANYA DIGIT
      • U+11066-U+1106f BRAHMI DIGIT
      • U+110f0-U+110f9 SORA SOMPENG DIGIT
      • U+11136-U+1113f CHAKMA DIGIT
      • U+111d0-111d9 SHARADA DIGIT
      • U+112f0-U+112f9 KHUDAWADI DIGIT
      • U+114d0-U+114d9 TIRHUTA DIGIT
      • U+11650-U+11659 MODI DIGIT
      • U+116c0-U+116c9 TAKRI DIGIT
      • U+118e0-U+118e9 WARANG CITI DIGIT
      • U+16a60-U+16a69 MRO DIGIT
      • U+16b50-U+16b59 PAHAWH HMONG DIGIT
  • 他にも、特殊な同一視がみられる。個別にルールが追加されているのかもしれない。次のような例を見つけた:
    • "Å"→"AA", "å"→"aa"
    • "Ä"→"AE", "ä"→"ae"
    • "Æ"→"AE", "æ"→"ae"
    • "ı"→“i" (トルコ語用のdotless iの対応)
    • "Ö"→"OE", "ö"→"oe"
    • "Ø"→"OE", "ø"→"oe"
    • "Ü"→"UE", "ü"→"ue"
    • "ß"→"ss" (エスツェット)

無視されるCombining Marksの一覧を次に挙げる。抜けがあるかもしれない。

ignorable_marks = [
    "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0305", "\u0306", "\u0307", 
    "\u0308", "\u0309", "\u030a", "\u030b", "\u030c", "\u030d", "\u030e", "\u030f", 
    "\u0310", "\u0311", "\u0312", "\u0313", "\u0314", "\u0315", "\u0316", "\u0317", 
    "\u0318", "\u0319", "\u031a", "\u031b", "\u031c", "\u031d", "\u031e", "\u031f", 
    "\u0320", "\u0321", "\u0322", "\u0323", "\u0324", "\u0325", "\u0326", "\u0327", 
    "\u0328", "\u0329", "\u032a", "\u032b", "\u032c", "\u032d", "\u032e", "\u032f", 
    "\u0330", "\u0331", "\u0332", "\u0333", "\u0334", "\u0335", "\u0336", "\u0337", 
    "\u0338", "\u0339", "\u033a", "\u033b", "\u033c", "\u033d", "\u033e", "\u033f", 
    "\u0340", "\u0341", "\u0342", "\u0343", "\u0344", "\u0345", "\u0346", "\u0347", 
    "\u0348", "\u0349", "\u034a", "\u034b", "\u034c", "\u034d", "\u034e", "\u034f", 
    "\u0350", "\u0351", "\u0352", "\u0353", "\u0354", "\u0355", "\u0356", "\u0357", 
    "\u0358", "\u0359", "\u035a", "\u035b", "\u035c", "\u035d", "\u035e", "\u035f", 
    "\u0360", "\u0361", "\u0362", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", 
    "\u0368", "\u0369", "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", 
    "\u0610", "\u0611", "\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", 
    "\u0618", "\u0619", "\u061a", "\u064b", "\u064c", "\u064d", "\u064e", "\u064f", 
    "\u0650", "\u0651", "\u0652", "\u0653", "\u0654", "\u0655", "\u0656", "\u0657", 
    "\u0658", "\u0659", "\u065a", "\u065b", "\u065c", "\u065d", "\u065e", "\u065f", 
    "\u0670", "\u06d6", "\u06d7", "\u06d8", "\u06d9", "\u06da", "\u06db", "\u06dc", 
    "\u06df", "\u06e0", "\u06e1", "\u06e2", "\u06e3", "\u06e4", "\u06e7", "\u06e8", 
    "\u06ea", "\u06eb", "\u06ec", "\u06ed", "\u093c", "\u094d", "\u3099", "\u309a"]

ひらがな・カタカナ用の合成用濁点・半濁点も無視される。ものの、濁点や半濁点がついた仮名をそうでない仮名と同一視することはしない(「テ」と「デ」は別)。そのため、濁点や半濁点のつき得る仮名についた合成用濁点・半濁点は削除しない。
これは、Unicode正規化により、NFCなどで合成してから合成用濁点・半濁点を除去するという順序にするとうまくいくと思う。

考えられるハッシュタグの同値判定の流れ

同値判定と書いてるけど、collation (照合)とよぶものらしい。そのために、Unicode Collation Algorithm (UCA)という標準が存在し、それをカスタマイズして使っている可能性もあるが、よくわからない。

さて、ともかく次のような流れで再現できると思われる:

  • 仮名+半角濁点・半濁点→濁音・半濁音の仮名に変換(Unicode正規化(NFKD/NFKC)で普通の仮名になるので略)
  1. Unicode正規化(NFKD)による互換分解
  2. 取り除けるマークを取り除く(合成用濁点・半濁点についてはここでは残す)
  3. 小文字にする
  4. "ı"→“i"、"ß"→"ss"などのルールの適用
  5. Unicode正規化(NFC?)による合成
  6. 合成されなかった合成用濁点・半濁点を除去

難読化の仕組み

上記したハッシュタグの同値判定を逆方向にたどっていくと、最終的に同値に判定される様々なバリエーションが得られる。詳しくはソースでも見てください。行きあたりばったりなので見てわかるかは知らないですが…
github.com