genfeed - 汎用 RSS ジェネレータ
サイトごとにカスタマイズされた正規表現を用意すれば、HTML を容易に RSS に変換することができます。ただ、サイトを1つ追加するごとに、スクリプトを作成するのは手間です。異なるのは正規表現のパターンだけですから、これを定義ファイル化して、汎用的に RSS を生成するツールを作ってみます。 先に紹介した 関心空間 RSS ジェネレータ のうち- クロールする URL
- サイトの channel 定義(title や description)
- マッチさせるパターン
title | RSS channel の title |
link | RSS channel の link |
description | RSS channel の description |
match | マッチした結果が item のどの要素にマッピングされるか |
サンプルコード
サイトのパターン等のデータを定義ファイル化し、順に RSS 変換するプログラム gendeed は List 2 のようになります(*1)。スクリプトの大まかな流れは、- サイトの定義ファイルをロード
- HTML をローカルのキャッシュファイルに取得
- HTML をパターンにマッチさせて、RSS を生成
use DirHandle; use Encode; use FileHandle; use HTTP::Status; use LWP::UserAgent; use URI; use XML::RSS;使用するモジュールをロードします。ディレクトリからファイルを取得するDirHandle、URI の相対パスを絶対 URL に変換するのに URI を使用します。
our $VERSION = "0.01"; our $SiteDir = "sites"; our $OutDir = "feeds"; mkdir "$SiteDir/cache", 0755 unless -e "$SiteDir/cache"; mkdir $OutDir, 0755 unless -e $OutDir;User-Agent に使用するバージョン番号
$VERSION
、サイトの定義ファイルを格納する $SiteDir
、RSS ファイルを出力する $OutDir
をパッケージ変数として定義し、ディレクトリがなければ mkdir します。
my $ua = LWP::UserAgent->new(); $ua->agent("genfeed/$VERSION");LWP::UserAgent のオブジェクトを初期化します。User-Agent 文字列にソフトウェア名とバージョン番号を入れておきます。
my @sites = load_sites();
load_sites
でサイト定義ファイルを読み込みます。
sub load_sites { my $dh = DirHandle->new($SiteDir) or die "$SiteDir: $!"; my @sites; for my $file (grep -f "$SiteDir/$_", $dh->read) { push @sites, load_site($SiteDir, $file); } return @sites; }
load_sites
では、$SiteDir
に含まれるファイルから load_site
を呼び出し、サイト定義をロードします。
sub load_site { my($dir, $file) = @_; my $fh = FileHandle->new("$dir/$file") or die "$dir/$file: $!"; my %param; while (<$fh>) { chomp; last if /^$/; /^(\S+): (.*)$/ and $param{$1} = $2; } $param{match} = [ split / /, $param{match} ]; $param{pattern} = do { local $/; <$fh> }; $param{filename} = $file; return \%param; }
load_site
はファイルを open し、RFC822 形式のヘッダをパースしながら、空行を見つけたら $param{pattern}
にパターンを格納します。また $param{match}
はスペースで区切って配列リファレンスとします。先に定義した asahi.com の場合、
$site = { title => 'asahi.top', link => 'http://www.asahi.com/', description => 'Asahi.com', match => [ 'link', 'title' ], pattern => "<li>\n<a href="(.*?)">(.*?)</a>\(\d\d:\d\d\)</li>", };のようなハッシュリファレンスとなります。
for my $site (@sites) { crawl_site($ua, $site); }
load_sites
で読み込んだサイト定義について、crawl_site
でクローリングします。
sub crawl_site { my($ua, $site) = @_; my $cache = "$SiteDir/cache/$site->{filename}.html"; my $base = URI->new($site->{crawl} || $site->{link}); my $resp = $ua->mirror($base, $cache);サイトの定義ファイル名からキャッシュファイルのパスを決定(*2)し、クロール先の URL を URI オブジェクトにします。 クロール先は、
crawl
というヘッダがあればそれを優先し、なければ link
要素を拾います。これは、サイトのトップページ(link
)以外に、その日の記事一覧が取得できるページ(crawl
)があるようなニュースサイトの場合、そのページから記事をマッチさせる方が効率が良いためです。
URI とキャッシュファイルを引数にして mirror
メソッドを実行します。これはローカルのキャッシュファイルの mtime を利用して If-Modified-Since などを HTTP リクエストヘッダに付加するため、ネットワーク資源を有効活用することができます。
$resp->code == RC_NOT_MODIFIED and return; $resp->is_success or do { warn "Error: ", $site->{title}; return };レスポンスのステータスが 304 Not Modified の場合、元ページが更新されていないため、RSS も更新せず return します。レスポンスが失敗した場合には、エラーを STDERR に吐き出し、次のサイトへ進みます。
my $rss = XML::RSS->new(version => 0.91); $rss->channel( title => $site->{title}, link => $site->{link}, description => $site->{description}, );XML::RSS オブジェクトを生成します。ここでもバージョンは 0.91 としましたが、日付が取得できるようであれば、RSS 1.0 にして
dc:date
要素を入れた方がよいかもしれません。
my $html = do { local $/; my $fh = FileHandle->new($cache); <$fh> };キャッシュファイルを open して、一気読みします。Perl の特殊変数
$/
を undef にしておくと、ファイルの中身を一気に読み込む(*3)ことが出来ます。
my $charset = extract_charset($resp, $html); $html = decode($charset, $html);HTTP レスポンスおよび HTML からエンコーディングを取得して、
Encode::decode
します。これにより $html
変数が Unicode 文字列になります。
sub extract_charset { my($resp, $html) = @_; $resp->header('Content-Type') =~ /charset=([\w\-]*)/ and return $1; $html =~ /<meta .*?charset="([\w\-]*?)"/ and return $1; return guess_encoding($html); }レスポンスの Content-Type から
charset=utf-8
といった文字列をマッチさせてエンコーディングを拾います。また Content-Type に charset 指定がない場合には HTML 内の meta タグから同様の表記を拾います。それでも失敗する場合には、guess_encoding
を呼びます。
sub guess_encoding { require Encode::Guess; Encode::Guess->set_suspects(qw/Shift_JIS euc-jp/); my $data = shift; my $enc = Encode::Guess->guess($data); ref($enc) or die "Can't guess: $enc"; # idiom return $enc->name; }
guess_encoding
では、Encode::Guess モジュールを使用してエンコーディングの自動判定を行います。Encode::Guess->set_suspects()
に候補のエンコーディングを渡します。ここでは日本語のページを想定しているため、Shift_JIS と euc-jp のみを指定しています。
my @whole_match = $html =~ /$site->{pattern}/g; my $match_num = @{$site->{match}}; while (my @match = splice(@whole_match, 0, $match_num)) { my %data; @data{@{$site->{match}}} = @match; $data{link} = URI->new_abs($data{link}, $base); $rss->add_item(%data); }デコードした HTML に対し、サイトの定義にある
pattern
でマッチをかけます。正規表現の g オプションで、すべてを1つの配列にマッチさせます。match
要素が2個 (たとえば link
と title
) の場合、すべてのマッチの前から2個ずつとっていくために、$match_num
にその値を格納します。
splice
関数を使用して、マッチ配列から順に要素をとりだし、ハッシュのスライスを用いて item 要素を構築します。またマッチした link
は相対パスとなっていることが多いため、URI->new_abs
を利用して、クローリング元の URI からの相対リンクとして絶対 URI を構築します。
my $xml = "$OutDir/$site->{filename}.xml"; open my $out, ">:utf8", $xml or die "$xml: $!"; $out->print($rss->as_string());最後に出力する RSS ファイルを UTF-8 モードで open し、
as_string
メソッドで文字列化して書き込みます。
実行例
先に紹介した asahi.com に加え、CNET Japan の定義ファイル List 3 も定義しています。CNET Japan では トップページより、ヘッドラインページの方が情報を一覧で取得しやすいため、こちらをcrawl
要素として定義しています。
% ./genfeed.pl % ls feeds feeds: asahi.top.xml cnet.japan.xmlコマンドラインから実行すると、
feeds
ディレクトリに asahi.top.xml
や cnet.japan.xml
などの RSS ファイルが作成されます。
Listings
List 1: asahi.top
title: asahi.com link: http://www.asahi.com/ description: Asahi.com match: link title <li> <a href="(.*?)">(.*?)</a>\(\d\d:\d\d\)</li>List 2: genfeed.pl
#!/usr/local/bin/perl -w # genfeed - generic RSS feed generator use strict; use DirHandle; use Encode; use FileHandle; use HTTP::Status; use LWP::UserAgent; use URI; use XML::RSS; our $VERSION = "0.01"; our $SiteDir = "sites"; our $OutDir = "feeds"; mkdir "$SiteDir/cache", 0755 unless -e "$SiteDir/cache"; mkdir $OutDir, 0755 unless -e $OutDir; my $ua = LWP::UserAgent->new(); $ua->agent("genfeed/$VERSION"); my @sites = load_sites(); for my $site (@sites) { crawl_site($ua, $site); } sub load_sites { my $dh = DirHandle->new($SiteDir) or die "$SiteDir: $!"; my @sites; for my $file (grep -f "$SiteDir/$_", $dh->read) { push @sites, load_site($SiteDir, $file); } return @sites; } sub load_site { my($dir, $file) = @_; my $fh = FileHandle->new("$dir/$file") or die "$dir/$file: $!"; my %param; while (<$fh>) { chomp; last if /^$/; /^(\S+): (.*)$/ and $param{$1} = $2; } $param{match} = [ split / /, $param{match} ]; $param{pattern} = do { local $/; <$fh> }; $param{filename} = $file; return \%param; } sub crawl_site { my($ua, $site) = @_; my $cache = "$SiteDir/cache/$site->{filename}.html"; my $base = URI->new($site->{crawl} || $site->{link}); my $resp = $ua->mirror($base, $cache); $resp->code == RC_NOT_MODIFIED and return; $resp->is_success or do { warn "Error: ", $site->{title}; return }; my $rss = XML::RSS->new(version => 0.91); $rss->channel( title => $site->{title}, link => $site->{link}, description => $site->{description}, ); my $html = do { local $/; my $fh = FileHandle->new($cache); <$fh> }; my $charset = extract_charset($resp, $html); $html = decode($charset, $html); my @whole_match = $html =~ /$site->{pattern}/g; my $match_num = @{$site->{match}}; while (my @match = splice(@whole_match, 0, $match_num)) { my %data; @data{@{$site->{match}}} = @match; $data{link} = URI->new_abs($data{link}, $base); $rss->add_item(%data); } my $xml = "$OutDir/$site->{filename}.xml"; open my $out, ">:utf8", $xml or die "$xml: $!"; $out->print($rss->as_string()); } sub extract_charset { my($resp, $html) = @_; $resp->header('Content-Type') =~ /charset=([\w\-]*)/ and return $1; $html =~ /<meta .*?charset="([\w\-]*?)"/ and return $1; return guess_encoding($html); } sub guess_encoding { require Encode::Guess; Encode::Guess->set_suspects(qw/Shift_JIS euc-jp/); my $data = shift; my $enc = Encode::Guess->guess($data); ref($enc) or die "Can't guess: $enc"; # idiom return $enc->name; }List 3: cnet.japan
title: CNET Japan link: http://japan.cnet.com/ crawl: http://japan.cnet.com/archive/headline.htm description: CNET Japan match: link title <li><span class="j3"><a href="(.*?)">(.*?)</a></span>
*1) データベースの処理等を除けば、Bulknews で動いているエンジンと同等です。
*2) 定義ファイルが
*3) slurp といいます。
*2) 定義ファイルが
asahi.top
であれば、キャッシュは cache/asahi.top.html
となります。*3) slurp といいます。
posted at: 00:29 by miyagawa | category: RSS | permalink
Trackback (6) | Printer Friendly | Email this blog