Help us understand the problem. What is going on with this article?

ツイートの文字数を **厳密に** 数える方法

概要

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

URLの正規表現(?)
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 を抽出
  • 各要素に重みを割り当てる
  • 全ての重みを加算して scale で割った値を weighted_length とする

URL 抽出

参照:Java 実装における extractUrlsWithIndices 関数の定義

  • 下記の正規表現パターン extract-url で検索
  • マッチした文字列のうち、パターン url または tco-url にマッチする部分を URL とする
  • ただし URL は以下の条件を満たさなければならない
    • protocol が存在」または「preceding-charsinvalid-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

https://github.com/twitter/twitter-text/tree/master/config

Weighted Length の計算に関わるパラメーターを指定する。この記事の執筆時点で以下の 3 つのバージョンがあり、最新版は v3

twitter-text-python

Python にも twitter-text-python というパッケージが一応ありますが、これは公式が提供するテストケースをパスしていない紛い物です。

というわけで、新たに 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ẛ̣ を打ち込むと字数上限に達します。

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 として扱われません

普通、ツイートの中の URL は全てパースされ強制的に t.co でラップされますが、この仕様を突くとパースを回避して URL をそのままツイートの中に書き込むことができます。

まとめ

文字数のカウントは、一見簡単そうに思えて非常に奥が深い問題です。特にツイートの長さを計算する場合は、実際に絵文字などが高頻度で使われるため、厄介な問題が無視できないものとして表面化してきます。

自分の好きな言語で twitter-text を実装すると、便利なライブラリが一つ増えるだけでなく、 Unicode に対する理解が深まるので、一度試してみてはいかがでしょうか。

参考リンク

Unicode Standard

Unicode 関連の解説記事

twitter-text


  1. 見ればすぐにわかりますが、この正規表現は ascii 文字のドメインにしか対応していません。 

  2. RGI は recommended for general interchange の略 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした