私が歌川です

@utgwkk が書いている

twitter-textのPerl実装 Twitter::Text を公開した

Twitter::Text - Perl implementation of the twitter-text parsing library - metacpan.org

Perlでツイートをバリデーションしたいときに使うことができます。どうぞご利用ください。

いろいろ学びがあったので、実装方針などについて書いていきます。

動機

そもそも既存ライブラリはなかったのか、と思うのですが、どうやら9年前から存在しなかったようです。

GitHubで twitter text perl で検索すると、以下のリポジトリがヒットしますが、どれも要件を満たさなかったです。

CPANをざっと twitter text というクエリで検索してみたのですが、どうやら既存ライブラリはなさそうでした。

なければ作るしかない、ということで、やっていきが発生します。

実装方針・ハマりどころ

Ruby実装をもとにする

Counting characters | Docs | Twitter Developerに従えばよい、というのは、言うは易しくなんとやら、というやつです。既存実装を参考にする作戦を立てます。

公式のtwitter-textライブラリは、以下のプログラミング言語による実装を含みます。

  • Java
  • Ruby
  • JavaScript
  • Objective-C

サードパーティー製のものを含めると、以下の言語実装もあります。

  • Rust
  • Swift

この中でいちばんPerlに近いのはRubyでしょう。Ruby実装を移植する形でなんとかすることを考え、実装していきました。

正規表現地獄

最初に目に入ったのは、正規表現地獄でした。とくに、絵文字にマッチする正規表現は自動生成されており、最初はその巨大さに愕然とするばかりでした。

Rubyの \u{$CODE} というUnicodeリテラルは、Perlの \N{U+$CODE} に対応している、ということに気づいたため、文字列置換でなんとかしました。

正規表現マッチングの移植と特殊変数

ツイートの文字数を計算するには、ツイートに含まれるURLと絵文字を抽出する必要があります。なぜなら、URLは一律で23文字、絵文字は2文字としてカウントすることになっているためです。

URLを抽出する処理は、流れとしてはシンプルに見えます。

  • ツイート本文から、URLっぽい部分を正規表現で抽出する
  • マッチした箇所の始点と終点を記録する
  • URLとしてvalidなら、返り値のリストに追加する
    • URLとしてvalidかどうかは、正規表現マッチした文字列をprotocol, domain, port, path, query, fragment……などのグループに分けてバリデーションする

Rubyの場合、 String#scan にブロックを渡すことで、マッチした箇所をグループに分けてループを回すことができます。また、マッチした箇所の始点と終点は $~ 特殊変数から取り出すことができます。

text.scan(REGEXP) do |all, before, url, protocol, domain, port, path, query|
  valid_url_match_data = $~
  start_position = valid_url_match_data.char_begin(3)
  end_position = valid_url_match_data.char_end(3)
  ...
end

Perlの場合はどうするかというと、whileループ*1と、正規表現のグループ化と /g フラグを組み合わせることで、同様のループを回すことができます。また、各グループのマッチした始点と終点は、それぞれ @- @+ 特殊変数から取得できます。

while ($text =~ /($REGEXP)/g) {
    my ($before, $url, $protocol, $domain, $port, $path, $query, $fragment) = ($3, $4, $5, $6, $7, $8);
    my $start_position = $-[4];
    my $end_postition = $+[4];
    ...
}

$REGEXP 正規表現全体をグループ化しているので、 文字列をキャプチャしたときのインデックスが1つずつずれることに注意しましょう。

他の抽出処理も基本的な流れは同じなので、歯を食いしばって移植していきます。

YAML::Tiny

twitter-textのテストケースや、TLDなどのリストはYAMLで公開されています。また、テストケースはYAMLを読み込んでテストする形にすること・YAMLからテストコードを生成しないこと(?)、と書いてあるので、YAMLのパースが必要です。

  1. Create a test which reads these files and executes the test cases.
    1.a. Do not convert these files to test cases statically. These test cases will change over time.

https://github.com/twitter/twitter-text/tree/master/conformance#guidelines-for-use

YAMLのパースに YAML::Tiny を使ったのですが、 ["foo", "bar"] という配列表現を、そのまま文字列としてパースしてしまっていました。配列表現を eval することでArrayRefに変換することでなんとかしました。

また、 expected: true というYAMLのエントリを読み込んだときも "true" という文字列としてパースされてしまいました。これはどうパースするべきか難しい気がしますが、ひとまずテストケースの側で is ..., bool($expected eq 'true') という形でアサーションすることにしました。

いちばん困ったのは、\u0000 のような、YAMLのUnicodeリテラルをうまくパースできないことです*2。これはYAML 1.2の規格を眺めつつ正規表現置換でなんとかしました。ここまでくると、 YAML::Tiny より高機能なYAMLパースライブラリを探す必要がある気がします。誰かよいYAMLパースライブラリについて教えてください。

JSONをパースする必要もあったのですが、これは JSON::XS を使えばよかろうということで、とくに困りませんでした。

2020/10/31 追記

サロゲートペア

display_offset_end (表示される文字列の終端の位置) や valid_offset_end (ツイートとしてvalidな文字列の終端の位置) が、テストケースと違う値になって、どうすればいいんだ……と頭を抱えることになりました。

Ruby実装のテストを見に行くと、言語実装によって文字数カウントの方法が違うのでテストしない、ということが書いてあって、それでいいのか……と思い、その方針に倣うことにしました。値は返すけどconformanceに従ってるとは限らないよ、というスタンスです。 JavaScript実装などでは、意図した通りの valid_offset_end が取れる? ようです。なんにせよ文字数カウントの方法が違うだけで、何文字削ればvalidになるかは引き算をすれば計算できるので、まあよいのではないでしょうか。

まとめ・感想

twitter-textのPerl実装 Twitter::Text を公開しました。autolinkなどは実装されていませんが、ツイートのバリデーション用途ぐらいにはじゅうぶんな機能が実装されているはずです。もっと効率的に書けるとか、バグってる、などありましたら GitHub - utgwkk/Twitter-Text: Perl implementation of the twitter-text parsing library までお願いします。

今更ですが、Perlの名前空間の規則に従うなら Text::Twitter にしたほうがよい気がしてきました。しかし、https://github.com/djm4/twitter-text-perl と名前がかぶる*3ので悩ましいところですね……。

*1:試してないけど、forループでもできる?

*2:なんかどうしてもテスト通らないけどなんでなのか……と頭を抱えて、printデバッグしたら \u0000 みたいな文字列が目に入って発覚した

*3:その上、READMEにhttp://search.cpan.org/dist/Text-Twitter/へのリンクがある!!