【Java】Twitter4jを使ってひなっちのおはっちをさーちして遊んだっち!


別にここであえて語るまでもないことだが、「ひなっち」とは、
ロックバンド「ストレイテナー」「Nothing's Carved In Stone」「Fullarmor」「Killing Boy」のベーシスト「日向秀和」氏の愛称である。
(以後、本項では「ひなっち」と記載させていただく)
ひなっちは基本毎日ツイッターで「おはっち」という挨拶を心がけており、
それに対してファン(フォロワー)のアカウントの方から「おはっち」と返信(リプ)をすると、
リプした順番に応じて(大体5位くらいまで?)ひなっちから「1番おはっちおめでとう!」というようなメッセージがもらえる。
フォロワーの方たちはひなっちおはっちいっちばん(一番)を取るべく
気合いをいれてツイッターでまちかまえているのだ。
これはライブ会場で先頭に赴く心情と近いものがあるのだろう(勝手な予想)。

で、この「ひなっちのおはっち」に対して、

  • 一体どれほどの人が「おはっち」しているのか?
  • 最短でどれほどの時間で「おはっち」しているのか?

というのを知りたくなったので、
Twitter4jというJavaのツイッターAPIを使ってひなっちおはっちさーちし、集計してみることにした。
本項ではそのためのTwitter4jのメモと、それをもとに作成した趣味用プログラム「ひなっちおはっちさーち」について記述する。

Twitter4j公式ページ:
http://twitter4j.org/ja/index.html

※ちなみに、Twitter4jを使うにあたっては、大前提で「APPアカウント登録」みたいな作業が必要になる。
ググれば出てくるが、https://apps.twitter.com/に行って、
指示に従って「APPアカウント登録」を行い、認証に必要な以下4つのプロパティ情報を取得のうえ、
Java実行環境と同階層に「twitter4j.properties」という名前で保存する。

oauth.consumerKey=*************************  
oauth.consumerSecret=**************************************************  
oauth.accessToken=**************************************************  
oauth.accessTokenSecret=*********************************************  

あるいは-Dのシステムパラメータで設定するとか、
Java内でConfig設定用クラスを用いて設定するとか、
いくつか方法があるみたいではあるが、とりあえずこれで。


 


 
Twitter4jは割と昔からあるみたいだが、登場からしばらく経ちいろいろ仕様が変わっているようで、
ググって出てくる記事やページも、2017年7月16日現在のTwitter4j最新Ver(4.0.4)に照らし合わせると、そのまま適用できない事例やサンプルコードが多い。
Twitter4jのダウンロードパッケージにも一応申し訳程度(?)にJavaDocはついてるが、全部英語なので読めるわけがない
まあそんなわけで重要なポイントでは手さぐりでの実装・実験が必要になった。
参考にしたサイトのURLを載せておく。
http://www.mwsoft.jp/programming/java/twitter4j_2016.html
Twitter4jでのsearchの基本的な使い方が載っている。
いろいろ調べた中では一番参考になった。
https://dev.twitter.com/rest/public/search
twitter APIの公式ページ。
クエリに指定するパラメータの記述方法がサンプルを含めていくつか載っている。
リプライの検索方法を知りたかったのだが、まあ、見つけたからいいや。

Twitter4jにおけるsearchの基本的な実装は以下の通りだ。

// (1)Twitterのインスタンスをつくる  
Twitter twitter = new TwitterFactory().getInstance();  

// (2)Queryオブジェクトをつくる
Query query = new Query();

// (3)クエリをセットする
query.setQuery(“おはっち”);

// (4)結果件数をセットする
query.setCount(100);

// (5)検索する
QueryResult result = twitter.search(query);

// (6)検索結果を取り出す
List resultList = result.getTweets();

// (7)総ナメする
for (int i=0; i < resultList.size(); i++) {
Status tweet = resultList.get(i);
…(省略)…
}


冒頭書いた「APPアカウント登録」が済んでいないとか、
済んでいてもpropertiesの設定に誤りがある、
認証できない(インターネット接続できない)、
というような場合は、(1)の段階で実行エラーになって先に進めない。

