複数のRSSを1つにまとめてFTPでサーバーに置くプログラムmergerss

動機

mixiにはブログのRSS配信を「日記」として登録できるが、登録できるRSSは1つだけである。自分の複数のブログの更新をmixiで知らせたいと思い、RSS配信を1つにまとめるFrankenfeedを使ってみたが、思ったようには動作してくれなかった。
そこで、mergerssというプログラムをPerlで書いた。最初はCGIとして書こうかと思ったが、そうするとCGIを実行できる場所を確保しなければならない。使用目的がブログの更新を知らせることなので、複数のRSSをまとめたRSSファイルを作成して、それをウェブサーバーに置いておけばいいと考えた。ブログの記事を書いた際に、mergerssを実行しなければならないが、大した手間ではない。

2006-06-20追記

はてなブックマークCGIとして動くものが欲しいというコメントをいただいたので、その機能を加えた。プログラムをmergerss.cgiのように.cgiを拡張子として持つファイルに保存して実行するとCGIとして動作する。

ほかの実現手段

2006-08-05追記

はてなブックマークで「それPla」というタグが幾つも付いていて、何のことか分からなかったのだが、宮川さんのPlaggerモジュールを使えばいいということだとやっと気が付いた。確かにその通りで、6月20日に追記したこのプログラムの存在意義も薄いことになる。強いて言えば、Plaggerを使うよりはRSSのデータをより直接触る分、RSSの処理に関する理解が深まるかも知れないということか。

2006-06-20追記

はてなブックマークで「それはてな RSS でやってる。」というご指摘もいただいた。確かに、はてなRSSに、自分のブログの「グループ」を作り、そこに自分のブログを登録しておけば、複数のRSSをまとめたRSSを得ることができる。たとえばmyblogsという名前のグループを使うとすると、まとめたRSSのURLは http://r.hatena.ne.jp/himazublog/myblogs/rss となる。試してはいないが、こうやって はてなRSSで作成したRSSmixiで使えるだろう。
したがって、mergerssの意義は、自分で好きに改造できることと、RSSAtomPerlで扱う例題となっている、ということになる。

仕様

mergerssの仕様は以下の通りである。

  • Unix*1のコマンド行で使う。コマンド行引数はなし。
  • 検証は自分のブログでおこなっているのみ。実行環境はCygwinperl。特別なことはしていないので、どんなUnixでも動くと思う。
  • 入力はRSS 1.0, RSS 2.0, Atom
  • 出力はRSS 2.0。
  • 出力中には各記事の題名とURLが含まれるのみ。mixiにはこれで十分なので。
  • 作成したRSS 2.0のファイルは指定された場所に保存し、その上で指定されたFTPサーバーの指定された場所に置く。
  • 設定はプログラムの最初の部分に定数宣言として書いてある。
  • 起動するとFTPサーバーのパスワードを聞いてくる。プログラム中にFTP_PASSWD定数として定義することも可能。
  • CGIとして動作させる場合(.cgiの拡張子で保存されている場合)は、FTP関係の定数宣言は不要である。

mergerssの出力例はこれである。

使用しているモジュール

mergerssはPerl 5.8にバンドルされていない以下のモジュールを使っており、mergerssを使うには適宜CPANからインストールする必要がある。

  • LWP::Simple (libwww)
  • XML::RSS (XML-RSS)
  • XML::Atom (XML-Atom)
    • XML::LibXMLを使っている。私がXML::LibXMLをインストールしようとした際はCygwinのlibxmlパッケージに不具合があって、ちょっと面倒だった。そのことをここに書いた。

コード

コードは以下の通り。

#!/usr/bin/perl -w

use strict;

my $RCSID = '$Id: mergerss,v 1.4 2006/06/19 20:45:28 himazu Exp $';
my $VERSION = (split(' ', $RCSID))[2];

use constant FEEDS => [
    [atom => "http://himazu.blogspot.com/atom.xml"],
    [rss  =>  "http://d.hatena.ne.jp/himazublog/rss"],
];
# As you see, an Atom feed is specified with "[atom => URL]"
# and an RSS 1.0 or RSS 2.0 feed is specified with "[rss => URL]"

use constant OUTPUT_FILENAME => "mergedrss.xml";
use constant OUTPUT_DIR      => "$ENV{HOME}/public_html";
use constant REMOTE_DIR      => "/himazu";
use constant FTP_SERVER      => "ftp.geocities.jp";
use constant FTP_USER        => "himazu";
#use constant FTP_PASSWD      => "xxxxxxxx";

use Date::Parse;

########################################################################

