概要
Twitter API 経由でツイートする場合には、リクエストを送る前に字数制限を越えていないか検証する必要があります。
しかし Twitter の文字数カウントは独自の複雑な仕様になっており、ナイーブな方法(言語標準の length
など)では正しい結果が得られません。
そこで本記事では以下の内容を取り上げます。
- 文字数カウントに伴う難点の説明
- ツイートの長さを 厳密に 数えられる公式ライブラリ twitter-text の紹介
- 他言語に
twitter-text
を移植するために必要な情報のまとめ - Python 3 用の自作パッケージ twitter-text-python の紹介
この記事が想定する読者
- Twitter bot などを作っていてツイートの文字数カウントが必要な人
- その他の状況で文字数カウントの沼にハマった人
- 文字コードについて手を動かしながら学ぶための題材が欲しい人
文字数カウントに伴う難点
一般的な問題
文字コードの区切り方
私たちが普段目にしている文字列は、内部的にはバイトで表現される整数の列です。
この整数列を「一文字ごと」に区切るための方法が状況によって複数パターン考えられ、それぞれ異なる「文字数」になってしまいます。
現在広く普及している Unicode の場合、「一文字ごと」の区切り方の候補として次のようなものが考えられます。(このあたりの概念については、記事『Unicodeとは? その歴史と進化、開発者向け基礎知識』に簡潔にまとまっているので一読をお勧めします。)
- UTF-8 code unit
- UTF-16 code unit
- Unicode codepoint (= UTF-32 code unit)
- Grapheme Cluster (書記素クラスタ)
また、 codepoint で区切る際には事前に Normalization Form に変換するか否かも重要です。
例えば é
という字には 2 種類の同値な表現方法があり、それぞれ codepoint 列の長さが異なります。
- 通常のアルファベット
e
(U+0065) とアクセント◌́
(U+0301) の列 - 合成済みの文字
é
(U+00E9)
前者のように結合文字を分解した形式を Normalization Form D (NFD) と呼び、反対に合成した形式を Normalization Form C (NFC) と呼びます。
区切り方による長さの違いを示す具体例
ascii 文字だけに閉じている場合 Grapheme = Unicode codepoint = UTF code unit
が成立するため、何も難しいことなど無いように見えます。
分割単位 | 分割された列 | 長さ |
---|---|---|
Grapheme | A | 1 |
codepoint (NFC) | U+0041 | 1 |
codepoint (NFD) | U+0041 | 1 |
UTF-16 BE | 0x0041 | 1 |
UTF-8 | 0x41 | 1 |
一方で結合文字を含む場合 Grapheme = Unicode codepoint
は一般に成立しません。しかし NFC に変換すれば一致することもあります。
分割単位 | 分割された列 | 長さ | ||
---|---|---|---|---|
Grapheme | é | 1 | ||
codepoint (NFC) | U+00E9 | 1 | ||
codepoint (NFD) | U+0065 | U+0301 | 2 | |
UTF-16 BE | 0x0065 | 0x0301 | 2 | |
UTF-8 | 0x65 | 0xCC | 0x81 | 3 |
次は特に複雑な絵文字の例です。 NFC に変換しても変わらず Grapheme ≠ codepoint
であり、おまけに各 code point は UTF-16 の surrogate pair で表現されているため codepoint = UTF-16 code unit
すら成り立ちません。
分割単位 | 分割された列 | 長さ | |||||||
---|---|---|---|---|---|---|---|---|---|
Grapheme | 👏🏽 | 1 | |||||||
codepoint (NFC) | U+0001F44F | U+0001F3FD | 2 | ||||||
codepoint (NFD) | U+0001F44F | U+0001F3FD | 2 | ||||||
UTF-16 BE | 0xD83D | 0xDC4F | 0xD83C | 0xDFFD | 4 | ||||
UTF-8 | 0xF0 | 0x9F | 0x91 | 0x8F | 0xF0 | 0x9F | 0x8F | 0xBD | 8 |
Twitter 特有の問題
ツイートの文字数カウントにおいては、 NFC に変換された文字列の Unicode codepoint を基本的な処理単位としますが、 URL および絵文字は例外的に扱われます。また、文字の種類によって異なる重みが付けられる点も重要です。
URL を常に一定の文字数としてカウント
ツイートの中に URL が含まれる場合、その URL の文字列としての長さは無視され、常に定数分の長さとしてカウントされます。
この仕様を再現するために、「ツイートの中から URL を抽出」というまた別の問題が発生します。 Twitter の URL 抽出処理には、様々なコーナーケースを考慮して作られた独自の正規表現が使われているため、その挙動を正確に再現しなければいけません。
例えば次のようなツイート
日本語http://example日本語.jp/path/index.html日本語
があったとき、 Twitter のテキストパーサーは http://example日本語.jp/path/index.html
を URL として抽出します。
一方、適当に Stack Overflow から拾ってきた下記の正規表現で検索すると結果が異なり、何もマッチしません1。
https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)
絵文字を常に一定の文字数としてカウント
Grapheme ではなく Unicode codepoint を処理単位とすると、前述した例のように複数の codepoint から成る絵文字に対処できません。
そのため、 Unicode が定める絵文字のリストをもとに正規表現を作成して、絵文字に対応する codepoint 列を抽出します。
文字の種類によって異なる重みを割り当て
長い間ツイートの文字数上限は 140 字でしたが、 2017 年 9 月の仕様変更により 280 字に引き上げられました。
と言っても、影響を受けるのは主にアルファベットなどで構成されるツイートに限られ、日本語や中国語・韓国語などは依然として 140 字に制限されます。
このような変更がされた理由は、言語によって 1 文字あたりの情報量に大きな差があるためです。例えば日本語と英語を比較すると、同じ内容の文を記述するために必要な文字数が 2 倍ほど違います。
新仕様でツイートの長さ (Weighted Length) を測る際には、文字の種類によって異なる重みを割り当てて総和を取ります。
正確には、文字の重みは Unicode code point の特定範囲に割り当てられる整数値で、例えばアルファベットなら重み 1 、 CJK 文字なら重み 2 が割り当てられます。
"abc" -> 長さ 3
"日本語" -> 長さ 6
"日本語abc" -> 長さ 9
twitter-text ライブラリ
幸いなことに、ツイートの文字数カウントを Java, JavaScript, Ruby, Objective-C で正確に実装したライブラリ twitter-text が公式に公開されています。
このライブラリの主要な機能は parseTweet
API で、文字列を引数に取りパース結果を返します。パース結果は下記の要素をもつオブジェクトです:
-
weightedLength
: ツイートの長さを表す非負整数値 -
permillage
: ツイートの長さと上限値の比を表す 0~1000 の整数値 -
isValid
: 妥当なツイートかを表す真偽値 -
displayTextRange
: 文字列全体の始端と終端のインデックス -
validDisplayTextRange
: ツイートとして妥当な部分文字列の始端と終端のインデックス
例として、次の文字列を parseResult
に入力した場合:
日本語http://example日本語.jp/path/index.html日本語 👪
以下のパース結果が得られます:
{
"weightedLength": 38,
"permillage": 135,
"isValid": true,
"displayTextRange": [0, 44],
"validDisplayTextRange": [0, 44]
}
なお twitter-text には parseResult
以外にもハッシュタグ抽出などの機能がありますが、本記事では扱いません。
twitter-text を他言語に移植する
上記 4 つの言語以外を使っていて、ツイートの文字数カウントが必要になる場合があるかもしれません。(私の場合、実際に Python で Twitter bot を作成していた時に必要になりました。)
このライブラリは Twitter サーバーでのテキスト処理を正確に模倣できて初めて意味をなすので、他言語に移植する際にはオレオレ実装にならないよう特に細心の注意を払うべきです。そこで、正確な実装のために必要なテストケースと仕様の定義に関する情報を以下にまとめました。
テストケース
twitter-text ライブラリには YAML で記述された多数のテストケースが付属しており、特定の言語に依存せず利用できるようになっています。これは parseTweet
などの API を単体テストするためのもので、各テストケースは (説明文, 入力, 正解出力)
の三つ組で表されます。
twitter-text
を実装する時に真っ先にすべきなのは、この YAML ファイルを読み込んでテストを自動実行する仕組みを作ることです。
仕様の定義
twitter-text に関して、特定の言語に依存しない形で正確に定義された仕様は公開されていません。。そのため、主に Java のコードを見て私が推測した仕様を記載します。
Weighted Length
参照:Java 実装における parseTweet
関数の定義
-
raw_text
の Unicode Normalization Form C (NFC) をnormalized_text
とする -
normalized_text
を要素に分割- URL を抽出(後述)
- URL 以外の部分から絵文字を抽出(後述)
- URL と絵文字以外の部分から Unicode codepoint を抽出
- 各要素に重みを割り当てる
- URL : transformedURLLength
- 絵文字 : defaultWeight
- codepoint : 属する range によって変化
- 全ての重みを加算して scale で割った値を
weighted_length
とする
URL 抽出
参照:Java 実装における extractUrlsWithIndices
関数の定義
- 下記の正規表現パターン
extract-url
で検索 - マッチした文字列のうち、パターン
url
またはtco-url
にマッチする部分を URL とする - ただし URL は以下の条件を満たさなければならない
- 「
protocol
が存在」または「preceding-chars
がinvalid-characters
にマッチしない」 - ドメインを punycode 変換した時の URL 全体の長さが 4096 以下
-
tco-slug
の長さが 40 以下
- 「
invalid-characters = [\u{FFFE} \u{FEFF} \u{FFFF}]
directional-characters = [\u{061C} \u{200E} \u{200F} \u{202A}-\u{202E} \u{2066}-\u{2069}]
laten-accents-chars = [\u{00C0}-\u{00D6} \u{00D8}-\u{00F6} \u{00F8}-\u{00FF} \u{0100}-\u{024F}]
|| [\u{0253} \u{0254} \u{0256} \u{0257} \u{0259} \u{025B}]
|| [\u{0263} \u{0268} \u{026F} \u{0272} \u{0289} \u{028B} \u{02BB}]
|| [\u{0300}-\u{036F} \u{1E00}-\u{1EFF}]
cyrillic-chars = [\u{0400}-\u{04FF}]
chars = [a-z0-9] || laten-accents-chars
general-path-chars = [a-z 0-9 ! * ' ; : = + , . $ / % # _ ~ & @ | - \[ \] \u{2013}]
|| latin-accents-chars
|| cyrillic-chars
parens-one-nested = "(" general-path-chars+ ")"
parens-two-nested = "(" general-path-chars* parens-one-nested general-path-chars* ")"
parens = parens-one-nested | parens-two-nest
punycode = "xn--" [- 0-9 a-z]+
subdomain = (chars (chars || [- _])*)? chars "."
domain-name = (chars (chars || [-])*)? chars "."
punctuation-chars = [- _ ! \ " # $ % & ' ( ) \[ \] { } * + , . / : ; < = > ? @ ^ ` | ~]
unicode-chars = [^ \s \p{Z} \p{InGeneralPunctuation}] -- punctuation-chars
unicode-domain = (unicode-chars (unicode-chars || [-])*)? unicode-chars "."
domain = subdomain* domain-name (gtld | cctld | punycode)
| (←protocol) domain-name cctld
| (←protocol) unicode-domain (gtld | cctld)
| domain cctld (→"/")
protocol = "http" s? "://"
port = [0-9]+
path-ending-chars = [a-z 0-9]
| [_ # / - +]
| latin-accents-chars
| cyrillic-chars
| parens
path-normal = general-path-chars*
(parens general-path-chars*)*
path-ending-chars
path-with-atmark = @ general-path-chars /
path-fragment = path-normal | path-with-atmark
path = "/" path-fragment*
query-chars = [a-z 0-9]
| [! ? * ' ( ) ; : & = + $ / % # - _ . , ~ | @]
| "[" | "]"
query-ending-chars = [a-z 0-9 - _ & = # /]
query = "?" query-chars* query-ending-chars
url = protocol? domain (":" port)? path? query?
tco-slug = [a-z 0-9]+
tco-url = ^ protocol "t.co/" tco-slug query?
preceding-char = [^ a-z 0-9 @ @ $ # #] -- invalid-characters
| directional-characters
| ^
extract-url = preceding-char url
絵文字抽出
参照:Java 実装における TwitterTextEmojiRegex
の定義
UTS #51: Unicode emoji が定める RGI set2 に含まれる絵文字を抽出する。
config
Weighted Length の計算に関わるパラメーターを指定する。この記事の執筆時点で以下の 3 つのバージョンがあり、最新版は v3
。
-
v1
: 2017 年 9 月の仕様変更以前の設定 -
v2
: Weighted Length を導入 -
v3
: 絵文字抽出処理を導入
twitter-text-python
Python にも twitter-text-python というパッケージがありますが、これは公式が提供するテストケースをパスしていないため、Twitter の文字数カウントと結果が一致する保証はありません。
というわけで、新たに Python 3 用のパッケージ twitter-text-parser を自作して公開しました。公式の JavaScript 実装をできる限り忠実に移植し、テストを全てパスしているため正確性は申し分ありません。
おまけ - Twitter テキストパーサーの穴を突く
Grapheme Cluster
Twitter のテキストパーサーは Unicode codepoint を基本的な処理単位としますが、結合文字に対処するために NFC への変換と絵文字の抽出を行います。
しかし世の中にはこのどちらでも対処できないタチの悪い Grapheme Cluster が存在します。
ẛ̣
という文字は 2 つの codepoint ẛ
(U+1E9B) と ◌̣
(U+0323) で構成され、 NFC に変換しても元のままです。この 2 つの codepoint の重みはそれぞれ 2 と 1 なので、 ẛ̣
の Weighted Length は 3 になります。つまり、 280 // 3 = 93
回 ẛ̣
を打ち込むと字数上限に達します。
ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣ẛ̣
— PND (@__P_N_D__) August 3, 2019
t.co URL の例外処理
ツイートから URL を抽出する仕様の「パターン extract-urls
にマッチした文字列のうち、パターン url
または tco-url
にマッチする部分を URL とする」に注目してみます。
例えば次の文字列
https://t.co/aBKlrUWVcb/https://www.example.com
をツイートすると、パターン extract-urls
が全体にマッチし、そのうち前半の https://t.co/aBKlrUWVcb だけが tco-url
にマッチして URL として抽出されます。そして、残った https://www.example.com
の部分は無視され URL として扱われません。
https://t.co/aBKlrUWVcb/https://www.example.com
— PND (@__P_N_D__) August 5, 2019
普通、ツイートの中の URL は全てパースされ強制的に t.co でラップされますが、この仕様を突くとパースを回避して URL をそのままツイートの中に書き込むことができます。
まとめ
文字数のカウントは、一見簡単そうに思えて非常に奥が深い問題です。特にツイートの長さを計算する場合は、実際に絵文字などが高頻度で使われるため、厄介な問題が無視できないものとして表面化してきます。
自分の好きな言語で twitter-text を実装すると、便利なライブラリが一つ増えるだけでなく、 Unicode に対する理解が深まるので、一度試してみてはいかがでしょうか。
参考リンク
Unicode Standard
- UAX #15: Unicode Normalization Forms
- UAX #29: Unicode Text Segmentation
- UTS #51: Unicode Emoji
- FAQ - Characters and Combining Marks
Unicode 関連の解説記事
- Unicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insider
- The 7 Ways of Counting Characters - LINE Engineering
- Unicodeのgrapheme cluster (書記素クラスタ) | hydroculのメモ
- 絵文字がある種のUnicodeバグを世界から一掃しつつある件について|Rui Ueyama|note
- 「文字数」ってなぁに?〜String, NSString, Unicodeの基本〜 - Qiita
- 絵文字を支える技術の紹介 - Qiita
twitter-text
- twitter/twitter-text: Twitter Text Libraries
- twitter-text Parser — Twitter Developers
- Counting characters — Twitter Developers