Published on Blog Developer's Cookbook.
Printer friendly version of http://blog.bulknews.net/cookbook/blosxom/rss/rss2email.html.


RSS をメールで送信する

by miyagawa at Thu, 23 Oct 2003 22:29

RSS は Aggregator で読んだり、自分の Blog に張りつけたりと、様々な使用法がありますが、トラディショナルに、お気に入りの MUA (メーラ) で RSS の更新情報をメールで受信したいという欲求もあるでしょう。今回は、RSS を取得して Email で送信するスクリプトをつくってみます。


RSS のパース

RSS のパース処理には、RSS の作成 でも使用した XML::RSS を使用します。XML::RSS は XML::Parser のサブクラスであるため、RSS の文字列を変数やファイルに保存した後、

my $rss = XML::RSS->new();
$rss->parse($doc);
$rss->parsefile($file);

などとしてパースを行わせることができます。パースした RSS の情報は、channelitems などのハッシュから取得できます。

DublinCore: メタデータ

今回作成するスクリプトは、定期的に指定した URL の RSS を取得して、更新情報があったら、設定したメールアドレスに Email を送信するものです。この「更新された時刻」を取得するために、RSS の DublinCore モジュールを使用して判定することにします。

関心空間の RSS を作成する では、RSS 0.91 の RSS を作成していたため、出力された RSS を見ただけでは、それぞれのアイテムの更新日付がわかりません。更新情報を取得して差分で何か処理するような場合にはこれでは不便です。そこで RSS 1.0 や RSS 2.0 では、モジュールを使用して、更新時刻などのコアには含まれないメタデータを RSS に含めることができます。

ここでは、名前空間 dc (*1) で定義される DublinCore モジュールを使用します。Dublin Core は、ウェブ上のセマンティックなデータの基礎として定義されたメタデータで、Dublin Core Metadata Initiative により策定されています。Dublin Core には Title や Creator 等、様々なデータがありますが、今回はそのうち日付を表わす Date を使用します。

サンプルコード

RSS を取得し、更新情報をメール送信するスクリプトは List 1 のようになります。

use Digest::MD5 qw(md5_hex);
use Encode;
use File::stat;
use HTTP::Date;
use LWP::Simple;
use MIME::Lite;
use XML::RSS;
スクリプトで使用するモジュールをロードします。今回は定番の LWP::Simple, XML::RSS に加えて、MD5 ダイジェストを作成する Digest::MD5、エンコーディングの相互変換 Encode、ファイルの stat をオブジェクト指向で行う File::stat、ISO 形式や RFC822 形式の日付文字列を扱う HTTP::Date、メール送信のための MIME::Lite といったモジュールを使用します。

our $CacheDir = "rsscache";
our $MailTo   = 'you@example.com';
設定変数を our で定義しています。$CacheDir は RSS ファイルのキャッシュを保存するディレクトリ、$MailTo は更新情報を送信するメールアドレスを指定します。

mkdir $CacheDir, 0755 unless -e $CacheDir;
キャッシュ用のディレクトリがなければ、mkdir します。

my $rss    = shift or die "Usage: rss2email URL\n";
my $digest = md5_hex($rss);
my $cache  = "$CacheDir/$digest.xml";
コマンドライン引数に RSS の URL を指定します。この URL の MD5 ダイジェストをとり、この hex 値をキャッシュファイル名とします。ダイジェストなので不可逆となってしまうのが欠点ですが、URL → キャッシュへの一方向変換でユニーク性が保証されれば OK なので、MD5 を利用するのがよいでしょう。

my $lastmod = -e $cache ? stat($cache)->mtime : 0;
my $status = LWP::Simple::mirror($rss, $cache);
キャッシュファイルが存在する場合は、あらかじめ更新時刻を stat しておきます。次に LWP::Simple::mirror を呼び出します。この関数は LWP::UserAgent の mirror メソッドへのラッパーとなっており、キャッシュファイルの mtime を見て If-Modified-Since のヘッダをつけて投げるなどの処理を透過的に行ってくれます。また、受信したレスポンスはキャッシュファイルに更新されます。

if ($status == RC_NOT_MODIFIED) {
    warn "$rss not modified\n";
} elsif (is_error($status)) {
    die "$rss not found!\n";
} elsif (is_success($status)) {
    proc_rss($cache, $lastmod);
}
mirror の返り値によって処理を分岐します。RC_NOT_MODIFIED では、304 レスポンスが返ってきているため、RSS ファイル自体が更新されていませんから、新着記事が届いているということもありません。レスポンスが失敗の場合(is_error)は、エラー終了します。リクエストが成功した場合(is_success)は、RSS ファイルの中身について処理を行います。

