理系学生日記

おまえはいつまで学生気分なのか

Read It Later Pro から Twitter に投稿したとき、はてブに自動で登録するスクリプト作った

これによって、Livedoor Reader で Pin を立てると Read It Later に自動転送され、iPhone/iPad 上の Read It Later で Twitter に呟くと、それが自動ではてブに登録され、さらに Evernote に登録されるというワークフローが確立しました!!
今回作成したのは表題の通り、Twitter→はてブの部分になります。他の部分については、以下で実現しています。

スクリプトを作成した動機

通勤が 3G の届かない東京メトロということもあり、ぼくは気になったフィードを全て iPhone 上の Read It Later に転送しておくことを習慣化しています。これにより、オフラインになっても常にフィード情報はぼくとともにあり、完全なるヒマ潰しができます。

しかし、Read It Later で読んだ記事をはてブに登録したいと思っても、以下 2 点の問題がありました。

  • 3G が届かない場合は、届く場所(オンライン環境)に着くまでどうしようもない
  • Read It Later は、当然ながらはてブ登録機能を持っていないため、iPhone ではてブに登録しようと思うとかなり面倒

結果、Read It Later 上で読んだ記事であってもその場では既読にできず(既読にすると、「後ではてブに登録する記事」という管理がしづらい)、家に返って Web ベースの Read It Later を開き、Firefox のはてブ拡張で登録していくというストレスの溜まる作業をする他ありませんでした。

ここでふと思ったのが、Read It Later Pro の Tweet 機能を利用すれば良いのではないかということです。
Read It Later Pro は読んでいる記事を Twitter 上にツイートする機能を持っており、ここには短縮 URL の情報が自動で付加されます。従って、このツイートを拾い、短縮 URL を伸長し、はてブに登録するスクリプトさえあれば、上記のメンドくささが解消されることになるのです。

スクリプト

App::TweetHB というモジュールを作って、基本的な処理はそちらで実施することにしています。そのため、スクリプト自体はかなり小さいものになりました。

事前に必要なものは、Read It Later と Twitter のアカウント、そして Bit.ly の API Key です。
Bit.ly が必要なのは、Read It Later で呟く際の短縮 URL サービスが Bit.ly であり、この伸長には Bit.ly の API を叩く必要があるためです。

use strict;
use warnings;
use Config::Pit;
use App::TweetHB;

my $bitly   = Config::Pit::get('bit.ly');
my $hatebu  = Config::Pit::get('hatena.ne.jp');
my $twitter = Config::Pit::get('tweethb');

my $app = App::TweetHB->new(
    accounts => {
	bitly	=> $bitly,
	hatebu  => $hatebu,
	twitter => $twitter,
    },
    logconf => 'conf/log4p.properties',
    prev_tweet_info => './data/prev.json',
);

foreach my $tweet ( $app->retrieve_my_tweets ) {
    my $parsed = $app->parse( $tweet );
    $app->register_bookmarks( $parsed );
}
$app->update_prev_info;

モジュール

App::TweetHB は以下のようなものとなり、とりあえず動くという状態まで持っていっています。
Bitly での URL 伸長には WebService::Bitly (参照)を、はてブ登録には WebService::Hatena::Bookmark::Lite を、Twitter からのツイート取得には、Net::Twitter::Lite を使っています。こういうのが予め揃っているのが Perl の好きなところです。
他、久しぶりに Log4perl を使いましたが、使い方完全に忘れていました…。

package App::TweetHB;
use strict;
use warnings;
use Carp;
use WebService::Bitly;
use WebService::Hatena::Bookmark::Lite;
use Net::Twitter::Lite;
use Try::Tiny;
use Regexp::Common qw/URI/;
use Log::Log4perl;
use Encode;
use YAML qw/LoadFile DumpFile/;

our $VERSION = '0.01';

# maximum number of records retrieved from twitter
my $TWITTER_MAXIMUM_TWEET = 200;

my $encoding = find_encoding('utf8');

sub new {
    my ($class, %args) = @_;

    my $self = bless {}, $class;

    unless ( $self->_validate_args(%args) ) {
	$self->error("provided arguments are not sufficient.");
	return;
    }

    if ( $args{logconf} ) {
	Log::Log4perl->init($args{logconf});
	$self->info("initializes Log4perl with $args{logconf}");
    }
    $self->{accounts}        = $args{accounts};
    $self->{prev_tweet_info} = $args{prev_tweet_info};
    $self->{data} = (-f $self->{prev_tweet_info} && -r _)?
	LoadFile($self->{prev_tweet_info}) : { twitter => {} };

    # account settings
    $self->_twitter_settings( $self->{accounts}->{twitter} );
    $self->_bitly_settings(   $self->{accounts}->{bitly}   );
    $self->_hatebu_settings(  $self->{accounts}->{hatebu}  );

    $self->info("account settings complete");

    return $self;
}

