AnyEvent::Twitterの使い方

Terminal上で動作するTwitter閲覧ツール「Twiterm」を作った - すぎゃーんメモにて、初めてAnyEvent::Twitterというモジュールを使ってみたのだけど、使い方を激しく勘違いしていたので、正しい使い方をメモっておく。
AnyEvent::Twitter - search.cpan.org

バージョン

2009年9月末時点では最新は0.26。

最も簡単なサンプル

use strict;
use warnings;

use AnyEvent;
use AnyEvent::Twitter;

# ユーザー名、パスワードを引数から取得
my ($username, $password) = @ARGV;
my $twitty = AnyEvent::Twitter->new(
    username => $username,
    password => $password,
);

# コールバックの登録
$twitty->reg_cb(
    statuses_friends => sub {
        my ($twitty, @statuses) = @_;
        # とりあえず取得で来たstatusの数だけ表示
        print scalar @statuses, "\n";
    },
);

# 観測開始
$twitty->receive_statuses_friends;
$twitty->start;


# プログラムが終了しないよう別のイベントループを回す
my $cv = AnyEvent->condvar;
my $w; $w = AnyEvent->timer(
    after    => 0,
    interval => 5,
    cb       => sub {
        print AnyEvent->time, "\n";
    },
);
$cv->recv;

実行結果は以下のようになる。

$ perl twitty.pl sugyan ********
1254348322.7404
198
1254348327.75838
1254348332.76877
1254348337.77918
1254348342.78943
1254348347.79971
1
1254348352.80151
1254348357.81191
1254348362.82232
1254348367.83263
1254348372.84291
2
1254348377.85351
1254348382.8638
1254348387.87423
1254348392.8845
1254348397.89477
2
1254348402.9019
1254348407.91221
1254348412.92259
1254348417.93377
1254348422.94404
1254348427.95658
1
1254348432.96256
1254348437.97293

タイマーは5秒毎に時間を表示し、それと並行してAnyEvent::Twitter定期的にfriends_timelineを取得し、新たに取得できた件数を表示している。
(1回きりで200件を取ってくるものだと思っていた…orz)

statuses_friendsコールバック

ここで渡されてくる引数、1番目は動いているAnyEvent::Twitterインスタンスそのまま。では2番目(以降)の@statusesに渡ってくるのは何か?
reg_cvでのstatuses_friendsのコールバックをちょっと変更してみる。

# コールバックの登録
$twitty->reg_cb(
    statuses_friends => sub {
        my ($twitty, @statuses) = @_;
        if (@statuses > 0) {
            my $status = shift @statuses;
            print "@$status\n";
            print join(',', keys %{$status->[0]}), "\n";
            print join(',', keys %{$status->[1]}), "\n";
        }
    },
);
$ perl twitty.pl sugyan ********
1254351016.40435
1254351021.41007
HASH(0x948f30) HASH(0x90c7c0)
timestamp,text,screen_name
source,truncated,favorited,created_at,text,user,in_reply_to_user_id,id,in_reply_to_status_id,in_reply_to_screen_name
1254351026.42057

このへんはpodにしっかり書いてあるのでドキュメントを読めば分かるのだけど、

  • @statusesは各statusに対応するARRAYリファレンスを格納した配列
    • 各要素は2つのHASHリファレンスを格納したARRAYリファレンス
      • 1つ目は各statusのtimestamp, text, screen_nameのみを内部で抽出したもの
      • 2つ目は抽出する前の、APIから取得してPerlのハッシュに格納した生のデータ

という構造になっている。

state

ところで上記のstatuses_friends、1回目はガツっと200件ほど取ってくるけど、2回目以降は新たに取得できたものだけが@statusesに入ってくる。更新がなければ空の配列になる。
どこでそういう制御をしているかというと、内部でstateというものを持っていて、それを使っている。
またstatuses_friendsを変更してみる。

