Hatena::ブログ(Diary)

しばそんノート

2010-02-07

Twitterにおけるニコニコ動画の人気度をランキングするサービスを作ってみた

まだα版ですが、とりあえず見られるレベルにはなったので公開してみます。

Twitterの公開ツイートの中から、ニコニコ動画のURLを含むものを探し出し、ツイート数が多い順に動画をランキングするサービスです。

ニコニコ動画本家のランキングとはだいぶ違う結果になるようなので、見る人が見ればそれなりに楽しいのではないかと…多分。

ツイート数自体がさほど多くありませんが、その分ランキングの入れ替わりは頻繁に起きるようです。

機能面、デザイン面ともにまだまだ作りたてといったところですが、とりあえず仕組みの簡単な説明を書いておこうと思います。

技術的にたいした話はありませんので、日記的な感覚で捉えていただければ幸いです。

開発言語など

Rubyです。データベース操作にSequel、テンプレートエンジンにHaml、memcached操作にmemcache-clientのgemを使用しています。後は細々したものを色々。

ツイートの収集方法

TwitterのStreaming APIを使っています。

結構前にStreaming APIの使い方について記事を書いたのですが、あれから正式版としてリリースされるにあたり、細部がいくらか変更されています。正式版Streaming APIの使い方については、また機会があれば記事にまとめてみたいと思っています。

今回は”statuses/filter”を使い、trackキーワードとして"nico"を指定しています。これはニコニコ動画自体のドメイン="www.nicovideo.jp"と、ニコニコ動画専用のURL短縮サービス”nico.ms”の両方を引っ掛けたかったためです。

[2010年2月10日修正] trackキーワードによる検索は(単語単位での)完全一致で行われますので、上の指定だと"nico.ms"のURLしか引っ掛かりません。"www.nicovideo.jp"も引っ掛けるためには、"nicovideo,nico"というように指定する必要があります。

当然関係ないツイートもかなり紛れ込んでいますので、こんな感じの正規表現でさらにフィルタリングしています。