(3)が、特に今回の「ひなっちおはっちさーち」におけるキモ。
ここで検索条件の詳細を記述(指定)するのだが、ここは後述する。

(4)における、Query#setCountの引数はintだが、Twitter APIのsearchメソッドの仕様上、100以上与えても意味ないらしい。
(実際1000とか与えても100件しか返却してくれない。実はこれが最大のネックである。詳細は後述する。)

(6)で取り出すのはjava.util.Listであり、型は「Status」というオブジェクト。
この「Status」というのがTwitterの「投稿文(つぶやき)」そのものを表しており、
普通のツイートも、リプも、リツイートも、中身のフィールドの値の有無はあれど、全て「Status」というので表現されている。
それのリストを取り出してグルグル回すだけなので、検索の考え方的には至ってシンプルで、直感的にわかりやすい。




(3)のクエリ指定文字列について。

「ひなっちおはっちさーち」において実現したい機能性は主に下記2点。

  1. ひなっち本人がおはっちツイートをした時間・ふぁぼ数・「おはっち」リプの数等
  2. 1番おはっちリプの時間(特にひなっち本人の「おはっち」ツイートからの経過時間)平均、最大、最小等

細かくいうともっといろいろ知りたいのだが、一旦最低限実現したい機能としてこのあたりを盛り込みたい。

↑で書いた「実現したい機能性」のうち、
1.は「ひなっち本人から発信された"おはっち"という文言を含むツイート」を検索する必要があり、
2.は「ひなっち本人に向けて発信された"おはっち"というツイート(リプ)」を検索する必要がある。(※)
つまり最低でも2つのクエリが必要だ。

いろいろ試した結果以下の記述で目当てのツイートを抽出できそうだとわかったので、ここにメモとして載せておく。
Query#setQueryの引数に、半角スペース区切りで条件を記述していくのが基本となる。

No用途クエリ記述内容説明

1 「おはっち」を含むツイートを検索 query.setQuery("おはっち"); 単純なキーワード検索をするだけなら、ひっかけたい文字列をただ普通にsetQueryに渡せば、それで良いらしい。
ただこれは、発信元やリプ先ユーザ等を何も指定してないので、ひなっちだろうがそうじゃなかろうが全部ひっかかってくる。
→つまり、ひなっち自身の「おはっち」ツイートや、それに対する「おはっち」リプ全部をひっかけることになる。
加えて、例えば以下のような投稿文もひっかかってきたので、"おはっち"という文言での完全部分一致検索をしている、というわけでもないらしい。
(このへんはTwitterのAPI任せなところがあるので、具体的な挙動はわからない)
やつはゴマ団子!…はっ、ち、違うぞぅ!?これは、ボクが用意しておいたものだからね!!  
いずれにせよこの条件では緩すぎて、目的としているところ以外が大量にヒットするため、もっと詳細な絞り込みが必要になる。
2 ひなっち本人から発信された「おはっち」のツイートを検索 query.setQuery("from:@Hinatch おはっち"); from:@[screen name]を加えることで、発信元(from)を限定できる。
@付でうすーく表示されるのを「スクリーンネーム」というらしいが、ひなっちの場合、これは「@Hinatch」なので、
これを指定すると「ひなっちからのおはっちツイート」を条件とした検索が可能となる。
のだが、
@aimomo5831 4番おはっちリプでしたぁ!!!  
というような、いわゆる「ひなっちへのおはっちのリプっちに対するひなっちからの順番回答っち」も対象となる為、
「実現したい機能性」の1.とは厳密に異なる。
ここで「リプを除く」というのを実現したくなる。
3 ひなっち本人から発信された「おはっち」のツイートのうち、リプを除いて検索 query.setQuery("from:@Hinatch おはっち exclude:replies"); exclude:[category]を加えることで、指定したcategoryのツイートを除外できる。(categoryというのは俺個人が便宜上つけた引数名なので公式ではない)
「exclude:replies」で、リプライを除外できる。
これで大体、毎回検索結果が10件前後になり、直近約1週間分の、リプを除いたひなっちのおはっちが検索できる。
「実現したい機能性」1.の目当てのものは大体これで賄えたといえる。
※余談だが、categoryとしてリプライを指定するやり方は、上記の公式サイトには載っていなかったので、
個人的にいろいろ試行錯誤して見つけた実現方法である。
なので、そもそも「リプ限定」をするにあたって、この記述が正しいかというと、必ずしもそうとは言えない可能性が高い。
4 ひなっちに対して行われた「おはっち」リプだけを検索 query.setQuery("to:@Hinatch おはっち filter:replies"); 今度は逆にひなっちに向けて発信された「おはっち」のリプだけを抽出したい。
これは「実現したい機能性」の2.にあたる。
to:@[screen name]は、from:~と違って、逆に発信先を限定する。
ひなっちを発信先として限定したい場合は「to:@Hinatch」とする。
また、filter:@[category]で、excludeとは逆に指定したカテゴリで絞り込み(フィルタリング)を行うことができる。
リプライに限定する場合はfilter:repliesでよい。