sub update_prev_info {
    my ($self) = @_;

    try {
	DumpFile($self->{prev_tweet_info}, $self->{data});
    }
    catch {
	$self->error("updating previous tweet info: ", $_ );
    }
}

sub _twitter_settings {
    my ($self, $conf) = @_;

    my $nt = Net::Twitter::Lite->new(
	consumer_key    => $conf->{consumer_key},
	consumer_secret => $conf->{consumer_secret}
    );
    $nt->access_token($conf->{access_token});
    $nt->access_token_secret($conf->{access_token_secret});
    $self->{twitter} = $nt;
}

sub _bitly_settings {
    my ($self, $conf) = @_;

    $self->{bitly} = WebService::Bitly->new(
	user_name    => $conf->{user},
	user_api_key => $conf->{pass},
    );
}

sub _hatebu_settings {
    my ($self, $conf) = @_;

    $self->{hatebu} = WebService::Hatena::Bookmark::Lite->new(
	username => $conf->{user},
	password => $conf->{pass},
    );
}

sub parse {
    my ($self, $tweet) = @_;

    if ( $tweet->{text} =~ s/$RE{URI}{HTTP}{-scheme => qr!https?!}{-keep}$// ) {
	my $uri = $1;

	my $expand = $self->{bitly}->expand(short_urls => [$uri]);
	if ( $expand->is_error ) {
	    $self->error("error in expanding $uri: " . $expand->is_error);
	    return;
	}

	my $longurl = $expand->results->[0]->long_url;
	$self->debug("$uri is expanded to ", $longurl, "." );
	return {
	    uri	  => $longurl,
	    title => $tweet->{text}
	};
    }
    return;
}

sub retrieve_my_tweets {
    my ($self) = @_;

    my $name = $self->{accounts}->{twitter}->{user};
    my @tweets = $self->retrieve_tweets;

    my @mytweets = grep { $_->{user} eq $name } @tweets;
    $self->info( "my ", scalar(@mytweets), " tweets are retrieved.");
    @mytweets;
}

sub retrieve_tweets {
    my ($self) = @_;

    my @tweets;
    try {
	my $data = $self->{data}->{twitter};
	$self->debug("retrieving tweets: since_id=", $data->{since_id});

	my $statuses = $self->{twitter}->friends_timeline({
	    $data->{since_id}? (since_id => $data->{since_id}) : (),
	    count => $TWITTER_MAXIMUM_TWEET,
	});
	$self->info(scalar(@$statuses), " tweets are retrieved.");

	@tweets = map { +{
	    text => $_->{text},
	    user => $encoding->decode($_->{user}->{screen_name}),
	    id   => $_->{id},
	}} sort { $a->{id} <=> $b->{id} } @$statuses;

	if ( @tweets > 0 ) {
	    my $last_id = $tweets[-1]->{id};

	    $self->{data}->{twitter}->{since_id} = $last_id;
	    $self->debug("last tweet id is ", $last_id);
	}
    }

    catch {
	$self->error("retrieve_tweets: " . $_);
    };

    @tweets;
}

sub register_bookmarks {
    my ($self, $info) = @_;

    try {
	$self->{hatebu}->add(
	    url => $info->{uri},
	    tag => ['untagged'],
	);
	$self->info( "bookmarked: [" . $encoding->encode($info->{title}) . "]" );
    }
    catch {
	$self->error( "error in bookmarking " . $encoding->encode($info->{title}) . ": " . $_ );
    };
}

sub _validate_args {
    my ($self, %args) = @_;
    # TODO: validation for twitter

    # validation check for accounts information
    my $accounts = $args{accounts};
    unless ( $accounts ) {
	$self->warn("accounts is required");
	return 0;
    }
    for my $service (qw/bitly hatebu/) {
	unless( $accounts->{$service} ) {
	    $self->warn("accounts/$service is required.");
	    return 0;
	}
	for my $key (qw/user pass/) {
	    unless ( $accounts->{$service}->{$key} ) {
		$self->warn("accounts/$service/$key is required");
		return 0;
	    }
	}
    }

    unless ($args{prev_tweet_info}) {
	$self->warn("prev_tweet_info is required");
	return 0;
    }
    return 1;
};

sub error { shift; my $logger = Log::Log4perl->get_logger(); $logger->error(@_); }
sub warn  { shift; my $logger = Log::Log4perl->get_logger(); $logger->warn(@_); }
sub info  { shift; my $logger = Log::Log4perl->get_logger(); $logger->info(@_); }
sub debug { shift; my $logger = Log::Log4perl->get_logger(); $logger->debug(@_); }

1;