PATTERN = %r{https?://.*(?:nicovideo\.jp|nico\.ms).*/sm(\d+)}

これにマッチすれば、$1に動画IDが入りますので、それを後々色々と使いまわします。

とってきたツイートは必要な情報だけ取り出して、とりあえずMySQLにぶち込んでいます。ランキング生成などを全部SQLクエリで行えますので、この辺はやはりリレーショナルデータベースが適しています。

ランキングの生成方法

上述の通りSQLクエリで実現しています。Sequelで書くとこんな感じ。テーブル名、カラム名とかの説明は面倒なので省かせてください。

@statuses.filter { |o| o.created_at > Time.now - @target_period }
         .group_and_count(:video_id.as(:video_id))
         .order(:count.desc, :video_id.desc)
         .limit(@max_video_count)

"group_and_count"メソッドが便利ですね。個人的に結構わかりやすく書けるのでお気に入りです、Sequel。

各動画につける最近のツイートは次のような感じで取り出しています。

@statuses.graph(:users, :id => :user_id)
         .filter { |o| o.created_at > Time.now - @target_period }
         .where(:video_id => video_id)
         .order(:created_at.desc)
         .limit(@max_tweet_count)

JOINする双方のテーブルに同名のカラムがある場合、"join"メソッドではなく"graph"メソッドを使うと、結果がテーブル毎のハッシュとして返ってきますので、カラム名の重複を回避することができます。

動画情報の取得

ランキングとして表示するからには、動画の名前やサムネイルなど、色々な情報が必要になってきます。

この辺はニコニコ動画APIとしてきちんと用意されていますので、これを使わせてもらいます。

とりあえず"getthumbinfo" APIを使っておけば、通常必要となる情報は全部取得できます。

今の実装では、トップページの「最新のツイート」を一分ごとに更新していますので、そのままだとかなり頻繁にAPIへアクセスすることになってしまいます。

そこで、取ってきた情報はmemcachedにも保存しておき、memcachedに情報がある場合はAPIへはアクセスしないようにしています。

こういう「とりあえず入れておくだけ」なデータにはリレーショナルデータベースは大げさになってしまいますので、この辺のkey/value式のデータストアが重宝します。

ちなみに、動画情報にはある程度の新鮮さを持たせておきたいので、memcachedに入れたデータも30分経ったら捨てるようにしています。

モジュールの可動場所

サービスのURLはさくらのレンタルサーバですが、ここには静的なHTMLと各種リソースしか置いてありません。上で説明したようなスクリプトは全て自宅で動かしています。

さくらのレンタルサーバではStreaming APIで使うような長寿命のプロセスは許可されていませんし、かといって自宅で固定IPをとってサービスを公開するのもちょっと億劫です。…ということで、こんな構成になりました。

自宅サーバではStreaming APIによるトラッキングの他に、cronで定期的にランキングHTML生成プロセスを動かしており、そいつがさくらサーバ上にアップロードするようになっています。

ちょっと面倒な構成ではありますが、さくらサーバ側の負荷も減りますし、まぁそんなに悪くもないのかな…なんて思っています。

そんなわけで、自宅サーバの能力はフルに使えますので、本当はやろうと思えば1分毎に毎時ランキングを更新する、といったこともできるのですが、それは果たして“毎時ランキング”なのか?という思いもあり、今のような1時間毎の更新としています。

この辺、他の方のご意見も伺いたいところです。

テンプレート

前述の通りテンプレートエンジンはHamlを使っていますが、フォーマットはHTML5で書いています。

といっても新しいAPIとかと使っているわけではなく、タグがHTML5的なだけですが。そのおかげでIEではJavaScriptを無効にしているとレイアウトがガタガタになります。

しかもW3Cのバリデータに通すとエラーになります。どうもHTML5ではmetaタグのhttp-equiv属性に"pragma"や"cache-control"などが使えない*1らしいのですが、さくらサーバでは.htaccessでのキャッシュコントロールはできませんし、仕方なくエラーとなることを承知でそのままにしてあります。

あとはCSS3のborder-radiusとかgradientとかもちょっと使ってみたり。Firefox, Safari, Chromeあたりでしか反映されませんが、その辺のブラウザで見るとちょこっと見た目が変わります。元々のデザインが味気ないので、たいした違いはありませんが。

今後について

Twitterで軽く確認してみましたが、どうやら今の時点では被っているサービスはないようなので*2、もう少し発展させてみようかと思っています。

せっかくStreaming APIを使ってリアルタイムにツイートが取れているのですから、それを活かした機能が欲しいところです。

ニコ動風のUIで、動画のサムネをバックに、取ってきたツイートをリアルタイムで*3流す…みたいなものも考えたのですが、そんなのあまり見ない気がして。どうでしょうね。どちらかというとニコ生風ってことになるのでしょうか。

bit.lyなど、他のURL短縮サービスにも対応できないかと考えましたが、いい案が浮かばないので実現できていません。良い策をご存じの方がいらっしゃいましたらご教示お願い致します。

あとはランキングの更新間隔とか、動画やツイートの表示数とか、細かい部分もあるのですが、なんといってもまずなんとかしたいのはデザインです。

自分デザインセンスは本当に無いもので…。もうちょっと見栄えの良いデザインにしたいなぁ。

そんな感じで、色々迷っているところですので、ご意見ご要望などありましたら是非@shibasonまでご連絡ください。よろしくお願い致します。

あ、あと、名前も募集しています!

*1:まだ定義されていない?

*2:それが一番心配でした。

*3:前述のようにちょっと変わった構成なので、「擬似リアルタイム」的なことしかできないかもしれませんが。

2009-10-18

Google App Engine/JRubyでTwitterの自動フォロー返し

先日Google App EngineのSDKが1.2.6にバージョンアップし、アプリケーションがメールを受信できるようになりました。

Twitterの自動フォロー返しを実現するには、おおまかに分けて

  1. フォロー通知のメールを受信したタイミングでプログラムを呼び出す
  2. 定期的にメールサーバにフォロー通知メールを確認しに行く
  3. 定期的にfollowingとfollowersの差分をチェックする

という3パターンがあると思います。

GAEのメール受信機能を使えば、この中の1番が手軽に実現できそうです。

ということで、ごく簡単な自動フォロー返しの仕組みをGAE/JRubyで作ってみました。

下準備

GAE/JRubyの環境を整えるところまでは以下の記事を参照して下さい。

gemでインストールできるApp Engine SDKは2009/10/18現在まだ1.2.5ですが、特に問題なく動くようです。開発サーバでのテストメール送信機能が使えないのは痛いですが…。

環境が整ったら、ディレクトリを作って必要なgemのインストールをしておきます。

$ mkdir sample-twitter-follow-back
$ cd sample-twitter-follow-back
$ appcfg.rb gem install sinatra appengine-apis

設定ファイルの作成

rackupの設定ファイルを書きます。

$ vim config.ru
require 'appengine-rack'

AppEngine::Rack.configure_app(
  :application => 'your-application-id',
  :version => 1,
  # 1. メール受信サービスを有効化する
  :inbound_services => [ :mail ]
)

# 2. 管理者アカウント以外からのアクセスを禁止する
use AppEngine::Rack::AdminRequired

require 'main'

run Sinatra::Application

ソース中のコメント部分を少し補足しておきます。

1. メール受信サービスを有効化する

AppEngine::Rack.configure_appメソッドは:applicationと:version以外にもいくつかオプション引数を取ります。*1

各種サービスに関しては、上のように:inbound_servicesオプションに使いたいサービス名を指定することでWEB-INF/appengine-web.xmlにその内容が反映され、サービスが使えるようになります。

2. 管理者アカウント以外からのアクセスを禁止する

今回はメール受信サービスから呼び出される以外の使い方を想定していませんので、全体に“管理者のみ許可”のアクセス制限をかけてしまいます。

他にもAppEngine::Rack::LoginRequiredやAppEngine::Rack::SSLRequiredなどのセキュリティオプションがあり、Rack::Builder#mapメソッドと組み合わせることで適用範囲を指定することもできます。

アプリケーションの作成

アプリケーション本体のソースを書きます。

$ vim main.rb
require 'sinatra'
require 'yaml'
require 'appengine-apis/logger'
require 'appengine-apis/urlfetch'

# 1. メールが届くと、メールアドレスを含んだURLが呼び出される
post '/_ah/mail/:account@:domain' do |account, domain|
  logger = AppEngine::Logger.new

  # 2. Twitterアカウント情報を読み込む
  accounts = YAML.load_file('accounts.yaml')
  unless accounts[account]
    # 3. 不明なアカウントの場合はログに記録しておく
    logger.warn("Received a mail for an unkonwn account.")
    logger.warn("Account Name: #{account}")
    logger.warn("Domain: #{domain}")
    halt 'unkonwn account'
  end
  username = accounts[account]['username']
  password = accounts[account]['password']

  # 4. POSTデータとして送られくるメールを読み込む
  mail_data = env['rack.input'].read

  # 5. メールを解析する
  props = java.util.Properties.new
  session = javax.mail.Session.get_default_instance(props)
  stream = java.io.ByteArrayInputStream.new(mail_data.to_java_bytes)
  message = javax.mail.internet.MimeMessage.new(session, stream)

  # 6. Twitter通知メールの固有ヘッダから必要な情報を得る
  mail_type = message.get_header('X-TwitterEmailType').to_a.first
  sender_id = message.get_header('X-TwitterSenderID').to_a.first

  if mail_type == 'is_following' && sender_id
    # 7. フォロー通知メールなのでフォロー返しをする
    url = "https://twitter.com/friendships/create/#{sender_id}.json"
    request = Net::HTTP::Post.new('/')
    request.basic_auth(username, password)
    options = {
      :method => request.method,
      :payload => request.body,
      :headers => { 'Authorization' => request['Authorization'] }
    }
    response = AppEngine::URLFetch.fetch(url, options)
    unless response.code.to_i == 200
      # 8. リクエスト失敗時はログに記録して500エラーを返す
      logger.error("Reguest failed of following back.")
      logger.error("Status: #{response.code} #{response.message}")
      logger.error("User ID: #{sender_id}")
      halt 500, 'request failed'
    end
  else
    # 9. それ以外のメールはログに記録しておく
    subject = message.subject
    from = message.from.to_a.join(', ')
    logger.warn("Received a mail which is not 'is_following'.")
    logger.warn("Subject: #{subject}")
    logger.warn("From: #{from}")
  end

  'ok'
end

先ほどと同じように、コメント部分について少し補足しておきます。

1. メールが届くと、メールアドレスを含んだURLが呼び出される

メール受信サービスのドキュメントを読むと、メールを受信する毎に"/_ah/mail/<address>"というURLに対してPOSTリクエストが発行されるようです。

URLに含まれるメールアドレスからアカウント名を取得しておきます。

2. Twitterアカウント情報を読み込む

Twitterのアカウント情報は別ファイルにまとめておくことにしました。こうすれば、複数のアカウントの自動フォローを1つのアプリケーションで担当することができます。

アカウント情報ファイルの詳細は後述します。

3. 不明なアカウントの場合はログに記録しておく

メールは誰でも送信できますので、想定していないアカウントに対してメールが送られてくることもあるかもしれません。どうもGAEのメール受信サービスはリクエストが失敗するとメールの再送をかけるようになっているようなので、ここはログに書き残しておくだけで正常なレスポンスを返しておきます。そうしないと延々と再送をかけられてしまいます。

4. POSTデータとして送られくるメールを読み込む

先ほどのドキュメントにもありましたが、メールソースはPOSTデータとして送られてきますので、一旦全てメモリ上に読み込んでしまいます。

5. メールを解析する

読み込んだメールソースをJavaのMimeMessageクラスを使って解析します。この辺は詳しい説明は省きます。こういう流れなのだと思って下さい。あるいはJavaのリファレンスを参照して下さい。

6. Twitter通知メールの固有ヘッダから必要な情報を得る

Twitterからのフォロー通知メールには、プログラムがメールを処理しやすいように、いくつか固有のヘッダが付加されています。

ここから情報を取得すれば、わざわざ面倒なメール本文の解析を行う必要はありません。

7. フォロー通知メールなのでフォロー返しをする

フォロー返しはAppEngine::URLFetch.fetchメソッドを使って行っています。Basic認証なのでHTTPSでリクエストします。

Net::HTTP::PostクラスはBasic認証に使うAuthorizationヘッダのエンコードと、POSTパラメータ*2のエンコードのために使っているだけで、直接リクエストには使用しません。自力でエンコードするならNet::HTTP::Postクラスを使う必要はありません。

8. リクエスト失敗時はログに記録して500エラーを返す

前述の通り、リクエスト失敗時はメールの再送をかけてくれるようなので、フォローに失敗した場合はわざと500エラーを返しておきます。

ただ、例えば「パスワードが間違っている」などの「再送しても成功しないケース」だった場合、再送のループに陥ってしまいます。きちんと作るならその辺りの配慮も必要になります。

9. それ以外のメールはログに記録しておく

これも今まで出てきたパターンと同じように、ログに記録した上で再送を防ぐために正常終了しています。

アカウント情報ファイルの作成

これでアプリケーションは完成ですので、最後にアカウント情報ファイルを同じディレクトリに保存しておきます。

$ vim accounts.yaml
user1:
  username: Sample_User1
  password: 1234ABCD
user2:
  username: testuser2
  password: foobarbaz

上の例で言うと"user1", "user2"の部分が「アカウント名」になります。「アカウント名」とTwitterへのログインに使う「ユーザ名」は同じでも異なっていても構いません。

言うまでもありませんが、これらのアカウント情報をクラウド上にアップロードすることはそれなりのリスクを伴います。その辺りは自己責任で行って下さい。

メールアドレスの登録

自動フォローしたいTwitterアカウントの「設定」ページでメールアドレスを登録します。メールアドレスは

<アカウント名>@<アプリケーションID>.appspotmail.com

になります。ドメインが"appspotmail.com"なのに注意して下さい。

アプリケーションのアップロード

本来ならここで開発サーバでのテストを行いたいところですが、前述の通り、2009/10/18現在はまだgoogle-appengine gemの各モジュールがSDK 1.2.6の機能に対応していないため、さっさと実環境にアップロードして、そちらで動作確認してしまいます。*3

config.ruの:applicationと:versionが正しいことを確認しておいて下さい。

$ appcfg.rb update .

アップロードが完了したら、試しに先ほど登録したメールアドレスに対して適当なメールを送信してみたり、実際に他のアカウントからフォローしてみたりして動作を確認しておくと良いと思います。

問題点

これでとりあえず自動フォローの仕組みが完成しました。しかし、このアプリケーションにはいくつかの重大な欠陥が存在します。

届いたメールがそのまま失われてしまうのが最たる問題でしょうか。フォロー通知メールなら別に消えたって構わないかもしれませんが、ダイレクトメッセージのメールやその他のメールまで消えてしまうのは問題です。転送するなり、データストアやログに記録しておくなりしたほうが良いでしょう。

パスワードを平文でアップロードしなければいけないのも気になる点です。この点はOAuthの仕組みを実装することで解決できそうです。

最もどうにもならない問題は、「GAEの1リクエスト30秒制限」と「JRubyインスタンスのロードの遅さ」の組み合わせによる「タイムアウトの頻発」でしょうか。

ログを監視しているとわかりますが、ぶっちゃけた話、JRuby環境をロードするだけで普通に30秒制限を突破してDeadlineExceededException例外が発生していたりします。幸い何度も再送をかけてくれるおかげで、そのうち成功してくれますが…。

今後GAE/JRubyの最適化が進めば解決するかもしれませんが、やはりGAEのRubyネイティブ対応が欲しいところです。

更なるフォロワー管理

さて、これで「フォロー通知メール反応型の自動フォロー」は実現できましたが、フォロワーの完全自動管理を行うためには、これだけでは全然足りません。

皆さんもご経験があるかもしれませんが、フォロー通知メールは100%信頼のおけるものではなく、実際には「フォローされたのにメールが送られてこない」というパターンもよくあります。

それに、bot等のプログラムではフォローを解除された場合の「自動フォロー外し」機能も必要なことがあるかもしれません。

これらはGAEのCronサービスと、Twitterの"friends/ids", "followers/ids" APIを組み合わせることで解決可能ですが、それはまた別の機会に。

簡単にやり方だけ書いておきますと、Cronで一日一回タスクを起動し、friends/idsとfollowers/idsをチェックして、差分IDに対してフォロー/アンフォローを実行していくだけです。

実際には前述の30秒制限もあり、確実に動作させるにはTask Queueサービスを利用するなど、色々工夫が必要になりそうな気がします。

…まぁ、レンタルでも自前でも、自由に使えるサーバ環境を用意できるならば、GAEではなくそちらを使ったほうが簡単だったりもしますが…。

*1:具体的に何をとるかは、ドキュメントが見当たらなかったのでソースを読むしかないのかもしれませんが…。

*2:今回はPOSTパラメータは存在しませんが。

*3:実際はコードのほうを変更して開発サーバでも動作確認をしています。

2009-08-16

Twitter Streaming APIをRubyで試してみる

Streaming APIとは

TwitterのStreaming APIをご存知でしょうか。

2009年4月頃から試験的に公開されているAPIなので、ご存知な方も多いと思います。2009年8月現在でまだαテスト中ですが、これを利用すると、push型でリアルタイムに情報を受け取ることができます。

TwitterのAPIは基本的にpull型なので、クライアントが能動的にリクエストを発行しなければ情報を受け取ることができません。しかし、1時間あたりのリクエスト回数、1リクエストあたりの最大データ数など、APIには様々な制限がかけられていますので、例えばリアルタイム性の必要なプログラムや、大量のtweetsが必要な統計プログラムなどの用途で通常のAPIを利用するのは厳しいものがあります。

そのような時はStreaming APIを使うと問題が解決するかもしれません。

過半数のAPIは利用に許可が必要ですし、また通常のAPIとは機能のベクトルが異なるため、「自分のタイムラインを取得して表示する」というような普通のTwitterクライアントライクな用途には使えませんが、アイデアさえあれば色々と面白いことができるのではないかと思います。

Streaming APIの概要

通常のAPIは、

  1. クライアントからTwitterサーバへHTTP接続を開き、
  2. クライアントがリクエストを送出し、
  3. サーバがレスポンスを送り返し、
  4. 接続を閉じる。

という流れになります。

Streaming APIの場合、クライアントがTwitterサーバへ接続を開き、リクエストを送るところまでは同じなのですが、その後はエラー等で切断されるまでずっと接続を維持し続けます。その間、サーバからは次々にレスポンスが送られてきますので、クライアントは順次それを受け取って処理していくことになります。

Streaming APIを使う上での注意点

詳しくはAPIのドキュメントに書いてありますが、重要なもののみまとめると、

  • 同一アカウントからの同時接続数は1本のみ。同一アカウントから2本以上の接続があった場合、古い接続は強制的に切断される。*1
  • 使用するHTTPライブラリ*2は、レスポンスボディを全て読み込んでから返すタイプではなく、順次読み込んでいけるタイプのものでなくてはならない。
  • サーバは接続の維持のために空行を送り返すことがあるので、クライアントはそれを許容できる*3作りでなくてはならない。
  • 通常のstatusの他に、「ユーザがstatusを削除した」という通知など、他の情報も混ざって送られてくるので、適切に処理すること。
  • エラーや通信の遅延など、様々な要因によって接続が切断されることもあるので、必要ならば自動で再接続する仕組みを組み込むこと。
  • 一部のAPIは膨大な量のデータを連続して返すので、それを受けるプログラムはパフォーマンスに関して相当な努力が必要かもしれない。
  • サービスの品質はベストエフォートである。

といったところだと思います。

使ってみる

百聞は一見にしかず…ということで、試しに一回Streaming APIを使ってみることにします。

Streaming APIの種類は後述しますが、ここではとりあえず"spritzer" APIをサンプルとします。これはPublic Timelineからランダムに抽出された発言が延々と流れてくるAPIです。

#!/usr/bin/env ruby
# coding: utf-8

require 'net/http'
require 'uri'
require 'rubygems'
require 'json'

USERNAME = '_USERNAME_' # ここを書き換える
PASSWORD = '_PASSWORD_' # ここを書き換える

uri = URI.parse('http://stream.twitter.com/spritzer.json')
Net::HTTP.start(uri.host, uri.port) do |http|
  request = Net::HTTP::Get.new(uri.request_uri)
  # Streaming APIはBasic認証のみ
  request.basic_auth(USERNAME, PASSWORD)
  http.request(request) do |response|
    raise 'Response is not chuncked' unless response.chunked?
    response.read_body do |chunk|
      # 空行は無視する = JSON形式でのパースに失敗したら次へ
      status = JSON.parse(chunk) rescue next
      # 削除通知など、'text'パラメータを含まないものは無視して次へ
      next unless status['text']
      user = status['user']
      puts "#{user['screen_name']}: #{status['text']}"
    end
  end
end

実行すると、Ctrl+Cなどでプロセスを終了するまで、標準出力にずらずらと発言が出てくると思います。私が試したときはだいたい秒間5〜10発言程度といった速度でした。Public Timelineはグローバルなので、大半は英語で埋まることになります。

実用的に使うのであれば、取得したデータを標準出力に送るのではなく、例えばファイルやデータベース経由、あるいはプロセス間通信などで別プロセスに引き渡し、別プロセスでリアルタイムにそれらを解析してなんらかの統計情報を得る…とか、そういう使い方になるのではないかと思います。*4

ちなみに、上のコードはレスポンスに"Transfer-Encoding: chunked"がセットされていることを期待した作りになっています。Streaming APIでは基本的にそうなっているようなので問題ないとは思いますが、仮に"Response is not chuncked"のエラーが出るようであれば*5、次のように明示的に区切り文字を指定してデータを取り出すような処理にすれば大丈夫だと思います。

#!/usr/bin/env ruby
# coding: utf-8

require 'net/http'
require 'uri'
require 'rubygems'
require 'json'

USERNAME = '_USERNAME_' # ここを書き換える
PASSWORD = '_PASSWORD_' # ここを書き換える

# Net::HTTPResponseクラスにeach_lineメソッドを追加
module Net
  class HTTPResponse
    def each_line(rs = "\n")
      stream_check
      while line = @socket.readuntil(rs)
        yield line
      end
      self
    end
  end
end

uri = URI.parse('http://stream.twitter.com/spritzer.json')
Net::HTTP.start(uri.host, uri.port) do |http|
  request = Net::HTTP::Get.new(uri.request_uri)
  request.basic_auth(USERNAME, PASSWORD)
  http.request(request) do |response|
    response.each_line("\r\n") do |line|
      status = JSON.parse(line) rescue next
      next unless status['text']
      user = status['user']
      puts "#{user['screen_name']}: #{status['text']}"
    end
  end
end

この際、データの区切りは"\n"ではなく"\r"であることに注意してください。*6"\n"は本文内にも出現し得るためです。細かいことはAPIのドキュメントに書いてあります。

Streaming APIの種類

Streaming APIには2009/08/16現在7つのAPIが用意されていますが、使い方は基本的に全て同じです。

タイプ別に大きく3つに分けることができます。

Public Timeline垂れ流し系

Public Timelineの内容、つまり鍵のかかっていない全ての発言を取得します。

このタイプのAPIには"firehose", "gardenhose", "spritzer"の3つが用意されています。

firehoseはPublic Timelineの全ての発言を含みます。利用には許可が必要です。

gardenhoseはfirehoseからランダムにサンプリングした発言を含みます。利用には許可が必要です

spritzerはgardenhoseよりも更に少なくサンプリングした発言を含みます。誰でも利用できます。

GETメソッドでリクエストを送ります。"count", "delimited"パラメータを取ることができますが、説明は省略します。

鍵をかけていない全てのユーザの全ての発言が流れてきますので、当然firehoseは恐ろしい量のデータが送られてくることになります。一番量の少ないspritzerでもかなりの勢いですので、この辺を扱う際は十分な注意が必要でしょう。

特定ユーザの発言追跡系

"follow"パラメータで指定したユーザの発言、及びそのユーザに向けられた発言だけを取得できます。*7鍵付きのユーザの発言は含まれません。

このタイプのAPIには"birddog", "shadow", "follow"の3つが用意されています。

birddogはfollowするユーザを20万まで指定できます。利用には許可が必要です。

shadowはfollowするユーザを5万まで指定できます。利用には許可が必要です。

followはfollowするユーザを200まで指定できます。誰でも利用できます。

POSTメソッドでリクエストを送ります。"follow"パラメータには、追跡したいユーザのID*8をカンマ区切りで並べて指定します。"delimited"パラメータについては省略します。

特定キーワードの抽出系

"track"パラメータで指定したキーワードを含む発言だけを取得できます。鍵付きのユーザの発言は含まれません。

このタイプのAPIには"track"のみが用意されています。誰でも利用できます。

POSTメソッドでリクエストを送ります。"track"パラメータには、キーワードをカンマ区切りで並べて指定します。キーワードは50個まで、1つのキーワードの長さは1〜30バイトです。

キーワードは大文字・小文字を区別せず、単語単位で検索されます。複数指定した場合はORになります。

この“単語単位”が曲者で、現状英語での単語単位…つまりスペースやその他記号で区切られた場合しかヒットしないようで、日本語キーワードを指定した場合はほとんど機能しません。

ただ、ハッシュタグなんかに対する抽出には使えますし*9、また例えば"nicovideo"を指定するとニコニコ動画のURLを含む発言を抽出できますので、使いようによってはなんとかなることもあります。

もう一回使ってみる

最後にもう一つサンプルを。今度は"track" APIを使ってみます。

#!/usr/bin/env ruby
# coding: utf-8

require 'net/http'
require 'uri'
require 'rubygems'
require 'json'

USERNAME = '_USERNAME_' # ここを書き換える
PASSWORD = '_PASSWORD_' # ここを書き換える

uri = URI.parse('http://stream.twitter.com/track.json')
Net::HTTP.start(uri.host, uri.port) do |http|
  request = Net::HTTP::Post.new(uri.request_uri)
  request.set_form_data('track' => 'bit')
  request.basic_auth(USERNAME, PASSWORD)
  http.request(request) do |response|
    raise 'Response is not chuncked' unless response.chunked?
    response.read_body do |chunk|
      status = JSON.parse(chunk) rescue next
      next unless status['text'] && status['text'].include?('http://bit.ly')
      user = status['user']
      puts "#{user['screen_name']}: #{status['text']}"
    end
  end
end

実行すると、bit.lyによる短縮URLを含んだ発言がずらーっと出てきます。さすがにかなりの勢いになりますので注意してください。

キーワードにドットを含めると上手くいかないようなので、"bit"で抽出して、その後スクリプト内で更にふるいにかけています。

このデータを加工すれば、bit.lyでどんなURLが注目を集めているか、といった統計を得ることができます。確か既にそんなサービスがあったように記憶していますが、おそらくこんな感じでStreaming APIを利用しているのではないでしょうか。

まとめ

まだαテスト中なため、品質面では過度な期待はできませんし、このAPI自体今後どう変わっていくかわかりません。*10しかし、それでもアイデア次第で様々な可能性が秘められている機能だと思います。一度Streaming APIで遊んでみるのはいかがでしょう。

*1:通常のAPIとStreaming APIの併用は可能。

*2:もしくはそれに類するもの

*3:単に無視すれば良いです。

*4RubyならマルチスレッドでQueueを使えば簡単にその類の連携ができます。

*5:上のコードの場合、認証失敗などでエラーが発生した際もこれが出ますので、そこは区別するよう注意してください。

*6:"\r"のあとには常に"\n"が続くようなので、ここではまとめて区切り文字としています。

*7:より厳密に言うと、"in_reply_to_*"パラメータでそのユーザが指定されている発言です。@付きで言及されていても、"in_reply_to_*"パラメータで指定されていない場合は含まれません。

*8:必ず数値で指定

*9:ハッシュタグを検索する場合、シャープ(#)をキーワードに含める必要はありません。勝手に上手いことやってくれます。

*10:APIのメソッド名もコードネームっぽいですし、正式リリースの時には変わりそうな気がします。

2009-08-02

TwitterのbotをOAuthに対応させる

Twitterの認証方式について

Twitterの認証方式にはOAuthとBasic認証の二通りの仕組みがあります。

Basic認証はお手軽なので、世に存在するTwitter APIを利用する一般的なクライアントは、大抵がこちらの方法を使っているのではないかと思います。

ですが、実はこのBasic認証はセキュリティ上の理由から、将来的に非推奨になることが予告されています。

既にTwitter API Wikiでは「今後作成するクライアントはOAuthを利用することを強く推奨する」と表明されており、それに応じてOAuthを使うクライアントも増えてきています。

…が、現状Basic認証を利用しているクライアントが既に大量に存在していることを考えると、今日明日でBasic認証が使えなくなる可能性はそう高くはないでしょう。先程のFAQでも、将来的に非推奨にすることは考えているが、日程は全くの未定である…と書いてあります。

何故OAuthを使いたいか

しかし実は、そういうセキュリティ的な事情以外にも、bot製作者的に「OAuthに移行したい!」と思わせる嬉しい利点がOAuthには用意されているのです。

それは投稿したTweetに表示される“クライアント名”を任意に設定できるということです。Web上でタイムラインを見たときに「webで」とか「Tweenで」とか表示されている部分ですね。

昔はBasic認証を利用するクライアントでもクライアント名を登録できたのですが、現在はOAuthへの移行を促すためか、OAuthを利用するクライアントしか登録することができません。その代わり、一人で複数のクライアントを登録できたり、いつでも好きなときに名前を変えることができたりと、かなり自由にクライアント名を弄ることができます。

これを使えば、例えばネタ系のbotなんかは、無機質な「APIで」という表示の変わりに、そのbot専用のお茶目な名前なんかを設定できたりしちゃうわけです。実際にこれを利用してユニークなクライアント名を表示しているbotもちらほら見かけますね。

ということで、セキュリティの向上というよりも*1むしろ、クライアント名を好きに弄ることを主目的として、botをOAuthに対応させる手順を書いていこうと思います。使用する言語はRubyです。

OAuthの仕組み

OAuthの仕組みについては以下のサイトなどの解説を参照してください。

認証の仕組みに馴染みが薄いと理解が難しい部分ですので、この記事では「使い方」以上のことは言及しません。

以下の記述でも、「クライアント=コンシューマ+ユーザ」くらいの大雑把な概念で用語を使用しています。

RubyでOAuthを利用する準備を整える

実際の手順に取りかかる前に、RubyでOAuthを利用する準備をします。

幸いruby-oauthというライブラリが存在していますので、ありがたく使わせてもらうことにしましょう。

$ gem install oauth

必要ならroot権限で実行してください。

これであとは

require 'rubygems'
require 'oauth'

すれば使えるわけですが、ここで一点注意しなければならないことがあります。

どうやらこのruby-oauthはRuby1.9系に完全には対応していないようで*2、そのまま使おうとすると例外が発生してしまいます。

とりあえずRuby1.9系でも最低限の機能だけは使えるようにするパッチを書いてみました。完全なものではありませんが、この記事に書いてあるレベルのことなら実行できるようになります。

oauth-patch.rb
if RUBY_VERSION >= '1.9.0'
  # エンコーディングの違いのせいで、
  # 日本語の文字列をpostパラメータに含めようとするとエラーが出ます。
  # 無理矢理エンコーディングをUTF-8に変えて再試行することで回避。
  module OAuth
    module Helper
      def escape(value)
        begin
          URI::escape(value.to_s, OAuth::RESERVED_CHARACTERS)
        rescue ArgumentError
          URI::escape(
            value.to_s.force_encoding(Encoding::UTF_8),
            OAuth::RESERVED_CHARACTERS
          )
        end
      end
    end
  end

  # 1.9から文字列がEnumerableでなくなりましたので、
  # その対応をしています。
  module HMAC
    class Base
      def set_key(key)
        key = @algorithm.digest(key) if key.size > @block_size
        key_xor_ipad = Array.new(@block_size, 0x36)
        key_xor_opad = Array.new(@block_size, 0x5c)
        key.bytes.each_with_index do |value, index|
          key_xor_ipad[index] ^= value
          key_xor_opad[index] ^= value
        end
        @key_xor_ipad = key_xor_ipad.pack('c*')
        @key_xor_opad = key_xor_opad.pack('c*')
        @md = @algorithm.new
        @initialized = true
      end
    end
  end
end

以降のサンプルプログラムでも、この"oauth-patch.rb"をrequireして利用しています。

OAuthクライアントを登録する

まず、以下のページにアクセスしてクライアントを登録します。

このとき、クライアント製作者のアカウントでWebからTwitterにログインした状態でアクセスしてください。作成したクライアントはTwitterのアカウントと紐付けられるためです。

"Register a new application"のリンクをクリックして登録画面へ進み、必要な情報を入力して「保存する」をクリックしてください。最低限"Application Name"、"Description"、"Application Website"が入力されていれば良いようです。

"Application Name"や"Description"には日本語も使えます。"Application Website"にはbotの説明ページなどがあればそのURLを、無ければ自分のサイトなど、製作者がわかるようなURLを入れておいたほうが良いでしょう。

それから、botから利用することを考えると

  • "Application Type"は"Client"に
  • "Default Access type"は"Read & Write"に

設定しておくことも必要でしょう。特に後者は"Read-only"のままにしておくと、そのクライアントを介したTweetの投稿などができなくなってしまうようです。

登録に成功すると"Application Details"の画面に移動します。そこに表示されている"Consumer key"と"Consumer secret"の値を使用しますので、メモって(コピペして)おいてください。

この"Application Details"画面には、先程の”Applications Using Twitter”画面からいつでもアクセスすることができます。また、クライアント名の変更などもこの画面から行えます。*3

アクセストークンを取得する

次に、このOAuthクライアントにbotアカウントでのアクセス許可を与え、アクセストークンを取得します。

この辺の話はOAuthの仕組みを知らないと少々ややこしいかもしれません。気になる方は先程のサイトなどでおさらいしてください。気にならない方は以下書いてある通りに実行すればOKです。

このアクセストークンを取得する手順も全自動で行えると良いのかもしれませんが、幸いTwitterはアクセストークンに有効期限を設定していないようですので、この手順を実行するのは最初の一回だけということになります。

ということであまり頑張らずに、一部手動を介して実行することにします。

次のような半自動スクリプトを書いてみました。

twitter-oauth.rb
#!/usr/bin/env ruby
# coding: utf-8

require 'rubygems'
require 'oauth'
require 'oauth-patch'

CONSUMER_KEY = 'CONSUMER-KEY' # ←ここを書き換える
CONSUMER_SECRET = 'CONSUMER-SECRET' # ←ここを書き換える

consumer = OAuth::Consumer.new(
  CONSUMER_KEY,
  CONSUMER_SECRET,
  :site => 'http://twitter.com'
)

request_token = consumer.get_request_token

puts "Access this URL and approve => #{request_token.authorize_url}"

print "Input OAuth Verifier: "
oauth_verifier = gets.chomp.strip

access_token = request_token.get_access_token(
  :oauth_verifier => oauth_verifier
)

puts "Access token: #{access_token.token}"
puts "Access token secret: #{access_token.secret}"

ソース中のCONSUMER-KEYとCONSUMER-SECRETを先程メモった値に書き換えて保存してください。

スクリプトを実行すると、以下のような出力が表示されます。REQUEST-TOKENの部分には実際にはもっと複雑な文字列が入っています。

$ ruby twitter-oauth.rb
Access this URL and approve => http://twitter.com/oauth/authorize?oauth_token=REQUEST-TOKEN
Input OAuth Verifier: 

'Access this URL and approve =>'の先に示されるURLに、botのアカウントでWebからTwitterにログインした状態でアクセスしてください。

"Allow (クライアント名) access?"、"拒否する"、"Allow"と表示されたページに移動すると思います。ページ内にクライアント名やその製作者名が表示されていますので、間違いが無いことを確認して"Allow"をクリックしてください。*4

"Allow"すると"You've successfully granted access to (クライアント名)!"と表示された画面に移動し、でかでかと7桁の*5数字が表示されると思います。

先程のスクリプトが入力待ちの状態で止まっていますので、この数字をそこに入力してください。

すると、最終的に以下のような出力が表示されてスクリプトが終了します。

Access token: ACCESS-TOKEN
Access token secret: ACCESS-TOKEN-SECRET

ACCESS-TOKEN、ACCESS-TOKEN-SECRETには実際にはもっと複雑な文字列が表示されます。この二つの値をメモって(コピペして)おいてください。

ここまでで準備は完了です。

アクセストークンを使ってTwitter APIを利用する

では、取得したアクセストークンを使ってTwitter APIを利用し、タイムラインの取得やTweetの投稿をするサンプルスクリプトを書いてみます。

twitter-oauth-access.rb
#!/usr/bin/env ruby
# coding: utf-8

require 'rubygems'
require 'oauth'
require 'oauth-patch'
require 'json'

CONSUMER_KEY = 'CONSUMER-KEY' # ←ここを書き換える
CONSUMER_SECRET = 'CONSUMER-SECRET' # ←ここを書き換える
ACCESS_TOKEN = 'ACCESS-TOKEN' # ←ここを書き換える
ACCESS_TOKEN_SECRET = 'ACCESS-TOKEN-SECRET' # ←ここを書き換える

# 下準備
consumer = OAuth::Consumer.new(
  CONSUMER_KEY,
  CONSUMER_SECRET,
  :site => 'http://twitter.com'
)
access_token = OAuth::AccessToken.new(
  consumer,
  ACCESS_TOKEN,
  ACCESS_TOKEN_SECRET
)

# タイムラインを取得して時系列順に表示
response = access_token.get('http://twitter.com/statuses/friends_timeline.json')
JSON.parse(response.body).reverse_each do |status|
  user = status['user']
  puts "#{user['name']}(#{user['screen_name']}): #{status['text']}"
end

# Tweetの投稿
response = access_token.post(
  'http://twitter.com/statuses/update.json',
  'status'=> 'このメッセージはOAuth認証を通して投稿しています。'
)

CONSUMER-KEY、CONSUMER-SECRET、ACCESS-TOKEN、ACCESS-TOKEN-SECRETを先程メモった値に書き換えて保存してください。日本語が含まれていますので、文字コードをUTF-8にすることを忘れないでください。

スクリプトを実行すると、タイムラインの直近20件が表示され、また「このメッセージはOAuth認証を通して投稿しています。」というTweetが投稿されます。

$ ruby twitter-oauth-access.rb

投稿した内容は実際にWebにアクセスするなどして確認してみてください。「APIで」の部分が、先程登録したクライアント名で表示されていたら成功です。また、"Application Details"の画面からクライアント名を変更すると、タイムライン上の表示にも即座に反映されることがわかると思います。

上のソースを見てもらえればわかりますが、ユーザ名やパスワードなどの情報は一切保持していません。

アクセストークンなどの情報は保持していますが、このアクセストークンに対するアクセス許可は、ユーザが任意に取り消すことができます。

botのアカウントでTwitterの「設定」ページを開いてみてください。一番右に「このユーザーに対する操作」というタブが増え、そこにアクセスを許可したクライアントがリストアップされています。ここからいつでも「許可を取り消す」ことができます。

この辺りがOAuthがBasic認証よりもセキュリティ的に優れていると言われる理由なわけですね。*6

botスクリプトをアクセストークンを利用するように書き換える

さて、アクセストークンを使ったTwitter APIの利用方法がわかりましたので、あとはbotスクリプトを書き換えてやれば、いつでも好きなときにクライアント名を変更できるbotの完成です。

実際にどこをどう書き換えればいいのかは、利用しているライブラリに依存しますので一概には言えません。

一例を挙げますと、jugyo氏作のRuby用Twitterライブラリ"Rubytter"は、予めOAuthを使ったアクセス方法が組み込まれていますので、上のサンプルスクリプトでも作成したOAuth::AccessTokenクラスのインスタンスさえあれば、すぐにでもOAuth対応させることができます。

OAuthに対応していないライブラリの場合は、Net::HTTPを呼び出している部分をOAuth::AccessTokenクラスのメソッドに書き換える必要があります。Net::HTTPとOAuth::AccessTokenのメソッド間対応は下のページなどを参考にしてください。シンプルに1対1対応していますので、あまり迷うこともないかと思います。

他に、コンシューマキーやアクセストークンキーなどの情報を渡す仕組みも用意してやる必要があるかもしれません。

スクラッチから書き起こしている場合も、やはりNet::HTTP部分をOAuth::AccessTokenに置き換えてやればOKです。既存のライブラリを書き換えるよりは、こちらのほうが融通が利きますので楽かもしれませんね。

まとめ

長くなりましたが、要点としては

  1. クライアントを登録し
  2. 半自動スクリプトを使ってアクセストークンを取得し
  3. botスクリプトのNet::HTTP部分をOAuth::AccessTokenに置き換える

という手順でOK、ということになります。

スクリプトの作りによっては若干手間がかかるかもしれませんが、一回手順を覚えてしまえば、あとは流れ作業なのでカンタンです。手順1と手順2でログインするTwitterアカウントを切り替えないといけないのが注意点といえば注意点でしょうか。*7

以上です。OAuthを使って、是非愛するbotにユーモア溢れるクライアント名を付けてあげてください!*8

参考サイト

*1:もちろんそれも大事なのですが

*2:あるいは私の使い方が悪いだけなのかもしれませんが…

*3:"Edit Application Settings"ボタン

*4:余談ですが、他のクライアントに対してあまり考えずに"Allow"することは、セキュリティ上の重大な過失となり得ます。十分に注意してください。詳しくはOAuthを悪用したTwitter DMスパムが登場 - まちゅダイアリー(2009-08-01)などを。

*5:桁数は変動するかも

*6:Twitter以外のサービスにIDやパスワードを教える必要が無い、という意味で。botのように自分で管理しているものなら、その辺りはさほど関係ないかもしれません。

*7:クライアントとbotが1対1対応するなら、両方botのアカウントでも良いのかもしれません。

*8:あまり頻繁に設定を変更するとTwitterから怒られるかもしれませんので、ほどほどに!

2009-06-28

Google App Engineで作るTwitter bot 〜 JRuby編

前置き

続きです。前回Google App Engine(以下GAE)上でRubyスクリプトを動作させるところまでできました。次のサンプルとして、簡単なTwitterbotを作成してみようと思います。

…どうして普通のWebアプリケーションじゃないのかって?それはまぁ…色々ありますが、元々私がGAEに触ってみようと思ったきっかけが「これを自作botの動作プラットフォームにできないかなぁ*1」と思ったから、ということもありまして。

それに、丁度URLフェッチやらGAEデータストアタスクのスケジューリングなど、GAE独自の機能がいくつか必要となってきますので、手軽なサンプルとしても悪くはないかな、と。

サンプルコードだらけで少々長い記事となってしまいましたが、どうぞお付き合い下さい。

作成するbotのタイプ

一言でTwitterのbotと言っても、様々なタイプが存在します。今回はシンプルな挨拶系botを作ってみます。タイムラインから他者の発言を取得し、「起きた」「おはよう」などのキーワードが含まれていたら「おはようございます!」とリプライを飛ばす…といった感じのものです。

この系統のbotを作成する場合に必要となる機能を考えると、

  1. Basic認証付きのGETリクエストでタイムラインを取得できること。
  2. 取得したデータ(XML or JSON)を解析できること。
  3. 取得した発言の内容によって処理を分岐できること。
  4. Basic認証付きのPOSTリクエストで発言を投稿できること。
  5. 最後に取得したステータスIDを保存及び取得できること。
  6. 一定間隔で自動実行できること。

といったところでしょうか。3番はRubyのcase式と正規表現でなんとでもなりそうですので、それ以外の機能が実現可能かどうかがカギとなります。

なお、基本的にこの記事ではTwitterのAPIについては一通りご存知であるという前提で話を進めていきます。ご存知でない方は、以下のドキュメントにざっと目を通しておくと良いかもしれません。*2

それでは、一つずつ条件をクリアしていきます。

雛形の準備

前回作成した雛形をそのまま流用します。

ダウンロードして展開し、一応わかりやすくディレクトリ名を変更します。

$ wget http://shiba.rdy.jp/souko/sample-jruby-on-gaej.tgz
$ tar xvzf sample-jruby-on-gaej.tgz 
$ mv sample-jruby-on-gaej sample-twitterbot-for-jruby-on-gaej

アプリケーション本体のファイル名(hello.rb)も変更しても良いのですが、今回は面倒なのでそのまま使っています。

ちなみに、GAEでは現状最大10個のアプリケーションしかデプロイできず、しかも削除することができません。なので、今回のようなサンプルは、枠を一つだけ確保し、そこをどんどん上書きする形で使っていった方が得策です。バージョン履歴は全部残りますので、いつでも昔のサンプルを動かすことができます。

friends_timelineを取得してみる

friends_timelineを取得するには、Basic認証付きのGETメソッドが使える必要があります。以下のページを眺めてみると、どうやらurlfetch-gaeというライブラリがこの要件を満たしてくれそうです。*3

簡単な使い方の説明などはこちら。

では、早速ダウンロードしてサンプルアプリに組み込みます。ライブラリの実体は一つのrbファイルですので、それをWEB-INFディレクトリに放り込むだけでOKです。gitをインストールしていない場合は、上のサイトの"download"ボタンからダウンロードしてください。

$ git clone git://github.com/Basaah/urlfetch-gae.git
$ cp urlfetch-gae/lib/urlfetch.rb sample-twitterbot-for-jruby-on-gaej/WEB-INF

ちなみにこのurlfetch.rb、わずか63行のシンプルなRubyスクリプトとなっていますので、RubyからどうやってJavaのクラスを利用するのかを学ぶのに良い題材となるかもしれません。

ではfriends_timelineを取得してみます。

$ cd sample-twitterbot-for-jruby-on-gaej
$ vim WEB-INF/hello.rb
require 'rubygems'
require 'rack'
require 'urlfetch'

class HelloWorld
  include Rack::Utils

  def call(env)
    res = Rack::Response.new

    res.write '<html><head>'
    res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>'
    res.write '</head><body>'

    url = 'https://twitter.com/statuses/friends_timeline.xml'
    username = 'username' # ここをbotのユーザ名に書き換える
    password = 'password' # ここをbotのパスワードに書き換える
    auth_token = [ "#{username}:#{password}" ].pack('m').chomp
    request_header = { 'Authorization' => "Basic #{auth_token}" }
    response = URLFetch.get(url, :header => request_header)

    res.write '<h2>Response code</h2>'
    res.write "<p>#{response.code}</p>"

    res.write '<h2>Response headers</h2>'
    res.write '<dl>'
    response.header_hash.each_pair do |key, value|
      res.write "<dt>#{escape_html(key)}</dt><dd>#{escape_html(value)}</dd>"
    end
    res.write '</dl>'

    res.write '<h2>Response body</h2>'
    res.write '<p>' + escape_html(response.content) + '</p>'

    res.write '</body></html>'
    res.finish
  end
end

今回は取得したレスポンスボディは特に解析せずそのまま出力します。また、Basic認証の仕組みは用意されていないようですので、自力でリクエストヘッダにセットしています。

開発サーバを起動して8080番にアクセスし、レスポンスヘッダや取得したXMLが表示されれば成功です。

$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .

取得したXMLを解析してみる

次はこのXMLを解析して必要な情報だけを取り出してみます。

JRubyではNative extensionが必要なgemは使えないようですので、JSONライブラリは使えません。Javaのクラスを呼び出して使ってもいいのですが、幸いRuby標準添付のREXMLがJRubyでも使えるようですので、こちらを利用します。つまり、フォーマットは常にXMLでリクエストすることになります。

では先ほどのソースを少々改造して、取得したメッセージをいくつかの情報に分け、テーブルで表示するようにしてみます。

$ vim WEB-INF/hello.rb
require 'rubygems'
require 'rack'
require 'urlfetch'
require 'rexml/document'
require 'time'

class HelloWorld
  include Rack::Utils

  def call(env)
    res = Rack::Response.new

    res.write '<html><head>'
    res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>'
    res.write '</head><body>'

    url = 'https://twitter.com/statuses/friends_timeline.xml'
    username = 'username' # ここをbotのユーザ名に書き換える
    password = 'password' # ここをbotのパスワードに書き換える
    auth_token = [ "#{username}:#{password}" ].pack('m').chomp
    request_header = { 'Authorization' => "Basic #{auth_token}" }
    response = URLFetch.get(url, :header => request_header)

    res.write <<-HTML
      <table>
        <tr>
          <th>ID</th>
          <th>text</th>
          <th>from</th>
          <th>posted at</th>
        </tr>
    HTML
    doc = REXML::Document.new(response.content)
    doc.each_element('/statuses/status') do |status|
      id = status.elements['id'].text
      created_at = Time.parse(status.elements['created_at'].text)
      text = status.elements['text'].text
      user = status.elements['user']
      screen_name = user.elements['screen_name'].text
      res.write <<-HTML
        <tr>
          <td>#{id}</td>
          <td>#{escape_html(text)}</td>
          <td>#{screen_name}</td>
          <td>#{created_at}</td>
        </tr>
      HTML
    end
    res.write '</table>'

    res.write '</body></html>'
    res.finish
  end
end
$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .

ID、発言内容、ユーザ名、投稿時間が表組みで表示されれば成功です。

発言を投稿してみる

取得は十分いけそうですので、次は発言の投稿を試してみます。urlfetch-gaeにはPOSTメソッドもサポートされており、ほぼ同じ感覚で使えるようですので、これも問題無さそうです。

とりあえずサンプルとして、アクセスする度に現在時刻をTwitterに投稿するスクリプトを書いてみます。

$ vim WEB-INF/hello.rb
require 'rubygems'
require 'rack'
require 'urlfetch'

class HelloWorld
  include Rack::Utils

  def call(env)
    res = Rack::Response.new

    res.write '<html><head>'
    res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>'
    res.write '</head><body>'

    url = 'https://twitter.com/statuses/update.xml'
    username = 'username' # ここをbotのユーザ名に書き換える
    password = 'password' # ここをbotのパスワードに書き換える
    auth_token = [ "#{username}:#{password}" ].pack('m').chomp
    request_header = { 'Authorization' => "Basic #{auth_token}" }

    now = Time.now.strftime("%Y/%m/%d %H:%M:%S")
    message = "現在時刻は #{now} です。"
    query_string = 'status=' + escape(message)

    response = URLFetch.post(url, query_string, :header => request_header)

    if response.code == 200
      res.write '<p>Success!</p>'
    else
      res.write '<p>Failure</p>'
    end

    res.write '</body></html>'
    res.finish
  end
end

ソース中にマルチバイト文字が出てきましたが、基本的に全てUTF-8で保存しています。TwitterのAPIがUTF-8なので、そこを揃えないと文字化けしてしまいます。

$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .

今回はアクセスしてみても'Success!'か'Failure'しか表示されませんので、Twitterのほうで実際に発言が投稿されているか確認する必要があります。

最後に取得したステータスIDを保存してみる

ここまででタイムラインの取得と発言まではできましたが、定期的に稼動させるbotとするためにはもう一つ重要な要素があります。

それは「前回取得したタイムラインの最後のステータスIDを保存できること」です。これがないと、次のタイムライン取得時に「どこまでが前回処理済なのか」を判別できず、同じ発言に対して2回リプライを飛ばしてしまう状態に陥ってしまいます。

前の記事で言及した通り、GAEではローカルファイルへの書き込みアクセスが禁止されていますので、他の手段をとる必要があります。

再度先ほどのページを眺めてみると、どうやらbumbleというライブラリがこの目的に使えそうです。

簡単な使い方の説明などは以下のページの真ん中少し下あたりが参考になります。

たった一つのデータを保存するためだけに使うには少々大げさですが、他に手段もないようですし*4、これを使うことにします。

インストールは先ほどのurlfetch-gaeと同じで、ダウンロード後、ライブラリの実体である一つのrbファイルをWEB-INFディレクトリに放り込むだけです。

$ cd ..
$ git clone git://github.com/olabini/bumble.git
$ cp bumble/bumble/bumble.rb sample-twitterbot-for-jruby-on-gaej/WEB-INF

もう一つ上の階層にもbumble.rbがありますが、これは無くても構いません。

では、先ほどのfriends_timelineをテーブルで出力するスクリプトにこれを組み込んで、前回実行時以降の発言だけ表示するようにしてみます。

$ cd sample-twitterbot-for-jruby-on-gaej
$ vim WEB-INF/hello.rb
require 'rubygems'
require 'rack'
require 'urlfetch'
require 'rexml/document'
require 'time'
require 'bumble'

class HelloWorld
  include Rack::Utils

  class StoredData
    include Bumble
    ds :last_id
  end

  def call(env)
    res = Rack::Response.new

    res.write '<html><head>'
    res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>'
    res.write '</head><body>'

    data_set = StoredData.all({}, :limit => 1)
    if data_set.empty?
      data = StoredData.create
    else
      data = data_set.first
    end
    last_id = data.last_id || 1

    url = 'https://twitter.com/statuses/friends_timeline.xml'
    query_params = { :since_id => last_id, :count => 200 }
    query_string = query_params.map do |key, value|
      escape(key.to_s) + '=' + escape(value.to_s)
    end.join('&')
    url += '?' + query_string

    username = 'username' # ここをbotのユーザ名に書き換える
    password = 'password' # ここをbotのパスワードに書き換える
    auth_token = [ "#{username}:#{password}" ].pack('m').chomp
    request_header = { 'Authorization' => "Basic #{auth_token}" }

    response = URLFetch.get(url, :header => request_header)

    res.write <<-HTML
      <table>
        <tr>
          <th>ID</th>
          <th>text</th>
          <th>from</th>
          <th>posted at</th>
        </tr>
    HTML
    doc = REXML::Document.new(response.content)
    doc.elements.to_a('/statuses/status').reverse_each do |status|
      last_id = id = status.elements['id'].text.to_i
      created_at = Time.parse(status.elements['created_at'].text)
      text = status.elements['text'].text
      user = status.elements['user']
      screen_name = user.elements['screen_name'].text
      res.write <<-HTML
        <tr>
          <td>#{id}</td>
          <td>#{escape_html(text)}</td>
          <td>#{screen_name}</td>
          <td>#{created_at}</td>
        </tr>
      HTML
    end
    res.write '</table>'

    data.last_id = last_id
    data.save!

    res.write '</body></html>'
    res.finish
  end
end

GETリクエストにsince_idとcountパラメータを含めていること、また最後に処理する発言を最新のものにするために、ステータスの配列をreverse_eachで処理していることあたりがポイントです。

$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .

間を空けつつ何度かアクセスしてみると、確かに新着メッセージしか表示されないことがわかります。

ちなみに、開発サーバでGAEデータストアを利用すると、WEB-INFディレクトリの中にappengine-generatedというディレクトリが作成されます。この中に書き込んだデータが保存されるため、開発サーバを立ち上げなおしても引き続きデータを利用することができます。逆にこのディレクトリを消してしまえばデータを初期化できます。

このディレクトリはGAEにアップロードする際は無視されますので、気にする必要はありません。

挨拶botを作ってみる

ここまでの材料で、手動で動かす挨拶botは作れるようになりました。ではhello.rbを整理しつつ少々改造して、簡単な挨拶botに仕立て上げてみます。

$ vim WEB-INF/hello.rb
require 'rubygems'
require 'rack'
require 'urlfetch'
require 'rexml/document'
require 'time'
require 'bumble'

class Hash
  def to_query_string
    map do |key, value|
      Rack::Utils.escape(key.to_s) + '=' + Rack::Utils.escape(value.to_s)
    end.join('&')
  end
end

class HelloWorld
  include Rack::Utils

  class StoredData
    include Bumble
    ds :last_id
  end

  URLS = {
    :friends_timeline => 'https://twitter.com/statuses/friends_timeline.xml',
    :status_update => 'https://twitter.com/statuses/update.xml',
  }

  USERNAME = 'username' # ここをbotのユーザ名に書き換える
  PASSWORD = 'password' # ここをbotのパスワードに書き換える
  def request_header
    auth_token = [ "#{USERNAME}:#{PASSWORD}" ].pack('m').chomp
    { 'Authorization' => "Basic #{auth_token}" }
  end

  def get_timeline(options = {})
    url = URLS[:friends_timeline] + '?' + options.to_query_string
    response = URLFetch.get(url, :header => request_header)
    raise "Get timeline failed: #{response.code}" unless response.code == 200
    doc = REXML::Document.new(response.content)
    doc.elements.to_a('/statuses/status').reverse
  end

  def post(text, options = {})
    options[:status] = text
    response = URLFetch.post(URLS[:status_update], options.to_query_string,
                             :header => request_header)
    raise "Post status failed: #{response.code}" unless response.code == 200
    doc = REXML::Document.new(response.content)
    doc.elements['/status']
  end

  def call(env)
    res = Rack::Response.new

    res.write '<html><head>'
    res.write '<title>Sample Twitter bot for JRuby on GAEJ</title>'
    res.write '</head><body>'

    data_set = StoredData.all({}, :limit => 1)
    if data_set.empty?
      data = StoredData.create
    else
      data = data_set.first
    end
    last_id = data.last_id || 1
    statuses = get_timeline( :since_id => last_id, :count => 200 )

    res.write <<-HTML
      <table>
        <tr>
          <th>ID</th>
          <th>text</th>
          <th>from</th>
          <th>posted at</th>
          <th>status</th>
        </tr>
    HTML
    statuses.each do |status|
      last_id = id = status.elements['id'].text.to_i
      created_at = Time.parse(status.elements['created_at'].text)
      text = status.elements['text'].text
      screen_name = status.elements['user/screen_name'].text
      next if screen_name == USERNAME   # 自分の発言はスキップ

      reply_message = nil
      unless text.include?('@') # 誰かへの返信ではない場合に限り
        # ここの条件分岐を発展させていけば、リプライのパターンを充実させられる
        case text
        when /(?:起きた|おきた|おはよ|オハヨ)/
          reply_message = 'おはようございます!いい朝ですね。'
        when /(?:こんにち(?:は|わ)|コンニチ(?:ハ|ワ))/
          reply_message = 'こんにちは。ご機嫌はいかがですか?'
        when /(?:こんばん(?:は|わ)|コンバン(?:は|ワ))/
          reply_message = 'こんばんは。今日も一日お疲れ様でした。'
        end
      end
      if reply_message
        post("@#{screen_name} #{reply_message}", :in_reply_to_status_id => id )
      end

      res.write <<-HTML
        <tr>
          <td>#{id}</td>
          <td>#{escape_html(text)}</td>
          <td>#{screen_name}</td>
          <td>#{created_at}</td>
          <td>#{reply_message ? 'replied' : 'ignored'}</td>
        </tr>
      HTML
    end
    res.write '</table>'

    data.last_id = last_id
    data.save!

    res.write '</body></html>'
    res.finish
  end
end

少々長くなりました。細部がかなり適当な実装となっていますが、最低限の挨拶botとしては稼動できる状態です。

$ ../appengine-java-sdk-1.2.1/bin/dev_appserver.sh --address=0.0.0.0 .

Twitter上で他のアカウントから発言してみるなどして動作を確認します。

自動的に定期実行させてみる

さて、ここまででbot自体の機能実装は一通り完了しましたが、botならばやはり自動的に定期実行させておきたいものです。

幸い、GAEにはcronの仕組みが用意されており、しかも最短で1分毎に指定したURLを呼び出すことができます。

上のページを読んで、cronの実行に必要な設定ファイルを記述します。

$ vim WEB-INF/cron.xml
<?xml version="1.0" encoding="UTF-8"?>
<cronentries>
  <cron>
    <url>/</url>
    <description>Auto-reply of sample twitter bot every 3 minutes</description>
    <schedule>every 3 minutes</schedule>
  </cron>
</cronentries>

そんなに急ぐようなbotでもありませんので、3分間隔であれば十分でしょう。

このままですとURLを手動で呼び出した場合もbotが起動してしまい、下手をすればTwitterのAPI制限に引っかかってしまいますので、このURLを管理者以外アクセスできないような設定にし、cron以外では実行できないようにしてしまいます。

…と思いましたが、どうも現状その辺りにバグがあるらしく、アクセス制限をかけるとcronが機能しなくなる模様。仕方ないので今回はあくまでもサンプルということでスルーしておきます。

では開発サーバで動作確認…といきたいところですが、あいにく開発サーバはcronの実行まではサポートしていませんので、実際にGAEにアップロードして確認する必要があります。

上のページにはアプリケーション本体はアップロードせず、cronの設定だけを書き換える方法も書いてありますので、何度か試行錯誤してみても良いでしょう。

アップロードする前にWEB-INF/appengine-web.xmlのapplicationとversionの値を適切に書き換えておいてください。

$ ../appengine-java-sdk-1.2.1/bin/appcfg.sh --enable_jar_splitting update .

versionを上げた場合、アップロードが完了したらGAEのダッシュボードからアプリケーションのデフォルトのバージョンを変更するのを忘れないようにしてください。古いバージョンがデフォルトのままになっています。

最初の数回は上手く動かない場合もあるかもしれませんが、しばらく様子を見ていると安定してくるはずです。*5

他のアカウントからキーワードにかかるような発言をしてみて、手動でURLにアクセスすることなく正しくリプライが返ってくれば、めでたくbotの完成です。

次のステップへ

後はこのスクリプトのメッセージ分岐の部分を追加修正していけば、それなりにしっかりとしたbotにできる…のですが、試行錯誤しながら継ぎ足し継ぎ足し書いてきた関係で、だいぶごちゃごちゃしたソースになってしまっています。

Twitter APIを呼び出す部分なんかも、少し整理すれば別ファイルに切り出してライブラリにまとめられそうです。

ということで、次回*6は、ソースを整理しつつ、botとしてもう少しまともなスクリプトに発展させていきます。

ある程度整理が付いたら、また前回のように「GAE上のTwitter botのテンプレート」になるパッケージでも用意してみようと思います。

*1:現在はさくらのレンタルサーバ上でいくつか稼動させています

*2:読まなくても作れますが。

*3:rb-gae-supportを使えば巷のTwitterライブラリがそのまま使えるかもしれませんが、今回は独自で実装してみます。

*4:memcacheはいつデータが消えるかわかりませんし

*5:最初に200件ステータスを取得してしまうために30秒制限にひっかかるのかもしれません。最初の取得だけ件数を制限すれば問題ないかも。

*6:もしくは近い未来…あるいは遠い未来