シェルライクなTwitterクライアントをざっくり作ってみた

昨日Twitterでシェルっぽい感じのTwitterクライアントあったらおもしろくない? とか言っていて, なんか楽しそうだったのでざっくり作ってみました.

使い方はこんな感じ.

$ ruby twsh.rb -u your_twitter_id -p password
twsh% echo "はろー twsh.rb"
twsh% ls
screen_name1: tweet1
screen_name2: tweet2
screen_name3: tweet3
screen_name4: tweet4
screen_name5: tweet5
twsh% exit
$

下記コードをtwsh.rbとかで保存して, Rubyとして実行してあげれば起動します. -pオプションはパスワードで必須ですが, -uによるTwitter IDは省略すると環境変数USERが使われます.

後はechoで発言, lsでTL取得です. exit(quit, logout)でシェルを抜けます*1.

ちなみにTwsh.rbとして実装されていないコマンドを用いると, 普段使っているシェルに実行を委ねます. そしてバッククォートによるコマンドの実行を備えているので:

twsh% echo "ボクの$ unameは" `uname` です

とかやると, "ボクの$ unameDarwin です"って出せたり.

適当で抜けとかありますが, 興味がある方は適当にいじっちゃってくだしあ.

あ, あとボクがTwitterでぶつくさ言っていたら@omasanoriさんが興味を持ったようでして, PythonでTwsh.pyを作っているみたいです! たぶんTwsh.rbよりクールなやつができあがると思いますよ!

require 'optparse'
require 'stringio'
require 'readline'
require 'rubygems'
require 'twitter'