これで目当てとしているところは大体検索できるのだが、
ひなっちのおはっちには毎日大量のおはっちリプが付くため、
検索結果上限100件では、2日分程度のおはっちリプだけで検索結果が埋まってしまう。
このため前日より前のおはっちリプが見たい場合、別の指定が必要になる。
5 ひなっちに対して行われた「おはっち」リプのうち、特定の日付のものを検索 query.setQuery("to:@Hinatch おはっち filter:replies until:2017-07-16 since:2017-07-15"); until:yyyy-mm-ddで、指定した日までのツイート、というような検索条件が指定できる。
since:yyyy-mm-ddで、指定した日からのツイート、というような検索条件が指定できる。
いろいろ試したかんじ、untilは指定日を「含まず」、sinceは指定日を「含む」ように条件を適用させている。
(多分、時刻部分を勝手に「00:00:00」にされてるのだと予想するのだが…)
このためuntilとsinceに同じ日付を指定すると検索結果は0件になる(なぜかQueryResult#getMaxIdは取れるのだが)。
出来れば時間単位で細かく指定したかった(※)が、公式見た感じyyyy-MM-dd形式で日付までなので、これを使う。
※「since_id」というパラメータにDateのlong値を指定するとイケそうだが、
何度か試したかんじsince_idを使うと毎回検索結果0件になったので、使わないことにした

この「until」「since」に渡す日付文字列を、日ごとに1日ずつ遡って検索していけば、
「その日のおはっちリプ」というのを1日ずつ抽出することが可能となるはずだ。

なお、公式サイトによれば、「クエリに渡す文字列は必ずURLエンコードしろ」みたいなことが書かれているが、
↑の実装の通りで、URLエンコードしないで渡してもちゃんとそれっぽく動作する。
これはTwitter4jがやってくれているのだろうか?




プログラムの考え方としては、ざっくり以下の通りとなる。

(1)「ひなっち本人から発信された"おはっち"という文言を含むツイート」(※本人のリプ除く)を検索する(実現したい機能性1)  
 ┗取得した検索結果がなくなるまで以下を繰り返す。  
  (2)(1)の結果を1件取得し、ひなっちのおはっちの「ツイート日付」、ツイートIDを取得する  
  (3)(2)をもとに、untilとsinceを設定する(untilは翌日、sinceは同日)  
  (4)「ひなっち本人に向けて発信された"おはっち"というツイート(リプ)」を、(3)を動的に適用しつつ実行す(実現したい機能性2)  
   ┗取得した検索結果がなくなるまで以下を繰り返す。  
    (5)(4)の結果を1件取得し、ツイートのID、リプライ元ID(inReplyStatusID)を取得する  
    (6)(5)のリプライ元ID=(2)のツイートID かつ ツイートIDが「(4)の結果の中で最小のツイートID」かどうかを検査する。(最小のツイートID=1番おはっちリプ、と見做す)  
    (7)「ひなっちのおはっち」と「1番おはっちリプ」に関する情報を内部的にVOに編集・保持し、ObjectOutputStreamでファイル出力する(ファイル名はひなっちのおはっちのツイートID)  
(8)過去出力分を含め、(7)で出力した(今回出力分含む)全オブジェクトを読み込み、集計する  



ポイントしては。。。

  • 結果をどっかに出力しておこうとはもともと思っていたので、本当はCSVとかTSVとか、扱いやすいテキストファイルにしたかったのだが、
    ツイートには得てして改行が入り込むもので、フラットなテキストファイルにするには(後々読み込んで集計することを考えると)ちとやりづらいところがあった。
    面倒くせえからSerializable継承してオブジェクトのまま出力しちまえと思い、(6)ではObjectOutputStreamを用いた。
    (DBにブチ込めればもっと楽なのだが…)
  • 調べてみると、時分秒まで一致している「おはっちリプ」がある。(2017/7/19には実際それが見られた)
    当たり前だが、特に1番おはっちリプ付近で発生しがちである。
    ⇒見つけたおはっちに対して、みんながみんな我先にと即効でリプりに行くのでアクセスが集中するためだ。
    最初は「検索したリプの中で一番若い時間=1番おはっちリプ」と判定すればいいと、短絡的に考えていたが、
    このようなケースではどれを「1番おはっちリプ」とするかわからない(というより不正判定になる可能性がある)
    ただ、このようなケースでも、それぞれの「ツイートID」が異なっているので、
    (6)では「一番若い(最小の)ツイートID=1番おはっちリプ」と見做すようにした。
    見ている限りでは問題なさそうだが、ツイートIDの採番=シーケンシャルで昇順というルールを暗黙の前提にしているため、
    このルールが崩れると(あるいはそもそもそんなルールがないとか)この判定ロジックは破たんする。
    (2017年7月現在で887826634841006080くらいまできており、long型の最大値9223372036854775807に近づいてきているので、なんとなくサイクリックに採番してそうな気もするが)

    余談だが、Status#getCreatedAt()で取得できるツイートの投稿日時は、Date型なのにミリ秒部分が全部0になっている。
    これがTwitter4jの仕様なのかTwitter APIの仕様なのかわからないが、Twitterでは投稿日時をミリ秒まで保持していないように見える。
    ミリ秒まで保持しているからといって↑の問題が解決するわけではないが(劇的に少なくなりそうではあるが)、
    なんとなく気になったので記録として残す。
  • 「ひなっちは1日に必ず1回だけおはっちツイートする」という風になんとなく思っていたが、
    調べてみるとどうもそうではないらしい。(そんなのひなっちの自由だから当たり前っちゃ当たり前なんだが。。。)
    (3)の日付編集と、それをもとにした(4)のクエリ実行は、上記の想定から「日別」に行われることを考えていたため、
    ひなっちが1日に2回以上「おはっち」すると、(4)で実行するのと同じクエリが複数回流れるので、
    複数の「おはっち」に対するおはっちリプをまとめて検索することになり、結果的に「1番おはっちリプ」の判定が正しく出来ない可能性がある。
    このため、(6)の判定では、リプのツイートに持つ「リプライ元のツイートID」(getInReplyStatusId)が、
    検査対象となっているひなっちのおはっちツイートのIDと一致しているか?
    つまり、「確実にそのおはっちに対するリプか?」というのを改めてチェックするようにしている。

    ただ、これは、「1日複数回投稿されたおはっち」に対し、そのそれぞれの「おはっちツイート」に対する「1番おはっちリプ」を特定するやり方なので、
    それが「1番おはっちリプ」という考え方と厳密な意味でマッチしているかどうかは議論の余地があるところだろう。
    ⇒例えば、
    8:01に「おはっち」(ID:1)
    8:02にもう一度「おはっち」(ID:2)
    8:03にID:2に「おはっちリプ」(ID:3)
    8:04にID:1に「おはっちリプ」(ID:4)
    となった場合、
    ↑のロジックだと「ID:1の1番おはっちリプはID:4」「ID:2の1番おはっちリプはID:3」というように、
    ツイートごとの「1番おはっちリプ」を特定するよう動くわけだが、
    同一日付内という意味ではID:3が唯一無二の「1番おはっちリプ」であり、総合的にみてID:4は「2番おはっちリプ」というように見ることもできる、
    という意味である。
    そもそも”何をもって「1番おはっちリプ」と見做すのか”は最早ひなっち自身に委ねられており、論理的な定義は存在していない(という認識である)。
    単なる遊びに難しい話を持ち込んでも話が混乱するだけだから(そもそもこれも俺の”遊び”だ)、まあ今はこれで良しとしておこうかなと思った。
  • たぶん俺が知らないだけでやり方があるのだろうが、「特定のツイートに対するリプ」というような切り口の探し方ができない。
    ↑に挙げたやり方も、「クエリ実行結果をJavaで判断する」というなんとも微妙な実装である(できればすべてクエリに委ねたかった)。
    そして、これが「実現したい機能性2.」の大きな弊害の1つになっている。
    というのも、「実現したい機能性2.」では、「日付」と「おはっち」というキーワード、「リプ」という条件だけを与えて検索しているため、
    ↑で挙げたように1日複数回ひなっちがおはっちした場合に、「どのおはっちへのリプなのか」が即座に(クエリ実行段階では)判断できないのである。
    加えて、「searchの検索結果上限は100件まで」という制限のため、1日50~60件単位で「おはっちリプ」のつく現状を考えると、
    ひなっちが1日に2回おはっちした時点で、それぞれの「おはっち」に対する1番おはっちリプの特定は極めて困難になる。
    (ある日の「おはっち」のうち、1回目で55件、2回目で50件の「おはっちリプ」がつくと、同日内での「おはっちリプ」は105件になるので、100件超えた先の5件分が検索できない)
    まあ「おはっちリプ」自体もツイートなので、それぞれにIDがついているわけだから、
    リプを辿るごとに手に入るIDをクエリのmax_idに指定して辿っていけば、100件以上先もいけるっちゃいけるんだが、
    APIの実行回数制限などもある中ではおよそ現実的な対応とは言えんだろう。
    これは割と心残りになっている部分である。ちょこちょこ追求していきたい。

といったところか。
細かいことを言うと、なぜか途中日のリプがまったく検索できない(結果が0件になる)とか、
API側が原因不明のよくわからん謎の動きをしている部分もあり、
実際に動かしてみた感じの感想では、理論的には問題なさそうでも、
決して精度の高い結果が得られるようなものではないというのが第一印象である。
残念なことだが、「個人の趣味」の範疇ではこの辺が限界かもしれない。
とりあえず、2017/7/21を基準に、直近の「おはっち」及び「おはっちリプ」をこのプログラムで検索した結果を以下に掲載する。

おはっちIDおはっちテキストおはっちタイムいいね!数おはっちリプ数1番おはっちリプタイムひなっちおはっちからの経過時間1番おはっちID1番おはっちユーザ名1番おはっちテキスト最終更新日時

884943632796598272 おはっちーーーっ!!!!!! 2017/07/12 10:12:58.000 140 43 2017/07/12 10:13:04.000 00:00:06.000 884943659317174273 akk @Hinatch おはっち! 2017/07/21 02:17:55.755
885308574913544192 おはっちーーーっ!!!!!!! 2017/07/13 10:23:07.000 144 34 2017/07/13 10:23:14.000 00:00:07.000 885308604600811520 akk @Hinatch おはっち! 2017/07/22 02:20:11.719
885696878016217088 ひるっちからのおはっちーーーっ!!!!! 2017/07/14 12:06:05.000 189 97 2017/07/14 12:06:12.000 00:00:07.000 885696905811853312 ほまれ @Hinatch おはっち! 2017/07/22 02:20:11.719
885995158520479748 おはっちーーーっ!!!!!٩( ᐛ )و 2017/07/15 07:51:21.000 155 51 2017/07/15 09:16:14.000 01:24:53.000 886016520890929152 RIE @Hinatch おはっちー٩(๑❛ʚ❛๑)۶ 2017/07/22 02:20:11.719
886352866633433088 サンモニおはっちーーーっ!!!!!!! 2017/07/16 07:32:45.000 162 5 2017/07/16 09:36:24.000 02:03:39.000 886383983499517952 コンポタ @Hinatch おはっち! 2017/07/22 02:20:11.719
886384414971658240 サンモニおはっちリプありがとう!!!!!

良い日曜日になりますようにっ!

ラブです❤️
2017/07/16 09:38:07.000 166 5 2017/07/16 09:52:09.000 00:14:02.000 886387947443937280 (あ・ω・や) @Hinatch おはっち!ひなっち!らぶっち! 2017/07/22 02:20:11.719
886760610721021952 海の日おはっちリプあーりがとぉ!!!!!

明日は北アルプス!٩( ᐛ )و
2017/07/17 10:32:59.000 144 0           2017/07/22 02:20:11.719
887001785168035840 おはっちーーーっ!!!!! 2017/07/18 02:31:19.000 186 0           2017/07/22 02:20:11.719
887470246084329472 おはっちーーーっ!!!!!!!! 2017/07/19 09:32:49.000 132 49 2017/07/19 09:32:57.000 00:00:08.000 887470278095249408 えす(´・ω・`) @Hinatch おはっち! 2017/07/22 02:20:11.719
887826634841006080 おはっちーーーっ!!!!!! 2017/07/20 09:08:59.000 143 75 2017/07/20 09:09:05.000 00:00:06.000 887826659843244033 みやい ゆうすけ @Hinatch おはっち 2017/07/22 02:20:11.719
888184373064876032 おはっちーーーっ!!!!!!! 2017/07/21 08:50:30.000 129 13 2017/07/21 09:03:07.000 00:12:37.000 888187546466791424 emihisa @Hinatch おはっちーー! 2017/07/22 02:20:11.719


「ひなっちおはっちからの経過時間」が10分を超えているものは、
何らかの理由で検索結果がすべて取得できていないために、「検索結果の中で一番若いIDのツイート」に相当するというだけで
プログラム都合上「1番おはっちリプ」にされてしまったデータであるといえる。
つまり、1番おはっちリプではない。
これは「おはっちリプ数」の数値の精緻さも同様である(信用してはならない)。
実際ひなっちのツイッターみて結果を照らし合わせてみると、それが明らかである。

「1番おはっちリプタイム」~「1番おはっちテキスト」までが空白になっているのは、
なんだかわからないがsearchの実行結果が0件になった日である(↑で言っていた内容)。
前後の日がちゃんと検索できているのに、特定の日だけが0件になるのは謎でしょうがない。
まあ前後が取れてるならいいか、ということで、深く考えないようにしてそっとしておく。

経過時間が6秒前後のリプは「1番おはっちリプ」として実態と合致しており、正しく判定ができているといえる。
むしろあまりにも6秒前後のリプが多くて疑いたくなるが(ツイッターのリプ反映が最少でも6秒おいてから行われるとかいう仕様があったりするんじゃねえのかみたいな疑り)、
この短期間の結果だけを見ると
ひなっちのおはっちに対する1番おはっちリプは、ひなっちがおはっちしてから大体6~7秒で行われる
というのが見て取れる。
逆に言えば、ひなっちのおはっちを見つけたら、6秒以内におはっちリプしないと1番が取れないということである。
ひなっちがおはっちするまでずーっと監視していればもっと早くおはっちリプするのも可能だろうが、そんなの実質不可能なので、
大抵の1番おはっちリプは、そのタイミングでツイッター見てた人が運よく見つけてリプした偶然の賜物なんだろう。

なお、今回実装したプログラムを少し改良すれば、
”無限ループしながら常に当日の「ひなっちのおはっち」を探し続け、  結果が1件以上あったら=ひなっちがおはっちしたら、
 「おはっち」という文言でひなっちのおはっちに対してリプする”

というプログラムもおそらく実装可能だ。
APIの実行回数制限があるので間を空けずにsearchし続ける(監視を行う)ことは不可能だが、
5秒に1回なら回数制限にはひっかからないので、(15分で180回実行可能だから)
理論上最速5秒で1番おはっちリプができる。
直近の傾向を見るに6秒が最速のおはっちリプだから、
おそらく何度か試せば比較的高確率で1番おはっちリプを取り続けることが可能となるだろう。
(やらねーけど)




Twitter4jを使った基本的な実装の利用でこの辺のことが実現できるのはわかったが、
上述したように諸々の理由や実装背景等から決して精度の高い結果ではない。
もう少し詳細な実装ポイント等を抑えて再挑戦したいところである。