# コールバックの登録
$twitty->reg_cb(
    statuses_friends => sub {
        my ($twitty, @statuses) = @_;
        print scalar @statuses, "\n";
        if (@statuses > 0) {
            print $statuses[0]->[1]->{id}, "\n";
        }
        use YAML;
        print Dump $twitty->state;
    },
);
$ perl twitty.pl sugyan ********
1254351935.7121
200
4509801997
---
statuses:
  friends:
    id: 4509801997
1254351940.72949
1254351945.73973
1254351950.74998
1254351955.75037
1254351960.76067
5
4509812919
---
statuses:
  friends:
    id: 4509812919
1254351965.77954
1254351970.79002
1254351975.79041
1254351980.80073
1254351985.81098
3
4509820045
---
statuses:
  friends:
    id: 4509820045
1254351990.82985
1254351995.84023

このように、取得できた@statusesの先頭要素(つまり最新のstatus)のidが常にstateに記録されている。
内部ではAPIからfriends_timelineを取得する際にこのid以降のものを取得するよう指定しているので、更新分だけがコールバックに渡されるようになる。
またこのstateは初期化時に引数で渡すことができ、例えば処理を中断する際に、中断前にstateを保存しておくと、それを使って新しくAnyEvent::Twitterインスタンスを作ってやれば中断前の続きからstatusを取得することが出来るようになる。

my $twitty = AnyEvent::Twitter->new(
    username => $username,
    password => $password,
    state    => {
        statuses => {
            friends => {
                id => 4509820045,
            },
        },
    },
);

このように指定してやると、最初にいきなり200件とってくることもなく、指定したid以降のものだけを取ってくることになる。

その他のコールバック

reg_cbにはもう2つコールバックを登録できる。

error

エラーが起こったときの処理を登録できる。

# コールバックの登録
my $watcher; $watcher = $twitty->reg_cb(
    error => sub {
        my ($twitty, $error) = @_;
        print "Error: $error\n";
        undef $watcher;
    },
);
$ perl twitty.pl sugyan hogefugapiyo
1254354336.81342
Error: error while fetching statuses for friends: 401 Unauthorized
1254354341.82181
1254354346.83121

認証エラーの場合、errorが何度も呼ばれてしまうので$watcherをundefして二度と呼ばれないようにしている。

next_request_in

TwitterAPIには制限があり、1時間に150回までしかstatusの取得などはできない。1時間ごとに制限の値はリセットされる。
next_request_inでは、次にリセットされるタイミングまでの時間、それまでの間のAPIの使用可能回数、そしてそれらから算出した次回実行までの時間(後述)を受け取る。

# コールバックの登録
my $watcher; $watcher = $twitty->reg_cb(
    next_request_in => sub {
        my $self = shift;
        my ($seconds, $remaining_request, $remaining_time) = @_;
        print "seconds           : $seconds\n";
        print "remaining_time    : $remaining_time\n";
        print "remaining_request : $remaining_request\n";
    },
);
$ perl twitty.pl sugyan ********
1254355336.83357
seconds           : 23.2765011119348
remaining_time    : 3140
remaining_request : 142
1254355341.84632
1254355346.85656
1254355351.86685
1254355356.87708
1254355361.88741
seconds           : 23.2624113475177
remaining_time    : 3116
remaining_request : 141
1254355366.89266
1254355371.90285
1254355376.91329
1254355381.92353
1254355386.93091
seconds           : 23.2406015037594
remaining_time    : 3091
remaining_request : 140
1254355391.94673

だいたい、status_friendsなど取得したときのコールバックが実行された後に呼ばれることになる。取得したstatusに対する処理はそちらで書いて、実行タイミングの調整(後述)などはこのコールバック内で行うのが良いと思う。

タイムラインの取得間隔

statusesを繰り返し取得する際、その更新間隔を内部でどうやって決定しているのか。
AnyEvent::Twitterは、初期化時に引数で"bandwidth"というパラメータを渡せる。これは、APIの回数制限に対しどれだけの頻度でアクセスを行うか、を決める値になる。
例:

my $twitty = AnyEvent::Twitter->new(
    username  => $username,
    password  => $password,
    bandwidth => 0.5,
);

たとえば普通にfriends_timelineの取得を繰り返し行うだけ、という場合は3600秒間に150回使用できるので最短24秒間隔でアクセスすることができる。しかし、他のクライアントからも同時にAPIを使用している、といった場合、このプログラムだけで許容量を使い切ってしまうわけにはいかない。それを調整するための値がこの"bandwidth"。これを使って、次の取得までの間隔を(remaining_time / remaining_request) / bandwidth という式で決定する。これを1.0にすれば150回/時間の制限を使い切ることになるし、1未満の数字にすれば時間間隔はそれより長くなりAPI制限に対して余裕ができるようになる。いちおう1以上の値を設定することもできる。何も指定しなかった場合はデフォルトで0.95に設定されるらしい。
ちなみに「xx秒間隔で取得したい」という場合は、podのnewメソッドの引数としては明記されていないけれど"interval"で秒数を指定すればbandwidthより優先されてその時間間隔で取得できるようになる。

my $twitty = AnyEvent::Twitter->new(
    username => $username,
    password => $password,
    interval => 5,
);

例えばこうすると必ず5秒間隔で取得しにいくようになる。あっという間にAPI使用制限に達してしまうだろうけどw また、微妙にundocumentedなカンジなのであんまり使わない方がいいのかも?

friends_timeline以外に取得できるもの

AnyEvent::Twitterで取得できるのはfriends_timelineだけでなく、mentionsも取得できる。podには

friends
public (currently unimplemented)
user (currently unimplemented)
mentions

と書いてあり、public_timeline, user_timelineに関してはまだ未実装のため取得できない。まぁ仕組み的にそんなに他とそんなに変わらないはずなのでサクッとパッチを書いてあててやれば使えるようになる気がするけど。

friends_timelineとmentionsを並行して取得する

取得イベントとして登録しておけば、friends_timelineとmentionsを1つのインスタンスから取得できる。

use strict;
use warnings;

use AnyEvent;
use AnyEvent::Twitter;

# ユーザー名、パスワードを引数から取得
my ($username, $password) = @ARGV;
my $twitty = AnyEvent::Twitter->new(
    username  => $username,
    password  => $password,
    bandwidth => 0.8,
);

# コールバックの登録
$twitty->reg_cb(
    statuses_friends  => sub {
        my ($twitty, @statuses) = @_;
        print "friends: ", scalar @statuses, "\n";
    },
    statuses_mentions => sub {
        my ($twitty, @statuses) = @_;
        print "mentions: ", scalar @statuses, "\n";
    },
    next_request_in   => sub {
        my $self = shift;
        my ($seconds, $remaining_request, $remaining_time) = @_;
        print "seconds           : $seconds\n";
        print "remaining_time    : $remaining_time\n";
        print "remaining_request : $remaining_request\n";
    },
);

# 観測開始
$twitty->receive_statuses_friends;
$twitty->receive_statuses_mentions;
$twitty->start;


# プログラムが終了しないよう別のイベントループを回す
my $cv = AnyEvent->condvar;
my $w; $w = AnyEvent->timer(
    after    => 0,
    interval => 5,
    cb       => sub {
        print AnyEvent->time, "\n";
    },
);
$cv->recv;

単純に$twitty->receive_statuses_mentionsとそれを受けるコールバックを追加しただけ。

