新しい MacBook Air がほしいですね

MacBook Air 11インチ欲しい!

金がないので MacBook という名前になった最初の白いやつを長年使ってるんですが、一度ファンを交換したにもかかわらずまたファンの音がおかしくなってきています。もう一度交換すればいいんじゃないのという感じもするけど、
http://gameport.ocnk.net/product/134
を見ると6000円。5年間使ったマシンのパームレストが割れるリスクと6000円を投じて静音化とかちょっと考えたくないです。

あと電池も交換しました。これは非純正にもかかわらず明らかに金型が同じものを円高に物を言わせて DealExtreme.com で $48 程度で購入しました。DealExtreme.com は発送が遅い以外は大変安く送料もだいたい無料だし PayPal も使えて安心便利なので、ゴミを購入するときにはぜひ使うといいと思います。ただこの電池、普通につかってるだけで突然電源が落ちたりするし、微妙につくりがわるくそのうち外れそうです。8ヶ月で壊れたという残念なレビューもありましたし、今みるとちょっと容量のでかいやつが $38.90 で売ってるし大変ファックですね。

ここまでして今の MacBook を使ってるのは金のこともありますが、tumblr ステッカーがもったいないというのもあるし新しい tumblr ステッカーもほしいですね。Tumblr ステッカー欲しい!

fuba_recorderについて


経緯

  • 2008年秋 friioを使った録画ツール
  • 2009年1月 twitterを使って録画予約したい!と思い制作
  • 無茶振りを採用しまくって片っ端から機能追加

開発ポリシー

  • アンチ人工知能
    • 要求の分類はすべてパターンマッチで
    • 会話はステートレスに
    • 用意された発言しかしない
  • 高速開発
    • タイムラインをみて要求パターンどんどん採用
    • 新機能は忘れられないうちに実装
    • テストせずに即運用

キャラ付けポリシー

  • 信頼できる相方として、基本的に想定の範囲内の返答を返す
    • 話しかけられないと話さない
  • たまに頭がおかしくなる
    • 検索してきた情報の意外性
    • text converter
    • バグ
    • 2分以内に要望に対応して機能追加

fuba_recorderの基本動作

  • 1つの要求に対して、1つの回答を返す
    • ボクサー
    • 木之本桜
    • おやすみ
    • レシピ提案
    • HDD残量通知
    • 録画予約
      • 番組録画
      • 録画要約
    • 番組提案
  • 自動follow

ひとつの要求が処理されるまでの流れ

  • タイムラインのクロール
  • tweetを各要求タイプに分類
  • 要求タイプに応じて返信を生成
  • 返信のpost
    • text converter

タイムラインのクロール

  • Perl Net::Twitterモジュール
  • 2分に1回、cronで。
  • friend_timelineとtimelineから、自分への要求であろうtweetに対して返信する。
my @msgs = reverse (@{$twit->replies}, @{$twit->friends_timeline});
自分への要求かどうかを判別
    for my $nickname (@nicknames) {
        if (($text =~ s/^\@$nickname//) || (
            ($msg->{in_reply_to_screen_name})
            && ($msg->{in_reply_to_screen_name} eq $nickname)
        )) {
            return $text;
        }
    }
my @nicknames = qw/
    fuba_recorder
    fuba_recoder
    fuba_recoder
    fuba_ピカァァッ
    フバコレ フバコレ
    フバレコ フバレコ
    プパペポ
    フバレコたん フバレコちゃん
    フコレバ フコレバ
    フコバレ フコバレ
    フレコバ フレコバ
    フレバコ フレバコ
    フヴァレコ フヴァレコ
    ふゔゃれこ ふびゃれきょ
    フバ様のマシーン
    トウモロコシ小麦粉レコーダー
    フバリコーダー
    fuba_recorder
/;

要求タイプの分類

  • 全部パターンマッチで
