Practice of Programming

プログラム とか Linuxとかの話題

LinkSeeker スクレイピングフレームワーク

最近、スクレイピングすることが多かったので、面倒くさくなって作りました。まだ、いろいろ途中ではありますが。


初Mouse、初git、初githubなんで、なんか変なことしてたらすみません。
http://github.com/ktat/LinkSeeker/


スクレイピングするときは、以前書いてますが、下記のような処理をしています。

  1. URLのページを取得する
  2. (URLのページを保存)
  3. スクレイピングする
  4. (スクレイピングしたデータを保存)
  5. データをDBに入れたりする

これを、各クラスにばらけさせました。

  1. LinkSeeker::Getter (URLのページ取得)
  2. LinkSeeker::HtmlStore (取得したページの保存)
  3. LinkSeeker::Scraper (ページのスクレイピング) -- 実装は継承したsubclassで
  4. LinkSeeker::DataStore (スクレイプしたデータの保存)
  5. LinkSeeker::DataFilter (スクレイピングしたデータを何かする) -- 実装は継承したsubclassで

上記クラスは何もしておらず、そのサブクラスで実際の処理を実装し、configにて、何を使うか指定します。


取得するサイトを表現するのは下記のクラス(Siteじゃなくて、Pageのがいい気が...)。

テスト代わりにとりあえず動かせるサンプルを置いてます。
sample/ 以下に、data ディレクトリを作った後、下記を実行してください。

perl -I../lib -Ilib -MNews -e 'News->new(file => ["news.yml"])->run'

data/src 以下に、取得したhtmlファイル
data/scraped 以下に、scrapeしたデータが入ります。


この例では、日経新聞のサイトからデータ取ってくるのですが、カテゴリのページを取得してスクレイピングして、その中にある個別のニュース記事を取得してスクレイピングしています。
こういったネストした処理も楽にできるようにしています。ユーザーは設定ファイルと、スクレイピング処理だけ書けばいいって感じです。


設定ファイルはこんな感じ

--- 
# 既に保存されているものがあればそれを優先する設定。
prior_stored : 1
# 下のようにも設定できる。
# html と data の両方を優先する。htmlだけにすれば、getはしないけど、scrapingはやり直す
# prior_stored : ['html', 'data']

getter :
  # LinkSeeker::Getter::LWP をhtmlを取得するのに使う
  class: LWP
html_store :
  # LinkSeeker::HtmlStore::File を取得したhtmlを保損するのに使う
  class: File
  path : data/src
data_store :
  # LinkSeeker::DataStore::Dumper を データを保存するのに使う
  class: Dumper 
  path : data/scraped

# 上の設定で、class 以外の設定については、各クラスのオブジェクトを作る際に渡される

sites:
  # サイト名。メソッド名に使われます(設定ファイル内でユニーク)
  nikkei_main_list:
    url :
      # url: http:.. のようにも指定できるけど、変数を使いたい場合は下記のようにする
      base:  http://www.nikkei.co.jp/news/$category/
      # variables 以下で、上の変数の設定をする
      variables:
        # 変数 $category は、News クラスの news_category メソッドで取得できる
        category: nikkei_news_category
    # News::Nikkei をスクレイピングに使う(メソッド名はサイト名 nikkei_main_list)
    scraper : Nikkei
    # 取ったデータを元にさらにスクレイピングするための設定
    nest :
      # サイト名。メソッド名に使われます(設定ファイル内でユニーク)
      nikkei_news_detail:
        url:
          # fromで、その前のスクレイピング結果(hash ref)のキーを指定すると、
          # そこには、URLが入っているとみなし、そこから、URLを取ってくる
          from: news_detail_url
          # 取得したページに対するユニークな名前を決定するための仕組み
          unique_name :
            # 正規表現でマッチした部分をユニークな名前として使う
            # 指定しないっとURLをエスケープした文字列が使われます
            regexp: /([^/]+)\.html$

※サイト名は設定ファイル内でユニークって書いてますが、そのうち変更すると思います。

nest以下の設定は、最初のsites の設定と同じようにかけるようにしているつもり。
sites 以下に複数のサイト設定を書けますし、nest以下にも複数のサイト設定を書けます。
getter/html_store/data_store とかは、サイト毎の設定の下にもかけます。
根っこに書くと、書いてない場合のデフォルトとして使われます。


scraperもサイト毎にかけますが、こちらは、デフォルトの設定というわけではなく、継承します。上の例でいうと、nikkei_news_detail は、scraperの設定書いていませんが、その親の nikkei_news_main の設定を継承します。もし、nikkei_news_detailで、scraperを設定して、さらにnestがある場合、nest先では、nikkei_news_detail の scraperが使われます。


Newsクラスには、設定ファイルでvariablesで指定したメソッドが必要です。

package News;

use Any::Moose;
use lib qw(../lib);

extends 'LinkSeeker';

sub nikkei_news_category {
  [ qw/main keizai sangyo kaigai seiji shakai/ ];
}

1;

これにより、URL内で使われている変数を置き換えます。
複数返しているので、複数のURLが作られて、そのすべてをスクレイピングします。
注意としては、複数の値を配列リファレンスで返す場合は、同じ数を返すようにしないといけません。(て、まだ、試してないけど)
とりあえず、この設定だと、下記のURLが対象になります。

 http://www.nikkei.co.jp/news/main/
 http://www.nikkei.co.jp/news/keizai/
 http://www.nikkei.co.jp/news/sangyo/
 http://www.nikkei.co.jp/news/kaigai/
 http://www.nikkei.co.jp/news/seiji/
 http://www.nikkei.co.jp/news/shakai/

スクレイピングのプログラムは、News::Nikkeiに書きます。

package News::Nikkei;

use Any::Moose;
use Web::Scraper;

my $base_url = 'http://www.nikkei.co.jp';

sub nikkei_main_list {
  my ($self, $src) = @_;
  my $scraper = scraper {
    process 'ul.arrow-w-m-list li a', 'news_detail_url[]' => '@href';
    process 'h3.topNews-ttl2 a', 'top_news_detail_url[]' => '@href';
  };
  my $result = $scraper->scrape(\$src);
  my $top_news = delete $result->{top_news_detail_url};
  unshift @{$result->{news_detail_url}}, @$top_news;
  for my $url (@{$result->{news_detail_url}}) {
    if ($url !~ /^$base_url/) {
      $url = $url =~m{^/} ?  $base_url . $url : $base_url .'/'. $url;
    }
  }
  return $result;
}

sub nikkei_news_detail {
  my ($self, $src) = @_;
  my $scraper = scraper {
    process 'h3.topNews-ttl3', 'title' => 'TEXT';
    process 'div.article-cap', 'content' => 'TEXT';
  };
  return $scraper->scrape(\$src);
}

1;

他には、url のところに、post_data を与えてやれば、POSTでリクエストを送るとか、header を渡すことで、リクエストヘッダーを送れたりもします。が、ドキュメントがまだぜんぜん書けてないです。

以下、予定ですが、現状の業務で使う分にはあんまり必要なかったりするので、結構先になるかも。

  • サンプル書く(test代わり)
  • ドキュメント書く
  • Log関係の実装おわりました
  • もらったcookieをネスト先でリクエストに含めるおわりました

DataFilterについて何も書いてない…ですが、ちょっと変更しようかなぁ、とか思ったり思わなかったりしているので、今回は言及しないということで(次回があるのか謎だけど)。