sub proc_rss {
    my($xml, $lastmod) = @_;
    my $rss = XML::RSS->new();
    $rss->parsefile($xml);
XML::RSS オブジェクトを生成し、parsefile メソッドを実行します。

    for my $item (@{$rss->{items}}) {
        my $dc_date = $item->{dc}->{date}
            or die "RSS should have dc:date element";
        my $epoch = HTTP::Date::str2time($dc_date);
        if ($epoch > $lastmod) {
            do_sendmail($rss, $item, $epoch);
        } else {
            last;
        }
    }
それぞれの RSS アイテムについて、Dublin Core の日付属性である dc:date を探します。XML::RSS では、モジュールで拡張した属性は、XML namespace のハッシュリファレンスに保存されています。ここで Dublin Core の名前空間は dc であるため、$item->{dc}->{date}dc:date の値が取得できます。今回のスクリプトでは、この dc:date を出力しない RSS (RSS 0.91 など) は対象外として例外を投げています。

Dublin Core の date 文字列は、ISO 8601 のサブセットである W3CDTF フォーマット(*2) で表記されているため、これをパースします。今回は HTTP::Date モジュールがうまく処理できるようなので、これを使用していますが、Date::Parse モジュール、また DateTime::Format::W3CDTF でも処理できるようです(*3)。POSIX の strptime などを使用してもパースすることは可能ですが、サイトやツールごとに表記の揺れが激しかったりして大変なので、こうしたモジュールを使用する方がベターでしょう。

パースした結果、もともとのキャッシュファイルの更新時刻 $lastmod より新しいエントリについては、do_sendmail でメールを送信します(*4)。キャッシュの mtime より古いエントリが登場したら、ループを抜けています。これは通常 RSS ファイルのエントリは新着順であるという慣習によっていますが、そうでない場合もありそうですので、lastnext にした方がよいかもしれません。

sub do_sendmail {
    my($rss, $item, $epoch) = @_;
    my $body = <<BODY;
New entry arrived for $rss->{channel}->{title}
$item->{link}
>> $item->{description}
BODY
    ;
RSS のアイテム内容をメールで送信します。本文には channeltitle 要素と、各 itemlink, description 要素を使用しています。

    my $mime = MIME::Lite->new(
        From => $MailTo,
        To   => $MailTo,
        Subject => encode('MIME-Header' => $item->{title}),
        Type => 'text/plain; charset=UTF-8',
        Encoding => '8bit',
        Data => encode('UTF-8' => $body),
        Datestamp => 0,
        Date => HTTP::Date::time2str($epoch),
    );
メール送信には MIME::Lite モジュールを使用しました。日本語などを扱うときに問題になるのがエンコーディングですが、ここでは Subject は UTF-8 + B エンコード、本文も UTF-8 として送信するようにしました。近頃の MUA であれば UTF-8 でも問題なく読めますから問題は少ないでしょう。どうしても無理な場合は ISO-2022-JP などに変更する必要があります。

またちょっとしたことですが、RSS 内の dc:date で設定してある日付が、メールの Date: ヘッダにつくよう、Datestamp と Date も設定してあります。

    $mime->send();
最後に send メソッドでメールを送信します。MIME::Lite はプラットフォーム毎に最適な方法で、メールを送信します(*5)

実行例

コマンドラインから RSS 1.0 や RSS 2.0 の URL を指定します。

% ./rss2email.pl http://blog.bulknews.net/cookbook/blosxom/index.rss10

うまくいけば、RSS のエントリが1通ずつ、メールで送信されてきます。また rsscache というディレクトリが作成され、その中にキャッシュの RSS ファイルが出来ていることも確認できるでしょう。

See Also

Listings

List 1: rss2email.pl
#!/usr/local/bin/perl -w
# rss2email - send email from RSS feed

use strict;
use Digest::MD5 qw(md5_hex);
use Encode;
use File::stat;
use HTTP::Date;
use LWP::Simple;
use MIME::Lite;
use XML::RSS;

our $CacheDir = "rsscache";
our $MailTo   = 'you@example.com';

mkdir $CacheDir, 0755 unless -e $CacheDir;

my $rss    = shift or die "Usage: rss2email URL\n";
my $digest = md5_hex($rss);
my $cache  = "$CacheDir/$digest.xml";

my $lastmod = -e $cache ? stat($cache)->mtime : 0;
my $status = LWP::Simple::mirror($rss, $cache);

if ($status == RC_NOT_MODIFIED) {
    warn "$rss not modified\n";
} elsif (is_error($status)) {
    die "$rss not found!\n";
} elsif (is_success($status)) {
    proc_rss($cache, $lastmod);
}

sub proc_rss {
    my($xml, $lastmod) = @_;
    my $rss = XML::RSS->new();
    $rss->parsefile($xml);

    for my $item (@{$rss->{items}}) {
        my $dc_date = $item->{dc}->{date}
            or die "RSS should have dc:date element";
        my $epoch = HTTP::Date::str2time($dc_date);
        if ($epoch > $lastmod) {
            do_sendmail($rss, $item, $epoch);
        } else {
            last;
        }
    }
}

sub do_sendmail {
    my($rss, $item, $epoch) = @_;
    my $body = <<BODY;
New entry arrived for $rss->{channel}->{title}
$item->{link}
>> $item->{description}
BODY
    ;
    my $mime = MIME::Lite->new(
        From => $MailTo,
        To   => $MailTo,
        Subject => encode('MIME-Header' => $item->{title}),
        Type => 'text/plain; charset=UTF-8',
        Encoding => '8bit',
        Data => encode('UTF-8' => $body),
        Datestamp => 0,
        Date => HTTP::Date::time2str($epoch),
    );
    $mime->send();
}
*1) http://purl.org/dc/elements/1.1/
*2) 2003-10-23T03:21:11+09:00 のようなフォーマット
*3) NDO::Weblog の naoya 氏に教えてもらいました。情報thanks!
*4) Blog ツールの Draft 機能などを使用した場合、前回の更新より前の時刻のエントリが新しくポストされることがあります。今回のスクリプトでは、そのような場合は拾うことができません。
*5) Unix なら sendmail コマンド、Win32 なら SMTP を使用

Copyright©2002-2003 Tatsuhiko Miyagawa