Twitter gem (バージョンは5.14.0)を使って検索して得られるインスタンス(以下、検索結果インスタンスと呼びます)の扱いが意外と難しいのに詳細解説している記事が見当たらないので書いてみました。
TL;DR(要点)
result_tweets = client.search(query, count: 100, result_type: "recent", exclude: "retweets", since_id: since_id)
で得られる result_tweets を取り扱うには
- take メソッドを使って取得ツイート数を決めてしまう
- Twitter gem モンキーパッチを当てて :since_id 指定を確実に有効にする
module Twitter class SearchResults def next_page return nil unless next_page? hash = query_string_to_hash(@attrs[:search_metadata][:next_results]) since_id = @attrs[:search_metadata][:since_id] hash[:since_id] = since_id unless since_id.zero? hash end end end
ようにして、 API 規制が掛からないように注意する。
また Ruby - Twitter gemでツイート検索する場合の要点、及び:since_id指定を有効にするモンキーパッチ - Qiita にも要約を載せました。
アクセスクライアントインスタンス作成
通常の認証
require 'twitter' client = Twitter::REST::Client.new( consumer_key: 'YOUR_CONSUMER_KEY', consumer_secret: 'YOUR_CONSUMER_SECRET', access_token: 'YOUR_ACCESS_TOKEN', access_token_secret: 'YOUR_ACCESS_SECRET', )
もちろん File: README — Documentation for twitter (5.14.0) にある
client = Twitter::REST::Client.new do |config| config.consumer_key = "YOUR_CONSUMER_KEY" config.consumer_secret = "YOUR_CONSUMER_SECRET" config.access_token = "YOUR_ACCESS_TOKEN" config.access_token_secret = "YOUR_ACCESS_SECRET" end
の形式でも良いのですが、ハッシュを渡す方が好きです。
Application-only auth
require 'twitter' client = Twitter::REST::Client.new( consumer_key: 'YOUR_CONSUMER_KEY', consumer_secret: 'YOUR_CONSUMER_SECRET', )
検索:Twitter::REST::Search#search(query, options = {})
返値である検索結果インスタンスは Twitter::SearchResults
。(Enumerable
を include している) Twitter::Enumerable
を include しているので each メソッドなどイテレータが使える(ただし検索結果が多い場合には後記する注意事項あり)。
API上限
通常の認証:180回/15分(平均:5秒に1回まで)
Application-only auth:450回/15分(平均:2秒に1回まで)
optionsパラメータ:
標準
:geocode
: 地点。"緯度,経度,距離"のフォーマットで指定。距離は mi または km 。例:"35.681382,139.766084,20km"(東京駅を中心とした半径20km以内。スペースを空けるとダメ):lang
: 言語。 ISO 639-1コード で指定。:locale
: 地域。有効なのは"ja"
のみ。:result_type
: 内容指定。"recent"
: 最近のツイート。"popular"
: ポピュラーなツイート。"mixed"
: popular と mix を混ぜたツイート。デフォルトは"mixed"
。:count
: 取得数。デフォルトは15。最大は100。:until
: 指定日まで検索。"YYYY-MM-DD"
のフォーマットで指定。ただし API では1週間までしか取得できず、 :until に1週間以前を指定してもやはり取得出来ない。:since_id
: 指定 ID 以降から取得(最も古いツイート ID)。0または nil を指定すれば指定してない状況と同じ。 残念なことに、検索結果が100件以上ある場合には無効(検索結果が100件以上でも有効にするモンキーパッチを後記します)。:max_id
: 指定 ID 以前を取得(最も新しいツイート ID)。
query内での設定だがパラメータ指定も可能なもの
:from
: 指定アカウントからのツイートを検索。アカウント名(screen_name)を指定する。:to
: 指定アカウントへのツイート(ただしツイート冒頭にある場合のみ)を検索。アカウント名(screen_name)を指定する。:exclude
:"retweets"
を指定すると検索結果から公式 RT を排除する。:filter
:"links"
を指定するとリンクを含むツイートのみを検索する。
検索例
query = "#ブラバンツなんたら" # 4/15の夜に私だけが投稿したハッシュタグ。121ツイートあります。 since_id = nil result_tweets = client.search(query, count: 100, result_type: "recent", exclude: "retweets", since_id: since_id) #=> #<Twitter::SearchResults:0x007ffb10d17cd0 @attrs= {:statuses=> [{:metadata=>{:iso_language_code=>"ja", :result_type=>"recent"}, :created_at=>"Wed Apr 15 15:39:04 +0000 2015", :id=>588365933179117568, :id_str=>"588365933179117568", :text=>"今気付いた、3位に汁だったんだw #ブラバンツなんたら", (略)
検索結果インスタンスの取り扱い
検索結果が少ない場合(1回で全ての結果が取得できる場合)
result_tweets そのまま、もしくは result_tweets.to_a を対象にしてイテレータを使う。
検索結果の全てが1回で取得出来ていない場合(検索結果が100件以上)
result_tweets.take(100) などで取得数を指定してイテレータを使う。take(1000) などと :count 指定数以上の数を指定した場合にも、指定数量を上限としたツイート数を取得してくれる。 take メソッドの代わりに、引数付き first メソッドでも OK 。
例
検索API残量のメソッドを先ず設定します。
def rate_limit_status_search_tweets(client) client.__send__(:perform_get, '/1.1/application/rate_limit_status.json')[:resources][:search][:"/search/tweets"] end
検索を実行します。
result_tweets = client.search(query, count: 100, result_type: "recent", exclude: "retweets", since_id: since_id); rate_limit_status_search_tweets(client)[:remaining] => 179
takeメソッドで取得範囲を指定してからイテレータを使う場合。
result_tweets.take(100).each_with_index { |tw, i| puts "#{i}: @#{tw.user.screen_name}: #{tw.full_text}" }; #=> 0: @riocampos: 今気付いた、3位に汁だったんだw #ブラバンツなんたら 1: @riocampos: パテルスキーもトップ10に入ったのか #ブラバンツなんたら : 98: @riocampos: ルームポットのホーニヒ #ブラバンツなんたら 99: @riocampos: あたっこ。ルームポット? #ブラバンツなんたら rate_limit_status_search_tweets(client)[:remaining] #=> 179 # APIは減っていない。検索結果はインスタンスの中に保持されているため。
取得範囲を指定せず検索結果インスタンスに対して直接イテレータを使う場合。
result_tweets.each_with_index { |tw, i| puts "#{i}: @#{tw.user.screen_name}: #{tw.full_text}" }; #=> 0: @riocampos: 今気付いた、3位に汁だったんだw #ブラバンツなんたら 1: @riocampos: パテルスキーもトップ10に入ったのか #ブラバンツなんたら : 119: @riocampos: sporzaで #ブラバンツなんたら きた\o/ 120: @riocampos: #ブラバンツなんたら かなぁ rate_limit_status_search_tweets(client)[:remaining] #=> 178 # API は1つ消費された。100件目以降の検索結果が API を使ってアクセスされたため。 result_tweets.each_with_index { |tw, i| puts "#{i}: @#{tw.user.screen_name}: #{tw.full_text}" }; #=> 0: @riocampos: 今気付いた、3位に汁だったんだw #ブラバンツなんたら 1: @riocampos: パテルスキーもトップ10に入ったのか #ブラバンツなんたら : 119: @riocampos: sporzaで #ブラバンツなんたら きた\o/ 120: @riocampos: #ブラバンツなんたら かなぁ rate_limit_status_search_tweets(client)[:remaining] #=> 178 # APIは減っていない。検索結果はインスタンスの中に保持されているため。 result_tweets.to_a #=> [#<Twitter::Tweet id=588365933179117568>, #<Twitter::Tweet id=588363721400942592>, : #<Twitter::Tweet id=588328770198773760>, #<Twitter::Tweet id=588311052384739331>] rate_limit_status_search_tweets(client)[:remaining] #=> 178 # APIは減っていない。検索結果はインスタンスの中に保持(ry
検索結果が多い場合に result_tweets.to_a を使うと
取得出来る(1週間以内のツイート)全ての検索結果を取得しようとします。そのため、検索結果が多い(:countを100に設定していた場合、通常で最大18000件以上、Application-only authで最大45000件以上)場合には、 API を使い切るまでアクセスし、RateLimited エラーで終了してしまいます。しかも規制が掛からないようにと考えて :since_id を指定していても、その since_id が検索2回目以降に含まれる範囲にある場合には :since_id 指定は有効になりません(要注意ポイント)。これは Twitter gem のバグだと思いますどうやら Twitter API 側のバグらしいですorz
対策としては
- take メソッドを使って取得数を決めてしまう
- 後記するモンキーパッチを当てて :since_id 指定を確実に有効にする
といった方法を用いましょう。
検索結果インスタンスの中身をさらに探る
もう一度検索し直して、インスタンスの中身を元に戻します。
result_tweets = client.search(query, count: 100, result_type: "recent", exclude: "retweets", since_id: since_id); result_tweets.instance_variables #=> [:@client, :@request_method, :@path, :@options, :@collection, :@attrs] result_tweets.attrs.keys #API の返値は @attrs に入る #=> [:statuses, :search_metadata] result_tweets.attrs[:search_metadata] #=> {:completed_in=>0.099, :max_id=>588365933179117568, :max_id_str=>"588365933179117568", # :next_results には、検索キーワード、max_id、exclude: "retweets"、count、result_type が含まれているが since_id は含まれない :next_results=>"?max_id=588343952513740799&q=%23%E3%83%96%E3%83%A9%E3%83%90%E3%83%B3%E3%83%84%E3%81%AA%E3%82%93%E3%81%9F%E3%82%89%20exclude%3Aretweets&count=100&include_entities=1&result_type=recent", :query=>"%23%E3%83%96%E3%83%A9%E3%83%90%E3%83%B3%E3%83%84%E3%81%AA%E3%82%93%E3%81%9F%E3%82%89+exclude%3Aretweets", :refresh_url=>"?since_id=588365933179117568&q=%23%E3%83%96%E3%83%A9%E3%83%90%E3%83%B3%E3%83%84%E3%81%AA%E3%82%93%E3%81%9F%E3%82%89%20exclude%3Aretweets&result_type=recent&include_entities=1", :count=>100, :since_id=>0, :since_id_str=>"0"} result_tweets.attrs[:statuses].size #=> 100 # API から取得した検索結果の数量は :count で指定した100 result_tweets.instance_variable_get(:@collection).size #=> 100 # @collection にも @attrs[:statuses] と同じものが入っている result_tweets.attrs[:statuses].first[:id] == result_tweets.attrs[:search_metadata][:max_id] #=> true # 検索結果に含まれる最初の(最も新しい) ID と検索結果のメタデータに含まれる最大 ID とは同じ result_tweets.attrs[:statuses].last[:id] -1 == result_tweets.attrs[:search_metadata][:next_results][/(?<=^\?max_id=)\d+/].to_i #=> true # 検索結果に含まれる最後の(最も古い) ID は、検索結果のメタデータに含まれる「次の検索結果を返すクエリ文字列の最大 ID」よりも1小さい、つまり今回取得出来た検索結果よりも古い検索結果を次回は取得することになる
イテレータを使う場合の注意点
一部繰り返しになりますが、ここが要点なのでお許しください。
検索結果のインスタンスにイテレータのメソッドを使うと、まず「全ての検索結果」を取得しようとします。もう少し詳しく書くと、検索結果のインスタンスには API から1回で取得した count 数しか無いのですが、「全ての検索結果」は API で取得可能な(1週間以内の全ての)検索結果を取得しようとします。
さらにつっこんで書くと、イテレータとしての検索結果はインスタンス変数 @collection に基づいて振る舞います。なので「全ての検索結果」を取得すると @collection は更新されます。
もう一度検索し直して、インスタンスの中身を元に戻します。
result_tweets = client.search(query, count: 100, result_type: "recent", exclude: "retweets", since_id: since_id); result_tweets.instance_variable_get(:@collection).size #=> 100
ここで(イテレータメソッドであれば何でも良いのですが) to_a メソッドを使って全ての検索結果を取得します。
result_tweets_all = result_tweets.to_a; result_tweets_all.size #=> 121 result_tweets.instance_variable_get(:@collection).size #=> 121
一方、 result_tweets.attrs (つまり @attrs)には「全ての検索結果」の最後のものだけが保持されます。
result_tweets.attrs[:search_metadata] #=> {:completed_in=>0.03, :max_id=>588343952513740799, :max_id_str=>"588343952513740799", :query=>"%23%E3%83%96%E3%83%A9%E3%83%90%E3%83%B3%E3%83%84%E3%81%AA%E3%82%93%E3%81%9F%E3%82%89+exclude%3Aretweets", :refresh_url=>"?since_id=588343952513740799&q=%23%E3%83%96%E3%83%A9%E3%83%90%E3%83%B3%E3%83%84%E3%81%AA%E3%82%93%E3%81%9F%E3%82%89%20exclude%3Aretweets&result_type=recent&include_entities=1", :count=>100, :since_id=>0, :since_id_str=>"0"} result_tweets.attrs[:statuses].size #=> 21
さらにつっこんで、 Twitter::Enumerable#each の実装を見てみます。
module Twitter module Enumerable include ::Enumerable def each(start = 0) return to_enum(:each, start) unless block_given? Array(@collection[start..-1]).each do |element| yield(element) end unless last? start = [@collection.size, start].max fetch_next_page each(start, &Proc.new) end self end end end
前半部分ではインスタンス変数 @collection の全てでイテレートします。
後半部分では「全ての検索結果」のうち取得していないものを API から取得します。
さて、インスタンス変数 @collection の中身をちょっと確認します。
result_tweets.instance_variable_get(:@collection).first.class #=> Twitter::Tweet result_tweets.instance_variable_get(:@collection).first.instance_variables #=> [:@attrs, :@_memoized_method_cache]
こちらの @attrs は上記の Twitter::SearchResults クラスの @attrs とは違って、 Twitter::Tweet クラスのインスタンス変数です(ややこしいですが)。
pry の ls を使って、検索結果インスタンスのなかにある各ツイートインスタンス( Twitter::Tweet )の中身をもう少し確認します。
ls result_tweets.instance_variable_get(:@collection).first #=> Memoizable::InstanceMethods#methods: freeze memoize Twitter::Base#methods: [] attrs to_h to_hash to_hsh #<Equalizer:0x007ffb10d44e88>#methods: hash inspect Equalizer::Methods#methods: == eql? Twitter::Identity#methods: id id? Twitter::Creatable#methods: created? created_at Twitter::Entities#methods: entities? hashtags? media? symbols? uris? urls? user_mentions? hashtags media symbols uris urls user_mentions Twitter::Tweet#methods: favorite_count geo? metadata retweeted text? favorite_count? in_reply_to_screen_name metadata? retweeted? truncated favorited in_reply_to_screen_name? place retweeted_status truncated? favorited? in_reply_to_status_id place? retweeted_status? uri favoriters_count in_reply_to_status_id? possibly_sensitive retweeted_tweet url favorites_count in_reply_to_tweet_id possibly_sensitive? retweeted_tweet? user filter_level in_reply_to_user_id reply? retweeters_count user? filter_level? in_reply_to_user_id? retweet? source full_text lang retweet_count source? geo lang? retweet_count? text instance variables: @_memoized_method_cache @attrs
各ツイートインスタンスは、 API からの返値を @attrs に保持し、またそれをメモ化する @_memoized_method_cache インスタンス変数も有しています。
result_tweets.instance_variable_get(:@collection).first.instance_variable_get(:@_memoized_method_cache) #=> #<Memoizable::Memory:0x007ffb0ed1a810 @memory= #<ThreadSafe::Cache:0x007ffb0ed1a748 @backend={:id=>588365933179117568}, @default_proc=nil>, @monitor= #<Monitor:0x007ffb0ed1a6a8 @mon_count=0, @mon_mutex=#<Mutex:0x007ffb0ed1a658>, @mon_owner=nil>>
Twitter gem でイテレータを使う場合の最大の問題点及び解決法
上記したように、残念ながら現在の Twitter gem では、 since_id 指定は1回で取得する場合にしか有効ではありません。よって、検索結果が多い場合には、 API を使い切るまでアクセスし、RateLimited エラーで終了してしまいます。
twitter gem バージョン5.14.0の Twitter::SearchResults クラスのソースを見てみます(コメントは省きました)。
module Twitter class SearchResults private def last? !next_page? end def next_page? !!@attrs[:search_metadata][:next_results] unless @attrs[:search_metadata].nil? end def next_page query_string_to_hash(@attrs[:search_metadata][:next_results]) if next_page? end def fetch_next_page response = Twitter::REST::Request.new(@client, @request_method, @path, next_page).perform self.attrs = response end def attrs=(attrs) @attrs = attrs @attrs.fetch(:statuses, []).collect do |tweet| @collection << Tweet.new(tweet) end @attrs end def query_string_to_hash(query_string) query = CGI.parse(URI.parse(query_string).query) Hash[query.collect { |key, value| [key.to_sym, value.first] }] end end end
先ほど見た Twitter::Enumerable#each メソッドでは、後半で fetch_next_page というメソッドを使って API へ新たにアクセスしていました。Twitter からの返値に search_metadata には :since_id が含まれていますが、次のツイートを取得するときには Twitter::SearchResults#fetch_next_page のソースを確認すれば分かるようにメタデータのうち next_results しか使っていないため、 :since_id の情報が使われていません。これが2回以上にわたる検索結果の取得時に :since_id 情報が使われない原因です。
なので Twitter::SearchResults#next_page をモンキーパッチして
module Twitter class SearchResults def next_page return nil unless next_page? hash = query_string_to_hash(@attrs[:search_metadata][:next_results]) since_id = @attrs[:search_metadata][:since_id] hash[:since_id] = since_id unless since_id.zero? hash end end end
としてやると、API へのアクセスが2回以上にわたる場合にも :since_id が有効に使われます(Enable since_id option at paging SearchResult by riocampos · Pull Request #682 · sferik/twitter でプルリクエスト提出済み)。