Twitter gemの検索結果インスタンスとその内部を解説(ついでに since_id モンキーパッチも)

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" を指定するとリンクを含むツイートのみを検索する。

参考:Twitter 検索API メモ - 超自己満足プログラミング

検索例

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 でプルリクエスト提出済み)。