module Twsh
  class Logout < StandardError; end

  class Shell
    class << self
      def login(argv, env)
        new argv, env
      end
    end

    def initialize(argv, env)
      username = env['USER']
      password = nil

      OptionParser.new do |opt|
        opt.on('-u VAL') {|v| username = v}
        opt.on('-p VAL') {|v| password = v}
        opt.parse! argv
      end

      fail if username.nil? || password.nil?

      @client = Twitter::Base.new Twitter::HTTPAuth.new username, password
      input = Input.new self
      logged_in = true

      while logged_in
        begin
          output = execute input.read
          puts output if output
        rescue Logout
          logged_in = false
        rescue Interrupt
          puts
          logged_in = false
        end
      end
    end

    attr_reader :client

    def execute(argv)
      return if argv.empty?
      command = argv.shift
      Command.new(self).execute command, argv
    end
  end

  class Command
    @@commands = {}

    class << self
      def setup
        fail unless block_given?
        yield new
      end
    end

    def initialize(twsh = nil)
      @twsh = twsh
    end

    def on(*commands, &process)
      fail unless block_given?
      commands.each {|command| @@commands[command] = process}
    end

    def execute(command, argv)
      process = @@commands[command]

      if process
        args = []
        args << @twsh if process.arity >= 1
        args << argv if process.arity >= 2
        output = nil

        StringIO.open '', 'w' do |io|
          orig = $stdout
          $stdout = io
          process.call *args
          $stdout = orig
          output = io.string unless io.string.empty?
        end

        output
      else
        `#{command} #{argv.join ' '}`.chomp
      end
    end
  end

  class Input
    def initialize(twsh)
      @twsh = twsh
    end

    def read
      read_with_context
    end

    private

    def read_with_context(context = nil, argv = [])
      prompt = context ? "#{context}> " : argv.empty? ? "twsh% " : '> '
      input = Readline.readline prompt, true
      context, argv, continue = parse "#{input}\n", context, argv
      continue ? read_with_context(context, argv) : argv
    end

    def parse(input, context = nil, argv = [])
      escape = false
      continue = false
      v = !argv.empty? ? argv.pop : ''

      new = lambda do
        argv << v unless v.empty?
        v = ''
      end

      input.each_byte do |c|
        if escape
          escape = false
          case c
          when LINE_FEED
            continue = true
            v << c if context
          when ?n
            v << "\n" if quoted? context
          when ?t
            v << "\t" if quoted? context
          when ?f
            v << "\f" if quoted? context
          when ?v
            v << "\v" if quoted? context
          else
            v << c
          end
        else
          case c
          when ?\n
            v << c if context
          when ?\s
            if context
              v << c
            else
              new.call
            end
          when ?'
            if context == :quote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :quote
            end
          when ?"
            if context == :dquote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :dquote
            end
          when ?`
            if context == :bquote
              v = @twsh.execute(parse(v)[1])
              context = nil
            elsif context
              v << c
            else
              context = :bquote
            end
          when ?\\
            escape = true
          else
            v << c
          end
        end
      end

      new.call
      [context, argv, continue || !context.nil?]
    end

    def quoted?(context)
      context == :quote || context == :dquote
    end
  end
end

Twsh::Command.setup do |c|
  c.on('exit', 'quit', 'logout') { raise Twsh::Logout }

  c.on 'echo' do |twsh, argv|
    twsh.client.update argv.join ' '
  end

  c.on 'ls' do |twsh, argv|
    n = 5

    OptionParser.new do |opt|
      opt.on('-n VAL', Integer) {|v| n = v}
      opt.parse! argv
    end

    twsh.client.home_timeline(:count => n).each do |status|
      puts "#{status.user.screen_name}: #{status.text}"
    end
  end

  c.on 'cd' do |twsh, argv|
    if argv.empty?
      Dir.chdir
    else
      Dir.chdir File.expand_path argv.first
    end
  end
end

Twsh::Shell.login ARGV, ENV

*1:Ctrl + cでもいいんですが, Readlineが捉えちゃっているのかInterruptがうまく投げられない……

名前空間つきのモジュールを使う

いつからこうなっているか知らないんですけど, Erlangって名前空間があるらしいです.

-module(ffhh.followers.director).

こんな感じでモジュールを任意の名前空間に属させるコトができます. アンダースコアで長ったらしい名前をつけなくてもいいワケですね! 例えば呼び出しとかも, 同一の名前空間に属していれば同一部分は省略できます. こんな感じ.

-module(ns.mod1).

-export([fun1/0]).

fun1() ->
    Result = mod2:fun1(),   % 実際にはns.mod2:fun1/0がコールされている
    .lists:reverse(Result). % ただ存在しないモジュールはルートから検索してくれるワケではないので,
                            % 先頭にドットをつけて, 明示的にルートから検索するようにしないとダメ
                            % (ちょっとめんどくさい)

ステキ! 少しめんどうなところもあるし, 実際OTP使ったりすると, どこから呼ばれるのかわからないので, 先頭にドットつけてフルパスで指定した方が結局無難じゃん! とかありますけども, まぁそれは些細な話というコトで.

んで名前空間を使うと, ディレクトリの構造も変えなくちゃいけなくて, CPANよろしくな構造にしないといけないんですよね.

コレを:

src
├── ns.erl
└── ns_mod1.erl

こんな風に:

src
├── ns.erl
├── ns
     └── mod1.erl(モジュール名はns.mod1)

あと*.beamファイルの出力先も同じような構造になってなくちゃいけません. *.erlと同じところに入れるなら簡単ですが, ebinなど別のディレクトリに出力するならディレクトリを作りつつコンパイルしなくちゃいけないはず.

というコトでそれを行うRakefileを書いてみた*1. MakefileじゃないのはただボクがMakefileの書き方をよく知らないってだけです.

require 'rake/clean'

BEAM_DIR = 'ebin'

def dirname(files)
  (files.map {|f| File.dirname f}.uniq - [BEAM_DIR]).sort_by {|f| f.length}
end

BEAM_OBJ = FileList['src/**/*.erl'].sub(/^src/, BEAM_DIR).ext 'beam'
BEAM_DIRS = dirname BEAM_OBJ

CLOBBER.include BEAM_OBJ + BEAM_DIRS

BEAM_DIRS.each {|dir| directory dir}

def modules(*mods)
  files = mods.flatten.map do |mod|
    segs = mod.split '.'
    segs[-1] = "#{segs.last}.beam"
    File.join BEAM_DIR, segs
  end

  dirname(files) + files
end

def resolve(ext)
  lambda {|obj| obj.sub(/^#{BEAM_DIR}/, 'src').sub(/\.[^.]+$/, ".#{ext}")}
end

ERLC = 'erlc'
ERLC_FLAGS = '-W -Iinclude'

task :default => modules('ns', 'ns.mod1')

rule /\.beam$/ => resolve('erl') do |t|
  sh "#{ERLC} #{ERLC_FLAGS} -o #{File.dirname t.to_s} #{t.source}"
end

楽ちんだぜー!

*1:ebinは最初からある想定です. *.appを置かない場合はebinも作るようにした方がいいと思います

新しいふぇらほい forefrontの構造

単なる走り書きです.

(やべぇ, 透過しっぱなしだ)

灰色の四角形は全部モジュールで, 太めの字はモジュール名, その下に書かれた小さな字は使用しているビヘイビアの名前です(存在しないのはビヘイビアを使っていない). 水色で囲われたところはsupervision tree. ffhh_supの下に並ぶモジュールはsupervisorの子となります.

ffhh

各プロセスへのインタフェースを備えているだけ. このモジュールから各プロセスに指示を出す.

ffhh_app

アプリケーション. ただsupervision treeを作るよう指示するだけ. まじ中身空っぽ.

ffhh_sup

スーパバイザ. どんなsupervision treeを作るかを定義しているだけなので, ffhh_app並みに内容がない.

ffhh_logger

ロガー. さまざまなプロセスからのログ出力要求に応えてログを吐くだけ.

ffhh_director

ユーザーを保持しているディレクタ. こいつがタイミングを見計らってクローラの生成/回収を行う.

ffhh_crawler

指定されたユーザーのフォロワーを捜査するクローラ. スーパバイザの管理下ではないので注意. ただクローラの死亡はディレクタがきっちり面倒みてる.

ffhh_judgement

ユーザーがスパムかそうでないかを判断する. クローラはこいつによってフォロワーをスパム or スパムでない, に振り分ける.

ffhh_sweeper

アカウントをスパムとして報告またはブロックする. クローラによって集めたスパムアカウントをこいつによってほいほいする. ほいほいした情報も蓄えている.

Twitter Streaming APIをErlangから使ってみる

TwitterさんはたくさんAPIを公開しており, 開発者からしたら頭が下がる想いですが, その中でもちょっと特殊なStreaming API.

Twitter API Wiki / Streaming API Documentation

Public Timeline上を流れるつぶやきを次々と受け取るAPIですね. 種類があるのでとりあえず書いておきます.

メソッドの種類

sampleメソッド

Public Timeline上のつぶやきをランダムにピックアップし, 応答として返してくれるメソッドです. 「とりあえずどんな感じなのか知りたいんだけど」って人にもってこいです.

URL
http://stream.twitter.com/1/statuses/sample.format
リクエストメソッド
GET
firehoseメソッド

Public Timeline上の全つぶやきを応答として返してくれるメソッド. おそらく消化ホース(firehose)の中を流れる水のよう, として名付けられたんじゃないでしょうか? 全つぶやきなのでそれはそれは激流なんだろうなぁ, と勝手に想像してました.

しかしこのメソッド, 利用時のアカウントに特定のロール(権限)が与えられていないと使えません. なので普段はあまり使(わ|え)ないでしょう.

URL
http://stream.twitter.com/1/statuses/firehose.format
リクエストメソッド
GET
filterメソッド

使うならこっちのfilterメソッド. Public Timeline上から条件に該当するつぶやきを応答として返してくれるメソッドです. 指定できる条件はユーザーID(follow)または任意のキーワード(track), です. 双方ともカンマ区切りで複数指定できます. 片方, または両方指定するコトもできますが, 両方省略するコトはできません. firehoseメソッドと同じになっちゃいますからね.

注意するコトはfollowにはScreen nameではなく, ユーザーIDしか指定できません. ボクだったらtakkkunではなく, 14976270です. またtrackにはマルチバイト文字を指定するコトができないようです. またデフォルトではfollowに200個, trackに400個までしか指定できません.

URL
http://stream.twitter.com/1/statuses/filter.format
リクエストメソッド
POST
retweetメソッド

コレだけ試していないのでなんともですが, Public Timeline上を流れるRetweetsを応答として返すメソッドでしょう. そんな感じ.

URL
http://stream.twitter.com/1/statuses/retweet.format
リクエストメソッド
GET

気になるところ

@tsupo さんによるTwitter APIの日本語訳があります.

twitterAPI.txt at master from tsupo's Twitter-API-Specification--written-in-Japanese- - GitHub

ここのStreaming APIの項にgardenhose, birddog, shadowというのがあります. しかし先のStreaming API Documentにはありません. コレらは代わりにロールという位置づけになっており, そのロールが付与されると, filterメソッドのfollowまたはtrackに指定できるIDまたはキーワードの個数が増える, というものです.

どちらにせよロールは設けるのでしょうが, filterメソッドの制限を拡張するのか, 新たなメソッドが使えるようになるのか, どうなるか気になるところです.

Erlangから実際に使ってみる

とりあえず現状で動くコードを書いてみます. 試すだけならcurlを使えばいいんですが, コードから扱ってみたかったのでErlangで試してみました.

-module(twitter_stream).
-author("KONDO Takahiro <heartery@gmail.com>").

-export([filter/3, stop/1]).

filter(User, Password, Data) ->
    Url = lists:append(["http://", User, ":", Password, "@stream.twitter.com/1/statuses/filter.json"]),
    Request = {Url, [], "application/x-www-form-urlencoded", Data},
    stream(post, Request, processor()).

stop(Streamer) ->
    send(Streamer, stop),
    receive {Streamer, stopped} -> stopped end.

stream(RequestMethod, Request, Processor) ->
    spawn(fun() ->
                  Options = [{sync, false}, {stream, self}],
                  case http:request(RequestMethod, Request, [], Options) of
                      {ok, RequestId} -> loop(RequestId, Processor);
                      {error, Reason} -> send(Processor, {error, Reason})
                  end
          end).

loop(RequestId, Processor) ->
    receive
        {http, {RequestId, stream_start, Headers}} ->
            send(Processor, {start, Headers}),
            loop(RequestId, Processor);
        {http, {RequestId, stream, Part}} ->
            case Part of
                <<"\r\n">> -> noop;
                _          -> send(Processor, {stream, Part})
            end,
            loop(RequestId, Processor);
        {http, {RequestId, {error, Reason}}} ->
            send(Processor, {error, Reason});
        {From, stop} ->
            http:cancel_request(RequestId),
            send(From, stopped)
    end.

processor() ->
    spawn(fun() -> process_loop() end).

process_loop() ->
    receive
        {_Streamer, {start, Headers}} ->
            io:format("Start: ~p~n", [Headers]),
            process_loop();
        {_Streamer, {stream, Part}} ->
            io:format("Stream: ~p~n", [Part]),
            process_loop();
        {_Streamer, {error, Reason}} ->
            io:format("Error: ~p~n", [Reason])
    end.

send(To, Message) ->
    To ! {self(), Message}.

つぶやきひとつひとつは改行で区切られるので, それを元にして切り出しています.

http:request/4のオプションとして{stream, self}を用いると, http:request/4を呼び出したプロセスに次々とレスポンスが流れ込んでくるので, それを改行区切りで別プロセスに渡すだけです. 実用するならその別プロセス(コード上ではprocessor)を外から渡せるようにした方がいいでしょうね.

とりあえずこんな感じに使います. erlang(大文字小文字は関係ない)を含むつぶやきを受け取ります.

% erl
Erlang R13B01 (erts-5.7.2) [source] [smp:2:2] [rq:2] [async-threads:0] [hipe] [kernel-poll:false]
Eshell V5.7.2  (abort with ^G)
1> inets:start().
ok
2> Streamer = twitter_stream:filter("Twitter ID", "Password", "track=erlang").
<0.48.0>
% 標準出力に結果を出力しているので, ここに応答が次々と表示される
3> twitter_stream:stop(Streamer).
stopped

コレでStreaming APIからつぶやきを受け取りつついろんなコトができそうです.

Twitterにまつわるいろいろな制限について

こんにちは! みなさん今日もTwitterで元気につぶやいていますか!? さえずっていますか!?

ところでよく聞く"規制"ってあるじゃないですか. なんかあまりにヒマだったのでちょっと調べてみたのです. そしたらこんなページが.

Twitter Support :: Following and Update Limits

Twitterが設けている制限について記述してあるページです. 抜粋してまとめてみました. まとめただけなので, 対策とかではないです. ちなみに○○制限という名前で統一してありますが, 正式な名前ではないのであしからず.

API制限

TwitterにはAPIというモノが設けられておりますが, ある制限を超えると使用できなくなります. それは1時間あたりの使用回数が150を超えた時です*1. この使用回数は特定のタイミングでリセットされます.

ちなみに誤解されやすいかもしれませんが, この使用回数を消費するAPIと消費しないAPIがあります.

大まかに言うと, TimelineやMentions, DirectMessagesなどの取得に関わるAPIは使用回数を消費します. それ以外のつぶやきの投稿や, フォロー/リムーブ, お気に入りの追加/解除などのAPI使用回数を消費しません. したがって「つぶやきすぎてAPI制限にひっかかっちゃった!」はありえません.

アップデート制限

じゃあなぜつぶやけなくなってしまうのか. それは別の制限があるからです. それがアップデート制限. つぶやくコトをTwitter側ではステータスのアップデートと表現するのでこういう風に呼んでみました.

どうやらつぶやきは1日に1,000件までしか受け付けないようです. Webからつぶやこうが, クライアントからAPI経由でつぶやこうが, 1,000を超すコトはできないみたいです. あとDirectMessageは1日に250件までです.

フォロー制限

あとフォローに関しても制限があります. ただ大した話ではないですが.

簡単に言うとfollowingを極端に増やすコトはできない, という話です. followingとfollowerの比率によって最大following数が決まります.

おかしな400 Bad Requestについて

制限にひっかかると, Twitter側からの応答で400 Bad Requestというのが返ってきます. 「その要求は受け付けられないなぁ」というコトですね. 試しにAPIを150回使用した後で, 追撃のAPIを使用すると400 Bad Requestが返ってきます.

しかしつぶやきが1,000に到達していないはずなのに, つぶやいたときに400 Bad Requestが返ってくるときがあります. ボク自身だと, ここ数日の深夜1時から朝7時ぐらいまでつぶやけないときがあります. それもまったくではなく, 1回2回つぶやくのに成功して, その後またつぶやけないとう具合に.

コレは先のページにも書かれていない現象なので, ただの推測ですがTwitter側の隠れた負荷軽減措置のような気がします. ボクは10,000回つぶやいた後ぐらいからこの影響を初めて受けたので, つぶやいた回数が多い人にこの措置が適用されているのかもしれません.

まとめ

  • APIは1時間あたり150回までしか使用できない. ただしTLの取得などに限る
  • つぶやきは1日に1,000件まで. DMは1日に250件まで
  • しかし時間帯やユーザーによっては1,000件未満でもつぶやけない時がある
  • followerを増やさずして, followingを極端に増やすコトはできない
  • コレらを知ったとしても, つぶやき無条件拒否の対策にはならない*2

追記[2009-10-12 1:41]

制限に関するページが他にもありました.

Twitter API Wiki / Rate limiting

気がかりなのは一番下のブラックリストに関連するところ. 怪しい挙動のアプリケーション(OAuthコンシューマも含むのでしょうか)は自動的にブラックリスト入りされるとか……?

*1:Twitterの調子によってはより少なくなるコトもあるとか

*2:Twitter側もふざけているわけではないでしょうし, 従うべきなのだと思います

Twitterのしつこいフェ○アイコンを自動でブロックするサービスを作ったよ

こちらでーす.

ふぇらふぇらほいほい

名前に関してはいろいろありますが, とりあえずBritney Fuckedのせいにしておいてください. あ, ごめんなさいすみません, あ, あ. 悪ふざけです.

使い方はOAuthの方で許可していただければそれだけでOKです. 詳しいコトはふぇらほいの方に書いてありますけどね.

使ったもの

せっかくなんで書いておきます. 余力があったらソースコードでも公開します.

Web側

全部Rubyで書いてあります. コントローラにSinatra, ビューにHamlを使っています. データベースへのアクセスはSQLを直接書いているので, 特にどうこうはありません. あとはグラフを描画するためにGruffを使っています.

サーバーはURLからもわかりますが, さくらさんです. データベースはMySQL. 最初はcoreserverの方でやっていましたけど, Gruffが利用するRMagickをインストールするコトができなかったので, しぶしぶ乗り換えた次第. ご迷惑をおかけしております.

バックグラウンド側

バックグラウンドはすべてErlangで書いています. 動作自体は各ユーザーさんのフォロワーを取得して, 例のスパムがいたらブロックするだけです.

ちなみにボクのマシンで動作させています. 当然Web側のデータベースともやり取りを行わなければいけない(ユーザーさんの取得, ブロック情報の更新)ので, その間はSSHを介してごちゃごちゃやっています*1.

ハマったところ

Web側は特に問題もないのですが, クローラなどのバックグラウンドで動作するやつを書いたコトがないので, 紆余曲折ありました.

最初の作りがあまく, 200ユーザー数を超えたあたりでとあるプロセスがヒープ領域を大量に消費してしまったり, プロセスの死亡をトラップせず, それが影響して全体が死んだり…… ユーザー数も予想外の増加数だったのでけっこう焦ったー. 公開して1日経たずにバックグラウンドのプログラムを書き換えるはめになりました.

学んだコトは:

  • いくらErlangのプロセスが軽いとは言え, そのプロセスに引き連れている値(loop(A, B, C)など)によっては考えモノ
  • メモリ的には問題なくても, 他にボトルネックとなる部分がある. 今回はTwitter APIへのリクエスト. 並列数多すぎてタイムアウトが頻発してた

とかでしょうか. 現在は並列数を最大でも20程度に抑え, プロセスも逐次spawn_linkするようにしてあります.

とりあえず

うっとうしくて, でも無視できなくて, 毎回手動で対処しているという方は一度どうでしょうかー.

*1:本当は同じネットワークにおいて, MySQLのデータベースと直接やりとりしたひ……

くるくる回して入力するUIを作ってみた

とある範囲の値を入力してもらいたいけど, テキストボックスじゃ自由度高すぎるし, 範囲が微妙に広いからコンボボックスじゃ選ぶのめんどいなー*1.

そんなコトを考えているときに「こんなのどうよ?」と思って作ってみた.

とりあえずこんな風になった

このページを開いて, 青いボックスの上でマウスのボタンを押しっぱなしにすると, ちっこい矢印がでてくる. その状態で, その矢印を中心に:

  • 時計回りにドラッグするとカウントアップ
  • 半時計回りにドラッグするとカウントダウン

します.

欠点

  • 入力の仕方がわかりにくい*2
  • 入力範囲が広すぎると何回もくるくるしなくちゃいけないのでめんどくさい

とりあえず意外におもしろい動きなんじゃないかと思います. iPodのUI? 知らないお( ^ω^)*3

あ, もし使いたい方はこちらからJavaScriptのソースを取得してもらえればいいかと思います. ちなみにjQueryに依存しているのでご了承を.

あと例に使ったソースはこちらです.

*1:下三角クリックして, スクロールして, 目的の項目をクリックするっていう手順がなんか煩雑だと思うのです

*2:センターとなるマーカーを工夫すればいいと思うけど

*3:作っている途中で「iPodのような操作感だなー」と思いましたけど

とりあえずRackのミドルウェアとして実装すればいいんじゃない?

最近Rackの上にごくごく簡単なフレームワークSinatraも使わず組んでいるんですが, ミドルウェアが便利で, 仕組みもごくごく単純でステキだなぁとか思っているからとりあえずメモしておこうと思って久しぶりにブログを書こうとふにゃらららら.

仕組みから言えばフィルターとして動きます. たとえば:

use Rack::ShowExceptions
use Rack::Lint
run ExampleApp.new

ってミドルウェアを積むと, Rack::ShowExceptions#call(env) -> Rack::Lint#call(env) -> ExampleApp#call(env)と順番に呼ばれます. ただチェインしているだけですね. 素直な実装でボクうれしい.

つーコトでオプション的な機能は引数のoptionsとか設けて対応するんじゃなくて, Rackのミドルウェアに任せれば済むんじゃないか. 素直な実装というコトで, 簡単に作れました. 試しに行頭と行末のホワイトスペースおよび改行を削除するミドルウェアを書いてみた.

require 'rubygems'
require 'rack'

class Strip
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call env
    body, content_length = strip body
    headers['Content-Length'] = content_length.to_s
    [status, headers, body]
  end

  def strip(body)
    content_length = 0
    body.each do |s|
      s.gsub! /^\s+|\s+$|[\r\n]/m, ''
      content_length += Rack::Utils.bytesize s
    end
    [body, content_length]
  end
end

あとはuse StripってしてあげればOK. 簡単だね.

ただミドルウェアを積む順番を意識するのはめんどうなので, @app.call(env)を必ず呼び出して, その結果を加工する程度のモノにした方がよさそうです. 試しにキャッシュ機能とか実装してみたんですけど, キャッシュがあった時点で後方のアプリケーションを呼び出さないので, 順番によって結果が変わる可能性があるからです.

とりあえずミドルウェアで実装しとけばいいんじゃない? って話でした. Sinatraにも使えるしね.

Rubyで実践 OAuth in Twitter

というコトで前回のエントリに引き続き, OAuthです. 今度はコードを書き, OAuthを用いてTwitterのユーザータイムラインを取得してみようと思います.

コードはGitHubの方に置いてみました. 初めてのGitHubわくわくです.

OAuth examples

ちなみにターミナルから起動します. GitHubの方にはREADMEすら置いていないのであしからず.

$ ruby examples/twitter.rb

以下手順を追っていきますが, コードを部分ずつにわけて載せています. 全部が見たい人はGitHubの方で見てみてください.

認証トークンを取得する

consumer = OAuth::Consumer.new CONSUMER_KEY, CONSUMER_SECRET, {
  :signature_method => OAuth::SignatureMethod::HMAC::SHA1
}

request_token = consumer.get_token REQUEST_TOKEN_URL

puts "Request token: #{request_token.token}"
puts "Request token secret: #{request_token.secret}"

まずコンシューマのキーと秘密の文字列でコンシューマオブジェクトを生成. Twitterは今のところ, signature_methodとしてHMAC-SHA1しか対応していないので, それも指定.

そしてOAuth::Consumer#get_tokenで認証トークンを取得.

Request token: REQUEST-TOKEN
Request token secret: REQUEST-TOKEN-SECRET

ユーザーを認証する

%x{open #{AUTHORIZE_URL % request_token.token}}
puts 'Press enter key after allowed.'
gets

最初に断っておきますと, openを使っているのでMac OS Xでしか動きません(たぶん). まぁAUTHORIZE_URL % request_token.tokenの結果を出力して, 手動でアクセスしてあげれば問題はないはず.

puts AUTHORIZE_URL % request_token.token
puts 'Press enter key after allowed.'
gets

このアクセスしたページでユーザーの認証と, リソースへのアクセス許可をもらう. Callback URLで指定したURLに飛ぶがそれは無視して, Enterキーを押す.

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

consumer.token = request_token
access_token = consumer.get_token ACCESS_TOKEN_URL

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

unless access_token.empty?
  puts 'Optional values:'
  access_token.each_pair {|key, value| puts "  #{key} => #{value}"}
end

さっき取得した認証トークン(request_token)をコンシューマにセット. 再度get_tokenを行い, 今度はアクセストークンを取得. ちなみにTwitterはこのとき付随的な情報として認証されたユーザーのuser_idとscreen_nameも返す. OAuthの仕様書を読んでいないが, こういうコトもできるという認識でOK?

Access token: ACCESS-TOKEN
Access token secret: ACCESS-TOKEN-SECRET
Optional values:
  user_id => 14976270
  screen_name => takkkun

サービスプロバイダ上のリソースにアクセスする

consumer.token = access_token
response = consumer.get 'http://twitter.com/statuses/user_timeline.json'

puts response.code
puts response.body

取得したアクセストークンをコンシューマにセットし, 任意のリソースにリクエストを送る. 今回はユーザータイムラインを取得.

200
[{"text":"\u3055\u3063\u304d\u304b\u3089\u4e0b\u307e\u3076\u305f\u306b\u76ee\u85ac\u3057\u3066\u308b", ~

ちなみにアクセストークンとシークレットを保存しておけば, 次から認証トークンの取得からアクセストークンの取得のステップを飛ばして, いきなりリソースへのアクセスを行うコトができる.

consumer = OAuth::Consumer.new CONSUMER_KEY, CONSUMER_SECRET, {
  :signature_method => OAuth::SignatureMethod::HMAC::SHA1,
  :token            => OAuth::Token.new('ACCESS-TOKEN', 'ACCESS-TOKEN-SECRET')
}
response = consumer.get 'http://twitter.com/statuses/user_timeline.json'

puts response.code
puts response.body

出力は上記と同じ.

取れました

というコトでOAuthによるリソースの取得ができました. 手順を追うといっても上っ面だけなので, 中で何をして, どんなリクエストを送っているかはoauth.rbでも見ていただければわかると思います.

さすがにいろいろ読んだし, コードも書いたので, 最初の流し読みのときよりもかなり理解が深まったかなー. 

OAuthを知る

どうやらOAuthというモノがあるらしい. 仕組みを考えだしたのはTwitterの中の人だとか.

そのOAuthについて理解を深めるために, 粛々とつづってみる. 間違っていたら突っ込んでいただけると嬉しいです.

OAuthの目的

OAuthは認証と認可を目的とする. OpenIDは認証だけなので, そこがOAuthとの違い.

この認可とは, 特定のコンシューマが, 認証されたユーザーの同意によって, サービスプロバイダ上にあるリソースへアクセスできるようになる, というモノ.

OAuthの登場人物

サービスプロバイダさん

ユーザーの認証と, リソースへ対するコンシューマからのアクセスを認可するサービスのコト. OpenIDでいうOpenID Providerの立場.

くだけて言うとOAuthサーバーみたいな感じ. 例えばTwitterとか.

コンシューマさん

リソースへのアクセスを, ユーザーの同意の上で行うサービスのコト. サービスと書いたが, 別にデスクトップアプリでもいい. OpenIDでいうRelying Partyの立場.

くだけて言うとOAuthクライアントみたいな感じ. 例えばTwitter用のデスクトップで動作するクライアントとか.

ユーザーさん

コンシューマから「ちょっとサービスプロバイダ上のリソースにアクセスしたいので, 許可してもらえるかな?」と言われる人.

OAuthの仕組み

リソースへのアクセスは認可もそうだが, まず認証されていなければならないので, たいていIDとパスワードが必要となる. そこをOAuthはトークンと言うランダムな文字列で代用するようにして, リソースへのアクセスを認めている, って感じ. このトークンは2種類ある.

認証トーク

まず「このURLにアクセスして, リソースにアクセスするのを許可してもらえるかな?」という時に必要となる認証トークン(request token). これは簡単に取得できる.

ちなみにこのタイミングでサービスプロバイダ側がログイン画面を用意するので, そこでユーザーにIDなりパスワードなりを入力してもらって, 認証を行う. 実際試してみるまでどこで認証がされるかがわかりづらかった……

そしてリソースへのアクセスの許可/拒否もしてもらう. 認証トークンの出番はこれだけ.

アクセストーク

次にリソースへのアクセスに必要となるアクセストークン(access token). この認証トークンとアクセストークンはユーザーの認証が済んで, さらに許可してもらった後なら取得できる(イメージ的には認証トークンと交換するイメージ).

そしてこのアクセストークンは何度でも使う. リソースへのアクセスに認可を下すには, アクセストークンが妥当かどうかで判断している. よって一度認可をもらえばリソースへのアクセスはツーツーというわけではなく, アクセストークンを毎回含め, 毎回認可してもらう必要がある.

OAuthを使うための準備

まずサービスプロバイダに「こういうコンシューマを作るので, お願いします」という登録が必要. それでコンシューマのキーと秘密の文字列を貰う. 秘密の文字列は署名(Signature)を生成するのに必要で, キー自体は認証トークンをもらうときも, アクセストークンを貰うときも, それ以後のアクセスを行うときも……, すなわちすべてのリクエストに含めなきゃいけない. でないと, どのコンシューマからのアクセスかわからないから.

というわけで, Twitterでコンシューマを登録してみる. まずhttp://twitter.com/oauth_clientsにアクセス.

"Register a new application »"のリンクをクリックして, コンシューマの情報を入力する.

特に重要なのがCallback URL. ユーザーの認証が済んだ後, ここに記入したURLにリダイレクトされるようになっている. なので大体はアクセストークンの取得を行うURLを書くコトになると思う.

そして登録したコンシューマの情報が記載されているページ.

Consumer keyがコンシューマのキー. Consumer secretが秘密の文字列となる. あと認証トークンとアクセストークンを取得するさいに用いるURL(Request token URL, Access token URL)と, ユーザーの認証およびリソースへのアクセス許可をしてもらうためのURL(Authorize URL)も書かれている.

これらの情報でサービスプロバイダとやりとりできる. ひとまずコンシューマの登録は完了したので, 準備はコレでおしまい.

次からコードを書いて実践してみる.

Rubyで実践 OAuth in Twitter