$ perl twitty.pl sugyan ********
1254364086.29058
1254364091.28648
mentions: 199
seconds           : 19.8586956521739
remaining_time    : 1827
remaining_request : 115
1254364096.28652
1254364101.28655
1254364106.28656
1254364111.28656
friends: 199
seconds           : 19.7916666666667
remaining_time    : 1805
remaining_request : 114
1254364116.28651
1254364121.28656
1254364126.28656
1254364131.28656
mentions: 0
seconds           : 19.921875
remaining_time    : 1785
remaining_request : 112
1254364136.28652
1254364141.28656
1254364146.28656
1254364151.28656
friends: 1
seconds           : 19.8761261261261
remaining_time    : 1765
remaining_request : 111
1254364156.28649

friends_timelineとmentionsが交互に取得されているのが分かる。

取得するタイムラインの頻度を決める重み付け

「フォロワーたくさんいるからfriends_timelineは頻繁に見たいけど@は滅多に飛んでこないからmentionsはそんなに何度も見にいかなくていい」という場合もあるかもしれない。
そんなときは、receive_status_... に引数として重み(weight)をそれぞれ渡してやれば良い。
これを指定すると、それぞれの重みに応じてAPIから取得するタイムラインを調整してくれる。例えばfriends_timeline:mentinsを3:1に設定してやると、

$twitty->receive_statuses_friends(3);
$twitty->receive_statuses_mentions(1);
$twitty->start;
$ perl twitty.pl sugyan ********
1254364955.25351
friends: 200
seconds           : 12.7925531914894
remaining_time    : 962
remaining_request : 94
1254364960.24945
1254364965.24947
1254364970.24946
friends: 0
seconds           : 12.755376344086
remaining_time    : 949
remaining_request : 93
1254364975.24946
1254364980.24948
1254364985.24939
mentions: 199
seconds           : 12.6766304347826
remaining_time    : 933
remaining_request : 92
1254364990.24945
1254364995.24946
friends: 0
seconds           : 12.6373626373626
remaining_time    : 920
remaining_request : 91
1254365000.2494

このように4回中3回はfriends_timelineを、1回だけmentionsを取得するようになる。API制限はどちらも合わせて150回/時間までなので、取得する時間間隔に関しては前述の仕組みの通りになる。

投稿(status_update)

ここまでのstatusesの取得とは別に、指定した文字列でstatusをupdateすることもできる。これもイベント駆動なインターフェースなので他の処理をブロックすることなく、投稿に成功したときにコールバックサブルーチンが呼ばれることになる。
前述までのサンプルではAnyEvent::Twitterの処理と別にAnyEventのタイマーを回していたが、これを以下のように書き換えてみる。

# プログラムが終了しないよう別のイベントループを回す
my $cv = AnyEvent->condvar;
my $w; $w = AnyEvent->io(
    fh   => \*STDIN,
    poll => 'r',
    cb   => sub {
        chomp(my $input = <STDIN>);
        use Encode qw/decode_utf8 encode_utf8/;
        $twitty->update_status(decode_utf8($input), sub {
            my ($twitty, $status, $js, $error) = @_;
            print "update!\n";
            print "status : ", encode_utf8($status), "\n";
            print "error  : $error\n" if defined $error;
        });
    },
);
$cv->recv;

どうやらdecode_utf8してからじゃないと日本語が化けるっぽい。
こうすると、タイマーは回らずに標準入力からの入力待ちになり(タイムラインの取得は前述までと同様に回っている)、何か文字を入力するとその文字列をTwitterに投稿する。
上記の例の場合イベントループは終了しないので入力するたびに投稿されることになる。

閲覧/投稿ができる簡単Twitterクライアント

ここまでの機能を理解していれば、自動でタイムライン(friends_timeline, mentions両方)を定期的に取得しては出力し、文字を入力すると投稿する、という簡単なターミナル向けクライアントを作成できる。

ここだけgist使うw

その他

現時点ではOAuthではなくBASIC認証を使ったAPI操作になっている。いずれ変更される…のか?
friends_timeline, mentions以外のAPIへの対応も期待したいところ。


このAnyEvent::Twitterのソースを参考にちょいと作り込めばAnyEvent::Wassrも作れるんじゃないかと思っている。