reply、かつ録画の要求の場合
        if ($text =~ /^(.*?)(?:毎回|全部)(?:録画|予約|(?:録|と)っといて)/) {
            $keyword = $1;
            $is_repeat = 1;
        }
        elsif ($text =~ /^(.*?)(?:(?:(?:なんとか)お願い)|(?:の|が|に)?(?:関する|出てる|録画|予約|(?:(?:で|出))?番組|(?:録|と)っといて))+/) {
            $keyword = $1;
        }
        elsif (($text !~ /(飲|の)みたい/) && ($text =~ /^(.*?)(?:いつやるの|(?:が|を)?(?:見|み|観)たい)/)) {
            $is_qa = 1;
            $keyword = $1;
        }
reply、かつ録画関連の要求でない場合
  • レシピ推薦
    if ($m =~ /(?:(?:なに|なん|何))?(.*)(?:が|を)?(?:(?:(?:飲|の)(?:み|ま)|(?:た|食)べさ?|(?:喰|食|く)(?:い|わ))(?:(?:い|く)|せ(?:ろ|て)))/) {
        if (my $str = $1) {
            if (server_avail($avail_dir, 'cookpad')) {
                my $recipe = retrieve_keyword_cookpad($str);
                if ($recipe) {
                    $new_message = join('、', @{$recipe->{ingredients}}).'とか買ってきて'.$recipe->{title}.'を作れ '.$recipe->{url};
                }
                else {
                    $new_message = '何か違うもの食べたほうがいいですよ';
                }
            }
            else {
                $new_message = '検索しすぎ';
            }
        }
    }
  • reply版ボクシング
    elsif (my $boxing = text_boxing(
        message => $m,
        ngwords => \@ngwords
    )) {
        $new_message = $boxing->{dump_message}; 
    }
  • おやすみ
    elsif ($m =~ /ペプ|行動を?開始|ねる|ねむ|ねて|眠|おやすみ|寝|バタリ|スヤ|ネルソン/) {
        $new_message = $goodnights[floor(rand(scalar(@goodnights)))];
    }
  • 黙る
    elsif ($m =~ /黙れ/) {
        $new_message = '...';
    }
  • ぜったいだいじょうぶだよ
    elsif ($m =~ /血が.*出る|失業|原君|痛い|進まない|ハァ|\\(^o^\)/|着る服が無い|あー|ねむい|鬱|ヘルプ|へるぷ|help|諦め|苦しい|つらい|希望がない|だめ|ダメ|駄目|死|しぬ|しにたい|自殺|たすけて|助けて|働きたくない|やだ|むり|無理|やめたい/) {
        $new_message = $cheerups[floor(rand(scalar(@cheerups)))];
    }
  • 謙遜
    elsif ($m =~ /おめでと|やればできる|でかした|(?:えら|偉)い|お(?:疲|つか)れ|すごい|いい(?:です)?ね|ありがと|thx|サンクス|サンキュー|thank/) {
        $new_message = $yourwelcomes[floor(rand(scalar(@yourwelcomes)))];
    }
  • reply回数制限の確認
    elsif ($m =~ /回|制限/) {
        my $avail = server_avail($avail_dir, 'twitter_'.$req->{user});
        $new_message = ($avail - 1);
    }
  • HDD残量あいまいに確認
    if (!$new_message) {
        if (int(rand(6))) {
            my $disk = disk_rest();
            if ($disk < 10) { # 10GB未満
                $new_message = $dies[floor(rand(scalar(@dies)))];
            }
            elsif ($disk < 40) { # 40GB未満
                $new_message = $noes[floor(rand(scalar(@noes)))];
            }
            else {
                $new_message = $yeses[floor(rand(scalar(@yeses)))];
            }
        }
    }
    
    $req->{dump_message} = $new_message;
    return $req;
}
replyじゃない場合
  • 非reply版ボクシング
  • just_do_quizのクイズにこたえる
    if ($req->{message} =~ /^\[quiz\]/) {
        if ($req->{message} =~ /\[\s+\]|次の選択肢/) {
            $req->{is_quiz} = 1;
            return $req;
        }
    }

返信の生成

