[http://search.cpan.org/perldoc?Web::Scraper:title=Web::Scraper] をつかってみた

ドキュメントねー,と思ったら Redirecting… みたいな素敵なチュートリアルがあったのでいまさらながら使えるようになりました。サンプル群も参考になりました。


はてブホッテントリから,タイトル,URL,キーワード,タグを抜き出すのを書いてみました。わりと素直に書くとこんな感じ?

use strict;
use warnings;

use Web::Scraper;
use Encode ();

#binmode \*STDOUT, ':utf8';

my $utf8 = Encode::find_encoding('utf8');

my $target
    = do {
        if (@ARGV && ! -f $ARGV[0]) {
            use URI;
            URI->new($ARGV[0]);
        }
        else {
            local $_ = do { local $/; <> };
            $utf8->decode($_);      # assume encoded in UTF-8
        }
    };

my $scraper = scraper {
    process 'div.entry',
        'entries[]' => {
            id      => [ '@id', sub { s{ \A entry- }{}xmso } ],

            'entry' => scraper {
                process 'div.entry-body a.bookmark',
                    uri   => '@href',
                    title => 'TEXT';
                process 'div.entry-footer a.keyword',
                    'keywords[]' => 'TEXT';
                process 'div.entry-footer a.tag',
                    'tags[]' => 'TEXT';
            }
        }
    ;
};

my $result = $scraper->scrape($target);

use YAML;
print $utf8->encode(Dump($result));     # terminal is in UTF-8

結果は,

---
entries:
  - entry:
      keywords:
        - コンピュータ
        - firefox
        - iTunes
        - MozillaZine
        - URI
        - キーワード
      tags:
        - firefox
        - tips
      title: Firefox 3の「スマートブックマーク」を使いこなそう! | IDEA*IDEA
      uri: !!perl/scalar:URI::http http://www.ideaxidea.com/archives/2008/06/firefox_3_2.html
    id: 9087545

みたいになりました。

んで

  • カテゴリを抜き出せてない
  • いっこいっこのエントリーが id 属性以外 entry というハッシュに入ってしまう(フラットにいれたい)

みたいな不満がでてきました。

前者ははてなの出力が,「カテゴリ」と「キーワード」をどちらも <a class="keyword"> で修飾されていて違いは先頭の <img> を見るしかないんですね。がんばってセレクタ書いたりすればいけるかもしれませんが,ここは単純にキーワードの先頭がカテゴリーだ,という仮定で話をすすめます。

後者は,ターゲットの attribute(ここでは id)と,ターゲットに包含される HTML のデータを並列に置けないということです(もし書き方があるなら教えてください*1。そしたら以下の話はまったくの無駄になります :)。


得られたデータ構造が望みどおりでないのは scraper の範疇外?*2という気もしますが,ともかく,取得中?に加工していこうと思いました。

Supported feature として ARRAYREF を渡すと2個め以降は filter とみなしてくれる(上記で id の加工にも利用しています)ので,素直にそれで書くとこんな感じ?

my $scraper = scraper {
    process 'div.entry',
        'entries[]' => [
            {
                id      => [ '@id', sub { s{ \A entry- }{}xmso } ],

                'entry' => [
                    scraper {
                        process 'div.entry-body a.bookmark',
                            uri   => '@href',
                            title => 'TEXT';
                        process 'div.entry-footer a.keyword',
                            'keywords[]' => 'TEXT';
                        process 'div.entry-footer a.tag',
                            'tags[]' => 'TEXT';
                    },

                    sub {
                        my $stash = shift;
                        $stash->{category} = shift @{ $stash->{keywords} };
                        return $stash;
                    }
                ],
            },

            sub {
                my $stash = shift;
                # repack
                $stash = { %{ $stash->{entry} }, id => $stash->{id} };
                return $stash;
            }
        ],
    ;
};

んーちとごちゃごちゃしてきました。


で他のアプローチですが,コードを読んでいて,scraper の引数は結局サブルーチンリファレンスだから,そこでいろいろ書いてしまえば一応目的を果たせるのではと思いました。んが,上記コードでいうところの $stash をどうやって取得すればいいのか。実は result に引数なしで渡すと,戻り値がその時点の $stash になります。result の本来の使い方の副次的作用ですが。

ですんで,

my $scraper = scraper {
    process 'div.entry',
        'entries[]' => {
            id      => [ '@id', sub { s{ \A entry- }{}xmso } ],

            'entry' => scraper {
                process 'div.entry-body a.bookmark',
                    uri   => '@href',
                    title => 'TEXT';
                process 'div.entry-footer a.keyword',
                    'keywords[]' => 'TEXT';
                process 'div.entry-footer a.tag',
                    'tags[]' => 'TEXT';

                my $stash = result;
                $stash->{category} = shift @{ $stash->{keywords} };
                return $stash;
            }
        }
    ;

    my $stash = result;

    foreach my $entry (@{ $stash->{entries} }) {
        # repack
        $entry = { %{ $entry->{entry} }, id => $entry->{id} };
    }

    return $stash;
};

みたいに書けます。やはりごちゃごちゃしてますが,インデントは減りました(笑)。あと,entries のところは array を walk しなきゃならないんで,filter version のほうが素直に書けてますね。

せっかくの良さ(DSL ぽさ)が消えていますが,result の引数にサブルーチンリファレンスを渡せるようになれば,ちとマシになると思います。そもそも scrAPI for Ruby にそんな機能ねーよ,と一瞬思いましたが,あちらは特異メソッドで result の挙動変えられますよね。

安直に書くと下記な感じ。

--- Scraper.pm.orig
+++ Scraper.pm
@@ -96,7 +96,11 @@
         my @keys = @_;
 
         if (@keys == 1) {
-            return $stash->{$keys[0]};
+            if (ref $keys[0] eq 'CODE') {
+                return $keys[0]->($stash);
+            } else {
+                return $stash->{$keys[0]};
+            }
         } elsif (@keys) {
             my %res;
             @res{@keys} = @{$stash}{@keys};

*1:ただし HTML::Element インスタンスを操るのはなしで。

*2:後加工するなり結果を walk する際に工夫するなりすればいいじゃんという意味です。