use LWP::Simple;
use XML::RSS;

sub get_rss {
    my $url = shift;
    my $rss = XML::RSS->new;
    my $feedcont = get($url);
    $rss->parse($feedcont);
    my @items;
    for my $item ( @{$rss->items} ) {
	my $title = $item->{title};
	Encode::_utf8_off($title);
	my $pubdate = $item->{dc}->{date} || $item->{pubDate};
	push(@items,
	     {title   => $title,
	      link    => $item->{link},
	      pubDate => str2time($pubdate)}
	);
    }
    my $title;
    my $link;
    if ( $rss->{channel} ) {
	$title = $rss->{channel}->{title};
	$link = $rss->{channel}->{link};
    }
    else {
	$title = $rss->{title};
	$link = $rss->{link};
    }
    Encode::_utf8_off($title);
    return {
	title => $title,
	link  => $link,
	items => \@items,
    };
}

########################################################################

use XML::Atom::Feed;

sub get_atom {
    my $url = shift;
    my @items;
    my $feed = XML::Atom::Feed->new($url);
    for my $entry ( $feed->entries ) {
	my $link = "";
	for my $l ( $entry->link ) {
	    my $rel = $l->get("rel");
	    if ( $rel eq "alternate" ) {
		$link = $l->get("href");
	    }
	}
	next if ( $link eq "" );
	push(@items,
	     {title   => $entry->title,
	      link    => $link,
	      pubDate => str2time($entry->updated)}
	);
    }
    my $feedlink = "";
    for my $l ( $feed->link ) {
	my $rel = $l->get("rel");
	if ( $rel eq "alternate" ) {
	    $feedlink = $l->get("href");
	}
    }
    return {
	title => $feed->title,
	link  => $feedlink,
        items => \@items,
    };
}

########################################################################

sub escape_markup {
    local $_ = shift;
    s/\&/\&/g;
    s/\</\&lt;/g;
    s/\>/\&gt;/g;
    s/\"/&quot;/g;
    s/\'/&apos;/g;
    $_;
}

use Date::Format;

sub rfc822date {
    time2str("%a, %e %b %Y %T %z", $_[0]);
}

########################################################################

use IO::File;
use Net::FTP;

sub main {
    my $is_cgi = $0 =~ /\.cgi$/i;
    my $passwd;
    unless ( $is_cgi ) {
	if ( defined &FTP_PASSWD ) {
	    $passwd = &FTP_PASSWD;
	}
	else {
	    print "password: ";
	    system('stty -echo');
	    $passwd = <STDIN>;
	    print "\n";
	    chomp $passwd;
	}
	system('stty echo');
    }
    my @feeds;
    for my $i ( @{FEEDS()} ) {
	my ($type, $url) = @$i;
	if ( $type eq "rss" ) {
	    push(@feeds, get_rss($url));
	}
	elsif ( $type eq "atom" ) {
	    push(@feeds, get_atom($url));
	}
	else {
	    warn "invalid feed type: $type";
	}
    }
    my $rss = XML::RSS->new(version => "2.0");
    $rss->channel(
	title => $feeds[0]->{title},
	link  => $feeds[0]->{link},
	description => "merged RSS feeds",
	generator   => "mergerss $VERSION",
    );
    my @all_items;
    for my $i ( @feeds ) {
	push(@all_items, @{$i->{items}});
    }
    for my $i ( sort {$b->{pubDate} <=> $a->{pubDate}} @all_items ) {
	$rss->add_item(title   => escape_markup($i->{title}),
		       link    => $i->{link},
		       pubDate => rfc822date($i->{pubDate}));
    }
    if ( $is_cgi ) {
	print "Content-Type: application/xml\r\n";
	my $rss_as_string = $rss->as_string;
	print "Content-Length: ", length($rss_as_string), "\r\n";
	print "\r\n";
	print $rss_as_string;
    }
    else {
	my $output_filepath = &OUTPUT_DIR . "/" . &OUTPUT_FILENAME;
	my $outputfh = IO::File->new("> $output_filepath");
	$outputfh->print($rss->as_string);
	$outputfh->close;
	my $ftp = Net::FTP->new(&FTP_SERVER, Passive => 1) or
	    die "failed to connect to @{[&FTP_SERVER]}\n";
	$ftp->login(&FTP_USER, $passwd) or
	    die "filed to login " . $ftp->message . "\n";
	$ftp->binary;
	$ftp->put($output_filepath, &REMOTE_DIR . "/" . &OUTPUT_FILENAME);
	$ftp->quit;
    }
}

main();
exit 0;