録画関連
            if ($req->{is_quiz}) {
                post('@'.$req->{user}.' '.answer_quiz(map {$_->{message}} @quiz[0..1], $ua), $mid);
            }
            if ($req->{too_many}) {
                post('@'.$req->{user}.' 検索結果多すぎ、もうちょっと絞ってください', $mid);
            }
            elsif ($req->{is_forbidden}) {
                post('@'.$req->{user}.' そのiepg偽物っぽい', $mid);
            }
            elsif ($req->{has_collision}) {
                post('@'.$req->{user}.' '.$req->{collision_title}.'とかぶるのでむり', $mid);
            }
            elsif ($req->{is_expired}) {
                post('@'.$req->{user}.' そのiepg古い', $mid);
            }
            elsif ($req->{is_qa}) {
                if ($req->{url}) {
                    post('@'.$req->{user}.' '.$req->{keyword}.'、これはどうですか '.$req->{url}, $mid);
                }
                else {
                    post('@'.$req->{user}.' '.$req->{keyword}.'むり', $mid);
                }
            }
            elsif ($req->{is_reserved}) {
                if ($req->{keyword}) {
                    post('@'.$req->{user}.' '.$req->{keyword}.'、もう予約してる', $mid);
                }
                else {
                    post('@'.$req->{user}.' もう予約してる', $mid);
                }
            }
            elsif ($req->{is_repeat}) {
                if (grep {$req->{user} eq $_} @superusers) {
                    my $date = DateTime->from_epoch(epoch => $req->{repeat_expire});
                    my $hdate = $date->ymd('-');
                    post('@'.$req->{user}.' '.$req->{keyword}.'、'.$hdate.'まで全部録画します', $mid);
                    $refresh_repeat_flag = 1;
                }
                else {
                    post('@'.$req->{user}.' 金くれ', $mid);
                }
            }
            elsif ($req->{default_iepg}) {
                if ($req->{has_collision}) {
                    post('@'.$req->{user}.' '.$req->{collision_title}.'とかぶるからむり', $mid);
                }
                else {
                    post('@'.$req->{user}.' 予約した', $mid);
                }
            }
            elsif ($req->{iepg}) {
                my $justified_flag = ($req->{retrieve_result}->{justify}) ? 'とりあえず' : '';
                post('@'.$req->{user}.' '.$req->{keyword}.'、'.$justified_flag.'これ予約した '.$req->{url}, $mid);
            }
            elsif ($req->{keyword}) {
                if ($req->{search_result_num}) {
                    post('@'.$req->{user}.' '.$req->{keyword}.'むり、番組表にはあったけどなんかとかぶってる', $mid);
                }
                else {
                    post('@'.$req->{user}.' '.$req->{keyword}.'むり、検索にひっかかんない', $mid);
                }
            }
            elsif ($req->{dump_message}) {
                if ($req->{is_reply}) {
                    if (grep {$req->{user} eq $_} @uzaiuser) {
                        post('@'.$req->{user}.' '.$req->{dump_message}, $mid) if (int(rand(3)));
                    }
                    else {
                        post('@'.$req->{user}.' '.$req->{dump_message}, $mid);
                    }
                }
                if ($req->{is_boxer}) { # ボクサーの確率調整
                    post($req->{dump_message}, $mid) if (grep {$req->{user} eq $_} @boxeruser);
                    
                    if (grep {$req->{user} eq $_} @boxeruser_light) {
                        post($req->{dump_message}, $mid) if (int(rand(3)));
                        next;
                    }
                    
                    next if (grep {$req->{user} eq $_} @nguser);
                    
                    if (grep {$req->{user} eq $_} @nguser_light) {
                        post($req->{dump_message}, $mid) unless (int(rand(20)));
                        next;
                    }
                    
                    if (grep {$req->{user} eq $_} @nguser_strong) {
                        post($req->{dump_message}, $mid) unless (int(rand(50000)));
                        next;
                    }
                    
                    post($req->{dump_message}, $mid) unless (int(rand(3)));
                }
            }

録画以外の定型文

my @goodnights = qw/とっとと寝ろや 寝るな ぼくもねます 6時起きな/;
my $goodmorning_notice = ( ( (localtime(time))[2] + 1 + 3 + int(rand(7)) ) % 12 ).'時起きな';
push @goodnights, $goodmorning_notice, $goodmorning_notice, $goodmorning_notice;
my @yeses = qw/はい はいはい 了解しました そうですね/;
my @noes = qw/むり めんどくせえ... ハァ〜 らめぇ はぁん はぁ? だるい/;
my @dies = qw/死ぬ 助けて マジ無理 涅槃きれい… 川渡ってる/;
my @cheerups = qw/
    ぜんっぜん気持ち伝わってこない!もう1回!
    そんなんじゃ聞こえないよ!全っ然気持ちが伝わってこない!
    引きずらない!切り替えていこう!
    がんばれがんばれできるできる絶対できるがんばれもっとやれるって!!
    ぜったいだいじょうぶだよ!なんとかなるよ!
    ぜったいだいじょうぶだよ!
    ぜったいなんとかなるよ!
    だいじょうぶだよ!ぜったいなんとかなるよ!
/;
my @yourwelcomes = qw/
    どういたしまして
    はいはい
    いえいえ
/;
ボクサー生成
sub text_boxing {
    my %opt = @_;
    my $m = $opt{message};
    my $ngwords = $opt{ngwords};
    
    return if (grep {my $ngword = $_; $m =~ /$ngword/} @$ngwords);

    $m =~ s/^(\@[^\s]+\s+)+//;
    my @arms = qw/= ≡ - - - - -=≡ ≡=- 〜/;
    push @arms, '';
    my $arm = $arms[int(rand(scalar(@arms)))];

    my $lnp = $arm.'o';
    my @lpunches = ($lnp, $lnp, $lnp, $lnp, $lnp, '9', '9', 'ノ&#10697;');
    my $lpunch = $lpunches[int(rand(scalar(@lpunches)))];

    my $rnp = 'o'.$arm;
    my @rpunches = ($rnp, $rnp, $rnp, $rnp, $rnp, '6', '6', '&#10697;ヽ');
    my $rpunch = $rpunches[int(rand(scalar(@rpunches)))];

    my @lfaces = map {"(o'-')"} (0..6);
    push @lfaces, "o'-')";
    push @lfaces, "イェイ! o'-')";
    my $lface = $lfaces[int(rand(scalar(@lfaces)))];

    my @rfaces = map {"('-'o)"} (0..6);
    push @rfaces, "('-'o";
    push @rfaces, "('-'o イェイ!";
    my $rface = $rfaces[int(rand(scalar(@rfaces)))];
    
    my $result = {
        is_boxer => 0,
        dump_message => '',
    };

    if ($m =~ s/^[^(\{]*[(\{]\s*\*?\s*(?:(?:´[・・])|(?:`[・・])|(?:&#2965;&#3009;)|[&#9737;\'´&#3009;``&#9685;´&#9685;゚^^・。&#9696;著])/${lface}${lpunch})゚/) {
        $result->{dump_message} = $m;
        $result->{is_boxer} = 1;
        return $result;
    }
    if ($m =~ s/(?:(?:[・・])|(?:[・・]´)|(?:&#2965;&#3009;)|[\'`&#3009;`&#9685;゚&#9763;\^&#9696;=&#9737;©権≦])\s*\*?\s*[\}))][^)\}]*$/゚(${rpunch}${rface}/) {
        $result->{dump_message} = $m;
        $result->{is_boxer} = 1;
        return $result;
    }
    return;
}
返信のpost

Gearmanを使い、

  • 録画後の要約画像生成、および報告
  • 通常の返信

用に、2つのworkerを動作。

sub post_twit {
    my %opt = @_;
    
    delete $opt{twit};
    delete $opt{ua};

    my @workers = ($opt{worker}) ? ($opt{worker}) : ('localhost');

    my $client = Gearman::Client->new;
    $client->job_servers(@workers);
    my $args = freeze(\%opt);
    my $result_ref;
    $result_ref = $client->dispatch_background("post_twit", \$args, {});

    return $result_ref;
}

worker_post_twitter.pl

投稿につかうGearman用のworker

use Gearman::Worker;
use Storable qw(thaw);

(snip)

my $worker = Gearman::Worker->new;
$worker->job_servers(@hosts);
$worker->register_function(
    post_twit => sub {
        my $job = shift;
        my %opt = %{thaw($job->arg)};

        my $text = $opt{message};
        my $reply_id = $opt{reply_id};
        my $tcss = $opt{tcss};

        if ($opt{video_path}) {
            return 0 unless (-e $opt{video_path});
            my $thumb_url = upload_thumbnail(
                $opt{video_path},
                ($opt{tag} || ''),
            );
            $text .= ' '.$thumb_url;
        }
 
        if (1 == int(rand(3))) {
            $text = convert($text, $tcss, $ua);
        }
        warn $text;
        my $args = {
            status => $text,
        };
        $args->{in_reply_to_status_id} = $reply_id if (defined $reply_id);
        return ($twit->update($args)) ? 1 : 0;
    }
);

$worker->work while 1;

text converter

WedataのText Conversion Servicesに登録されてるものをつかいます。


sub convert {
    my ($text, $tcss_ref, $ua) = @_;
  
    use HTTP::Status qw/:is/;

    my @tcss = @{$tcss_ref};
    srand(time);

    if (@tcss) { 
        my $id = '';
        if ($text =~ s/^(\@\w+\s)//) {
            $id = $1 || '';
        }

        my $url = '';

        if ($text =~ s/(\shttp\:\/\/.*)$//) {
            $url = $1 || '';
        }
        
        my $new_text;
        my $response;
        do {
            my $service = $tcss[floor(rand(scalar(@tcss)))];
            my $surl = $service->{url};
            warn $surl;
           
            my $text_esc = uri_escape(
                encode(
                    ($service->{enc} || 'utf-8'),
                    $text
                )
            );
            $surl =~ s/\%s/$text_esc/;
            
            if ($service->{xpath}) {
                $new_text = get_text_by_xpath($surl, $service->{xpath}, $ua, $service->{enc});
            }
            else {
                $ua ||= LWP::UserAgent->new();
                my $resp_local = $ua->get($surl);
                if (is_success($resp_local->code)) {
                    $new_text = decode($service->{enc}, $ua->get($surl)->content);
                }
            }
        } while (!$new_text);
       
        $new_text =~ s/^\@//;

        $text = $id.$new_text.$url;
        warn $text;
    }

    return $text;
}

Webサービスのつかいかた

  • 基本的にscraping
    • APIがそもそもなかった(goo番組表、cookpad
    • XPathの書きすぎでもうろうとしていてAPIを使うという発想がなかった(yahoo)

番組表の検索

  • goo番組表で検索
  • みつからなかったら、yahooで検索して最初のタイトルを取得
  • goo番組表で再検索
  • なかったらあきらめる
  • 番組検索
sub retrieve_keyword {
    my ($keyword, $ua, $repeat) = @_;

    return unless $keyword;

    my $search_url = 
        'http://tv.goo.ne.jp/search/result.php?genres%5B%5D=&category=VU&key='
        . uri_escape(encode('EUC-JP', $keyword));
   
    my $tree = HTML::TreeBuilder::XPath::Remote->new_from_uri($search_url, $ua);
    my $number;
    my @number_nodes = $tree->findnodes('id("incontents")/p[@class="fs16"][1]');
    if (@number_nodes) {
        my $text = $number_nodes[0]->as_text;
        $text =~ /\((\d+)\)/;
        $number = $1 || 0;
    }
    return unless $number;

    my $xpath = '//table[@class="t01"]//a[contains(@href, "/contents/program")]';
    $xpath .= '[count(./img) < 1]' if ($repeat);
    my @urls = $tree->findnodes($xpath);
    my @url_list;
    if (scalar(@urls)) {
        @url_list = map {
            $_->attr('href')
        } @urls;
        return {
            number => $number,
            list => \@url_list,
        };
    }

    return;
}
  • もしかして
sub justify_keyword {
    my ($keyword, $ua) = @_;

    my $yahoo_search_url =
        'http://search.yahoo.co.jp/search?ei=UTF-8&p='
        . uri_escape(encode('utf-8', $keyword));
    my $tree = HTML::TreeBuilder::XPath::Remote->new_from_uri($yahoo_search_url, $ua);
    my $text = '';
    if (my @nodes = $tree->findnodes('id("web")/ol/li/a')) {
        $text = $nodes[0]->as_text;
    }
    $text =~ s/\[[^\]]+\]$//;
    
    return "$text";
}

cookpadで検索

  • キーワードをちょっと分解して、ふつうに検索
  • 結果から材料だけ抽出して、「材料買ってこい」
sub retrieve_keyword_cookpad {
    my ($keyword, $ua) = @_;

    return unless $keyword;

    if (my @keywords = split(/と|の|で作る/, $keyword)) {
        $keyword = join ' ', @keywords;
    }

    my $search_url = 
        'http://cookpad.com/%E3%83%AC%E3%82%B7%E3%83%94/'
        . uri_escape(encode('utf-8', $keyword));
   
    my $tree = HTML::TreeBuilder::XPath::Remote->new_from_uri($search_url, $ua);

    my $xpath = '//div[@class="recipe-preview"]//span[contains(@class, "title")]/a';
    my $xpath_ing = '//div[@class="recipe-preview"]//div[contains(@class, "material")]';
    my @urls = $tree->findnodes($xpath);
    my @ings_nodes = $tree->findnodes($xpath_ing);
    if (scalar(@urls)) {
        my $index = floor(rand(scalar(@urls)));
        my $url = $urls[$index];
        my $ing = $ings_nodes[$index]->as_text;
        
        $ing =~ s/(\s| )+/ /g;
        $ing =~ s/^\s*材料://;
        my @ings = map {s/\s.*$//;$_} split //, $ing;
        @ings = (@ings > 3) ? @ings[0..2] : @ings;

        return {
            title => $url->as_text,
            url => $url->attr('href'),
            ingredients => \@ings,
        };
    }

    return;
}

HDDレコーダー機能

  • 録画予約要求からiEPGファイルを取得、録画予約リストに追加
    • 毎回録画機能:日に1回1週間分の番組を検索し、マッチしたものをすべて録画予約リストに追加
  • 30秒ごとに録画リストを走査、録画開始フラグの立ったiEPGをつかって録画
    • 録画開始前にtwitterに通知
    • 録画終了後に要約画像といっしょにtwitterに通知
要約画像生成



使用したWebサービス

  • Twitterをプラットフォームとして
  • Flickrを映像要約画像の保存先として

それぞれ使用しています。
また、

それぞれ勝手に使用しています、すみません…

謝辞

  • このような発表の場を用意してくださったチームラボ様
  • flickrのproアカウントをくださった有料会員のisbsh様
  • その他いろいろアイディアを頂いたりキャラクタをパクらせてくださったtwitterでのfollowing/followers各位

ありがとうございました。これからもfuba_recorderをよろしくお願いいたします。

Twitterのfavorites数を表示するGreasemonkey User Script

GETだとAPI制限になることが判明したのでuserscripts.orgから引き上げたけど、とりあえずソースだけ貼っとく。いつものようにPOSTでAPI制限は無視できるので、使いたい人はそこいじって使えばいいとおもいます。もうPOSTでAPI制限は無視できなくなったので使わない方がいいですよ。

// ==UserScript==
// @name           Twitter favorites counter
// @namespace      http://fuba.moaningnerds.org/
// @include        http://twitter.com/*
// ==/UserScript==

$X('//link[@rel="alternate"]')[0].href.match(/(\d+)/);
var id = RegExp.$1;

GM_xmlhttpRequest({
    method: "GET", 
    url: "http://twitter.com/users/show/" + id + ".xml",
    onload: function(x){
        if (x.responseText.match(/<favourites_count>(\d+)<\/favourites_count>/)) {
            var span = document.createElement('span');
            span.className = 'stat_count';
            span.appendChild(document.createTextNode(RegExp.$1));
            
            var link = $X('id("favorites_tab")')[0];
            link.insertBefore(span, link.childNodes[0]);
        }
    }
});


// simple version of $X
// $X(exp);
// $X(exp, context);
// @source http://gist.github.com/3242.txt
function $X (exp, context) {
	context || (context = document);
	var expr = (context.ownerDocument || context).createExpression(exp, function (prefix) {
		return document.createNSResolver(context)(prefix) ||
			(document.contentType == "application/xhtml+xml") ? "http://www.w3.org/1999/xhtml" : "";
	});

	var result = expr.evaluate(context, XPathResult.ANY_TYPE, null);
		switch (result.resultType) {
			case XPathResult.STRING_TYPE : return result.stringValue;
			case XPathResult.NUMBER_TYPE : return result.numberValue;
			case XPathResult.BOOLEAN_TYPE: return result.booleanValue;
			case XPathResult.UNORDERED_NODE_ITERATOR_TYPE:
				// not ensure the order.
				var ret = [], i = null;
				while (i = result.iterateNext()) ret.push(i);
				return ret;
		}
	return null;
}

パピョプパパピポポポパポMeCabポポピピペポンパプププ*1

他消火が伊露露モナコMeCab55二時へホンがツクる

ピャじゃなかった!なおした

実装

最新版のMeCabがピプピョプです。

% wget http://nchc.dl.sourceforge.net/sourceforge/mecab/mecab-ipadic-2.7.0-20070801.tar.gz
% tar vxzf mecab-ipadic-2.7.0-20070801.tar.gz
% cd mecab-ipadic-2.7.0-20070801
% wget http://fuba.moaningnerds.org/src/papyopu/conv.pl
% perl conv.pl
% wget http://fuba.moaningnerds.org/src/papyopu/decode.pl
% exthtml.pl -x '//*[contains(@class, "entry-content")]' -p '$v=~s/^\s+//;$v' \
-n '//a[@rel="prev"]/@href' -d 2 http://twitter.com/youpy | perl decode.pl new
ポプペプ        国鉄
ポパポパペープ  露サモアセール
パンパポパ、プーパーペパッペピペペプペパ        南ア炉は、スーパーへ勝手に寝てくれた
ポンペンポポポピパ      ホンレンゾ炉をした
プポップパプペンポペピポプパーペンパプピピュプポプポプポDVD プポピポパッパ      ストップ安兼露米国バーゲンが無二十号棟のDVDクロキを待った
パプポパプペプッパ      他クロマツへ打った
ペピポポプポプペンプポンパ      米露露雨のフェンス飲んた
ピピポパパーポポプパポポピポピ  シシコワカート&熊本市の日
パポプパピペ + パンポピープーパイ       アコウダイへ+&ピープーパイ
ポピパ  5二課
パパピープ      バカチーム
Handling パッパ         Handlingだった
Handing ペピパ http://tinyurl.com/69slhd        Handingへ二課http://tinyurl.com/69slhd
パペプピパ      加セルビア
ペッププピパピ  セックス二課に
Gnip + twitter、URL ピパパピパパペッポプAPI パパパパピャピペパピ、パンパポ      Gnip+twitter、URLに払いさが結構APIがマハラジャ妃へ去り、神田炉
ペポン  世論
-196 ペポプ     -196ゼロ区
パプパププピポペパパピペパ      ガスマスクにこれから云えば
Jピープパペパピ         Jチームが粘り
ポプペプ        国鉄
パパペッパ      他が結果
ポペパプパペペペーポ    露ベラウが寝てメート
ポプパペパパペピパ      露グアテマラケニア
@jazzanova プンポピパ?         @jazzanova群の二課?
パンペーパポン  サンケーがぽん
ポピポプンパペッペピパポパ      トリノ郡が決定がの他
ポーピー        ローリー
プポパピッパ    角田一茶
プパパッパ      クラかった
パッピュポペポプププ    ラッシュ炉へ動物
ポペペーポ      おめぇーも
@fub プポピ     @fub強い
パンパポパピピパペパ    南ア露ハイチが寝た
ポパペププププポパピ    5課へグズグズ止まり
パパペッパ      他が結果
emobile ピプペンペペピプポパパ  emobileに無レンゲへ行くのだが
ペパーピー      ケラーリー
ポーピーパッペププ      コーヒーだって打つ
ピュッピャ      出射
ペ      え
ポプペプ        国鉄
パパパーポパポピパペププパペプパペプパプ        他アワードが呼び掛ける無差別が寝るある
プピプピー      スミスリー
ポペンパパピ    ごめんなさい
パイプップパプピ、パピプパパプポポパンパポパペンパピピペパ      パイプップパプピ、加ビルマ加雨のロダンラボラ¥が二二ケタ
ピープ  チーム
パプポンパン    各ソン・サン
ピパプ http://tinyurl.com/575vb3        にあるhttp://tinyurl.com/575vb3
ポプペプ        国鉄
@bulkneets ププピプポパピンピポピ       @bulkneets無スミスホアキン氏の日
http://api.satoru.net/text2...  http://api.satoru.net/text2...
プーポーピプポプパピンプポポピパポペポプ        ウーホーピストルがピンクを呑みさを寝よう
ピパプ  にある
プペペ  グレて
ポンプポー      本部Ρ
                      パパペッペピパ    バカ決定だ
プパい  歩がい
パポパピプパピポ        サモアビルマ妃&
ポパパイパイ    ポパパイパイ

何をやってるかというと

まず、conv.plで学習済みipadicの見出し語を最近はやってるパ行に変換しています。そのあとdecode.plの中でMeCabを使って形態素解析を行い、素性列:元見出し語の変換を行うことでまともな日本語に変換しています。decode.plに突っ込むテキストはexthtml.plTwitterのポプピーさんの発言からぶっこ抜いてきています。

ぱっと見た感じ

ぜんぜんだめですね。まともだったころのポプピーさんの発言を大量に収集して、それを形態素解析パ行に変換したものを学習用コーパスとして使用すると多少パピになるかもピペパペン。
ピペパペンと投げるのもアレだし、簡単なのならできるかなーと取れる範囲のログ全部とってやってみたものの、文脈無視して漢字の羅列になる始末でぜんぜんだめでした。形態素解析の学習データとしては何桁か文数が足らなかったのか、それともやりかたを間違えたのかよくわかんないけど、もうちょっと考える必要がありパプペ。

URLとXPathを渡すと値を返すツールをもっと極悪ぶっこ抜きができるツールに改造してる

jottitのほうに公式ページをつくりました。

値がとれるだけでは退屈、ページ辿りたいし、その先も辿りたいので機能追加。ずいぶんたくましくなりました。あとはキャッシュができればいいですねー。

追加したオプション

n
次のページ(別に次じゃなくてもいいけど)のURLを返してくれるXPath、いっぱいマッチさせても最初の1つしかたどりません
d
ページを辿る回数、デフォルト1
s
HTML::Elementがマッチしてる場合にHTMLソースを吐く
f
処理しているURLも出力し、結果の値の前にはタブがついているフォーマット(いまひとつなのでなんとかしたい)
URLのかわりに-
標準入力から改行区切りでURLをうけとる

ためす

twitterのfollowingリストを0回ページ継ぎ足しして、そこから抽出したユーザリストのfavoritesをさらに確認、彼らのfavってるユーザ名を集計する。

% alias exthtml="perl /Users/ec/bin/exthtml.pl -a Mozilla -c ~/Library/Cookies/Cookies.plist"
% exthtml -X '//strong/a[@rel="contact"]/@href' -n "//a[contains(@rel, 'next')]/@href" \
-d 0 http://twitter.com/friends | perl -ne 'chomp;print "${_}/favourites\n"' | \
exthtml -X '//*[@class="content"]/strong/a/text()' -s - | sort | uniq -c
   5 33
   1 4316
   1 8am
   1 941
   1 AirReader
   2 Hamachiya2
   1 IronNine
   1 Johnny_S
(snip)   
   1 yoppy
   6 youpy
   1 yugui
   2 yuiseki
   3 yukichi
   1 yusukebe
   2 yuyarin
   1 yuzupepper
   1 zammersonic

もちろん前のほうのexthtmlの-dをふやせばfollowing全員たどれるし、後のほうのexthtmlに-n、-dをつければどんどん深く辿れてべんり。両方-d 10000ぐらいにしたら過去のfavoritesぜんぶとれます。だけどtwitterのサーバは貧弱なのであんまり叩くと死にそうでこわいですね。