はてなでXML-RPC(再掲)

トラックバックがありましたので、はてなXML-RPCの元記事を再掲載します。元記事は2004年1月に書いたものです。
現在のはてなでも動くかどうかは未確認です。



Blogger, MovableType などのWeblogサイトに簡単に記事をポストできたりする Weblog ツールなるものがあります(通常、WeblogツールというとMovableTypeなどのコンテンツ生成エンジンを指すようですけど、ここでは記事の更新ツールの意味)。
これらのツールは blogger や metaWeblog というAPIセットを XML-RPC プロトコル上で呼び出すことでMovableTypeエンジンと会話をしています。

はてなダイアリーも、このような Weblogツールで簡単更新できれば、ブラウザからボタン一発でコメントを追加ということも夢ではなくなるはず。

というわけで、はてな用のXML-RPCサーバスクリプトを作成しました。
といっても、自分が使っている glucose でしか動作確認していませんし、このソフトが使っているAPIしか作っていません。

他のツールがあったら教えてください。

はてなダイアリーXML-RPCサーバスクリプト


# ver. 0.2
# 2004/1/2
# by joyful

# 実装済API (MovableType 2.65 の mt-xmlrpc.cgi がサポートするAPIのうち)
# blogger.newPost, blogger.editPost, blogger.getUsersBlogs,
# metaWeblog.newPost, metaWeblog.editPost,
# mt.getCategoryList, mt.setPostCategories, mt.publishPost
#
# 未実装API
# blogger.getRecentPosts, blogger.getUserInfo, blogger.deletePost,
# metaWeblog.getRecentPosts, metaWeblog.getPost, metaWeblog.newMediaObject,
# mt.getPostCategories, mt.getTrackbackPings, mt.supportedTextFilters,
# mt.getRecentPostTitles

# 動作確認済み weblogツール
# - glucose 0.0.1-237 http://glucose.dip.jp/


# 設置方法
#
# MovableTypeが設置されているサーバだったら、MovableTypeのextlibディレクトリを
# @INCに追加すれば完了(すぐ下の BEGIN ブロックの中を変更)
#
# MovableTypeがないサーバなら、Perl5のライブラリ LWP, XMLRPC, HTTP 等を
# 適宜設置する必要あり。
# とりあえずはMovableType/extlib に含まれてるディレクトリを全部コピーすればよい
(注:全体はこの記事の末尾に掲載。06/1/23)

あと(次の)RSDファイルもどこかのサーバに置いておく必要あり。ただし、apiLinkの値は設置したサーバスクリプトを指定すること。





Hatena
http://d.hatena.ne.jp/
http://d.hatena.ne.jp/






(注:サーバ名 joyful.main.jp は適宜変更する。06/1/23)


このスクリプトを使用すると、はてなにログインする際のクッキーがスクリプトを設置したディレクトリに残ります。

.htaccessファイルで見えなくしておきましょう。

glucose を使っている人は、Weblogの追加ダイアログで、はてなのユーザIDとパスワードを入力し、「from RSD.xml」を選んでURLに http://joyful.main.jp/rsd.xml を入力すれば、サーバスクリプトの設置なし(ここのサーバを使う)で使えるようになります。ただし、上の注意のように、このサイトにクッキーが残ります(見ませんけど)。
(注:上記の1段落は今は無効です。joyful.main.jpが動いてないので。06/1/23)

weblogツールのAPI呼び出しシーケンス

glucose
  • My Weblogの追加時
    1. blogger.getUsersBlogs(appkey, userid, passwd)
  • Blog Editorを開いた時
    1. mt.getCategoryList(blogid, userid, passwd)
  • Blog EditorでSubmit Commentをクリックした時
    1. metaWeblog.newPost(blogid, userid, passwd, item, publish)
    2. mt.setPostCategories(postid, userid, passwd, categories)
    3. mt.publishPost(postid, userid, passwd)

資料

コメント

(他人の文章なので削除)



#!/usr/bin/perl -w

# はてなダイアリー用XML-RPCサーバスクリプト
# ver. 0.2
# 2004/1/2
# by joyful

# 実装済みAPI (MovableType 2.65 の mt-xmlrpc.cgi がサポートするAPIのうち)
# blogger.newPost, blogger.editPost, blogger.getUsersBlogs,
# metaWeblog.newPost, metaWeblog.editPost,
# mt.getCategoryList, mt.setPostCategories, mt.publishPost
#
# 未実装API
# blogger.getRecentPosts, blogger.getUserInfo, blogger.deletePost,
# metaWeblog.getRecentPosts, metaWeblog.getPost, metaWeblog.newMediaObject,
# mt.getPostCategories, mt.getTrackbackPings, mt.supportedTextFilters,
# mt.getRecentPostTitles

# 動作確認済み weblogツール
# - glucose 0.0.1-237 http://glucose.dip.jp/


# 設置方法
#
# MovableTypeが設置されているサーバだったら、MovableTypeのextlibディレクトリを
# @INCに追加すれば完了(すぐ下の BEGIN ブロックの中を変更)
#
# MovableTypeがないサーバなら、Perl5のライブラリ LWP, XMLRPC, HTTP 等を
# 適宜設置する必要あり。
# とりあえずはMovableType/extlib に含まれてるディレクトリを全部コピーすればよい


use strict;

# MovableType/extlib を追加
my($LIBDIR);
BEGIN {
if ($0 =~ m!(.*[/??])!) {
$LIBDIR = $1;
} else {
$LIBDIR = './';
}
unshift @INC, $LIBDIR . 'mt/extlib';
}

###############################################################################
## はてなCGIへのインタフェース
###############################################################################

package Hatena;

use strict;
use HTTP::Cookies;
use LWP::UserAgent;
use HTTP::Request::Common qw(POST);

use vars qw( $HatenaURL );
$HatenaURL = "http://d.hatena.ne.jp";
use vars qw( $DateChangeTime );
$DateChangeTime = 4; # 日付が変わる時刻

# HTMLエンティティの復元
sub decode_html {
my($html) = @_;
return '' unless defined $html;
$html =~ tr!?cM!!d;
$html =~ s!'!"!g;
$html =~ s!"!"!g;
$html =~ s!<!!g;
$html =~ s!&!&!g;
$html;
}

# 記事ID $postid が含まれるダイアリーの日付文字列を取得
sub getHatenaDateByPostid {
my ($postid) = @_;
my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst)
= localtime($postid - 3600 * $DateChangeTime);
sprintf("%4d%02d%02d", 1900 + $year, $mon + 1, $mday);
}

# ユーザ $userid のダイアリーのタイトルを取得する
sub getHatenaTitle {
my ($userid) = @_;
my $request = HTTP::Request->new(GET => "$HatenaURL/$userid/");
my $content = LWP::UserAgent->new->simple_request($request)->content;
$content =~ m!(.*)!i;
$1;
}

# ユーザ $userid のダイアリーのカテゴリーを取得する
# カテゴリ一覧みたいなのがない場合は、現在のページでリンクがあるカテゴリのみ
sub getHatenaCategories {
my ($userid) = @_;
my $request = HTTP::Request->new(GET => "$HatenaURL/$userid/");
my $content = LWP::UserAgent->new->simple_request($request)->content;
my @cat;
while ($content =~ s!searchdiary??word=[^>]*>([^<]+)!!) {
push @cat, $1 unless (grep(/^$1$/, @cat));
}
open(FILE, "> $userid.cat");
print FILE join("?n", @cat), "?n";
close(FILE);
@cat;
}

# ユーザ $userid の日付 $date (指定しない場合は最新)のダイアリーに含まれる
# 記事のリストと、各種フォーム変数を取得する
sub getHatenaArticles {
my ($ua, $userid, $passwd, $date) = @_;
# 指定された日付の編集画面を取得
my $url = "$HatenaURL/$userid/edit";
$url .= "?date=$date" if $date;
my $request = HTTP::Request->new(GET => $url);
my $res = $ua->simple_request($request);
if ($res->header('Location') ne '') {
# ログインしていないのでトップページにリダイレクトされた
# ログイン処理
my %formdata = ('key' => $userid, 'password' => $passwd);
$request = POST("$HatenaURL/login", [%formdata]);
$res = $ua->request($request);
# 再度、編集画面を取得
$request = HTTP::Request->new(GET => $url);
$res = $ua->simple_request($request);
}
$res = $res->content;
# フォーム変数と個々の記事を収集
# フォーム変数の値は $arts->{変数名} に、
# 個々の記事は $arts->{article記事ID} に格納される。
# 記事IDは「*1072961200* タイトル」の1072961200などのこと
# それ以降、次の「*数字* タイトル」が現れるまでがこの記事IDの本文となる。
# 対応する記事IDが存在しないテキスト(本文の最初の部分)は
# $arts->{article}に格納される。
my $arts;
my $inform = 0;
my $inarea = 0;
my $artid = 'article';
for my $line (split("?n", $res)) {
if ($inform) {
last if ($line =~ m!!i);
if ($inarea) {
if ($line =~ m!!) {
$inarea = 0;
} else {
if ($line =~ /^?s*?*([0-9]+)?*(.*)$/) {
$artid = "article$1";
$line = $2;
}
$arts->{$artid} .= decode_html($line)."?n";
}
} elsif ($line =~ /]+>(.*)$/) {
$inarea = 1;
$line = $1;
if ($line =~ /^?s*?*([0-9]+)?*(.*)$/) {
$artid = "article$1";
$line = $2;
}
$arts->{$artid} = decode_html($line)."?n";
} else {
while ($line =~ s!]+)>!!) {
my $str = $1;
$str =~ /type=?"([^?"]+)?"/;
my $type = $1;
if ($type !~ /^submit$/i && $type !~ /^checkbox$/i) {
$str =~ /name=?"([^?"]+)?"/;
my $name = $1;
my $value;
$value = $1 if ($str =~ /value=?"([^?"]*)?"/);
$arts->{$name} = $value;
}
}
}
} elsif ($line =~ m!new(GET => $url);
my $res = $ua->simple_request($request);
if ($res->header('Location') ne '') {
# ログインしていないのでトップページにリダイレクトされた
# ログイン処理
my %formdata = ('key' => $userid, 'password' => $passwd);
$request = POST("$HatenaURL/login", [%formdata]);
$res = $ua->request($request);
# 再度、編集画面を取得
$request = HTTP::Request->new(GET => $url);
$res = $ua->simple_request($request);
}
# 記事を書き込む
my %formdata;
for my $key (sort { $b cmp $a; } keys(%$arts)) {
if ($arts->{body} eq '' && $key =~ /^article(.*)$/) {
if ($1) {
$formdata{"body"} .= "*$1*" . $arts->{$key};
} else {
$formdata{"body"} = $arts->{$key} . $formdata{"body"};
}
} else {
$formdata{$key} = $arts->{$key};
}
}
$request = POST($url, [%formdata]);
$res = $ua->request($request);
}

# 小見出し $title 、本文 $body で新しい記事を投稿する
sub postHatenaArticle {
my ($userid, $passwd, $title, $body) = @_;
# cookie_jarの生成
my $cookie_jar = HTTP::Cookies->new(file => "$userid.cok", autosave => 1);
# UserAgentの生成と、cookie_jarのセット
my $ua = LWP::UserAgent->new;
$ua->cookie_jar($cookie_jar);
# 最新の日付の記事リストを取得
my $arts = getHatenaArticles($ua, $userid, $passwd);
undef $arts->{date}; # 変数 date がないと以前の文章に追加されるようだ
$arts->{body} = "*t* $title?n?n$body";
my $res = setHatenaArticles($ua, $userid, $passwd, $arts);
# リダイレクト先を取得
my $url = "$HatenaURL/$userid/" . $res->header('Location');
my $request = HTTP::Request->new(GET => $url);
$res = $ua->simple_request($request)->content;
# 追加された記事のIDを取得
$res =~ s/?n//g;
$res =~ m!class=?"section?">(.*?)!i;
$1 =~ m!name=?"([0-9]+)?"!;
# 記事ID自体を返すとglucoseは大きな数を負とみなして止まってしまう
$1 - 1000000000;
}

sub editHatenaArticle {
my ($userid, $passwd, $postid, $title, $body) = @_;
$postid += 1000000000;
# cookie_jarの生成
my $cookie_jar = HTTP::Cookies->new(file => "$userid.cok", autosave => 1);
# UserAgentの生成と、cookie_jarのセット
my $ua = LWP::UserAgent->new;
$ua->cookie_jar($cookie_jar);
# 記事ID $postid を含む日付のダイアリーの内容を取得
my $date = getHatenaDateByPostid($postid);
my $arts = getHatenaArticles($ua, $userid, $passwd, $date);
return 0 if ($arts->{"article$postid"} eq '');
# 記事ID $postid の記事の内容を変更
$arts->{"article$postid"} =~ /^(?^?+?])*/;
my $cstr = $1;
$arts->{"article$postid"} = "$cstr $title?n?n$body";
# 記事ID $postid を含む日付のダイアリーの内容を変更
setHatenaArticles($ua, $userid, $passwd, $arts, $date);
1;
}

# 記事ID $postid の記事のカテゴリーをカテゴリーIDリスト @cids にする
sub setHatenaCategories {
my ($userid, $passwd, $postid, @cids) = @_;
$postid += 1000000000;
# cookie_jarの生成
my $cookie_jar = HTTP::Cookies->new(file => "$userid.cok", autosave => 1);
# UserAgentの生成と、cookie_jarのセット
my $ua = LWP::UserAgent->new;
$ua->cookie_jar($cookie_jar);
# 記事ID $postid を含む日付のダイアリーの内容を取得
my $date = getHatenaDateByPostid($postid);
my $arts = getHatenaArticles($ua, $userid, $passwd, $date);
return 0 if ($arts->{"article$postid"} eq '');
# カテゴリー文字列を生成
open(FILE, "$userid.cat");
my @cats = ;
close(FILE);
my $cstr;
for my $id (@cids) {
$cats[$id - 1] =~ /^(.*)?s*$/;
$cstr .= "[$1]";
}
$arts->{"article$postid"} =~ s/^(?^?+?])*//;
$arts->{"article$postid"} = $cstr . $arts->{"article$postid"};
# 記事ID $postid を含む日付のダイアリーの内容を変更
setHatenaArticles($ua, $userid, $passwd, $arts, $date);
1;
}

# open(FILE, ">> c:/xmlrpc.txt");
# print FILE $postid, ":", $date, "?n?n";
# for my $key (keys(%$arts)) {
# print FILE $key, ":", $arts->{$key}, "?n";
# }
# close(FILE);



###############################################################################
## blogger/metaWeblog/mt のXML-RPCインタフェース
###############################################################################

package Hatena::XMLRPCServer;

use strict;
use Jcode;

my $MyDiaryID = 1; # dummy

sub no_utf8 {
for (@_) {
next if ref;
$_ = pack 'C0A*', $_;
}
}

sub newPost {
my $class = shift;
my ($appkey, $blogid, $user, $pass, $item, $publish);
if ($class eq 'blogger') {
($appkey, $blogid, $user, $pass, my($content), $publish) = @_;
$item->{description} = $content;
} else {
($blogid, $user, $pass, $item, $publish) = @_;
}
no_utf8($blogid, values %$item);
for my $f (qw( title description mt_text_more mt_excerpt mt_keywords )) {
next unless defined $item->{$f};
$item->{$f} = Jcode->new($item->{$f}, "utf8")->euc;
}
my $postid = Hatena::postHatenaArticle(
$user, $pass, $item->{title},
$item->{description}."?n".$item->{mt_text_more});
SOAP::Data->type(string => $postid);
}

sub editPost {
my $class = shift;
my($appkey, $postid, $user, $pass, $item, $publish);
if ($class eq 'blogger') {
($appkey, $postid, $user, $pass, my($content), $publish) = @_;
$item->{description} = $content;
} else {
($postid, $user, $pass, $item, $publish) = @_;
}
no_utf8(values %$item);
for my $f (qw( title description mt_text_more mt_excerpt mt_keywords )) {
next unless defined $item->{$f};
$item->{$f} = Jcode->new($item->{$f},"utf8")->euc;
}
my $res = Hatena::editHatenaArticle($user, $pass, $postid, $item->{title},
$item->{description}."?n".$item->{mt_text_more});
SOAP::Data->type(boolean => $res);
}

sub getUsersBlogs {
shift if UNIVERSAL::isa($_[0] => __PACKAGE__);
my ($appkey, $user, $pass) = @_;
my $title = Hatena::getHatenaTitle($user);
my @res;
push @res, {
url => SOAP::Data->type(string => $Hatena::HatenaURL.$user."/"),
blogid => SOAP::Data->type(string => $MyDiaryID),
blogName => SOAP::Data->type(string => Jcode->new($title)->utf8)
};
?@res;
}

sub getCategoryList {
my $class = shift;
my ($blog_id, $user, $pass) = @_;
my @data;
my $id = 1;
foreach (Hatena::getHatenaCategories($user)) {
push @data, {
categoryName => SOAP::Data->type(string => Jcode->new($_)->utf8),
categoryId => SOAP::Data->type(string => $id)
};
$id ++;
}
?@data;
}

sub setPostCategories {
my $class = shift;
my ($postid, $user, $pass, $cats) = @_;
my $is_primary = 1;
my @cids;
for my $cat (@$cats) {
if ($cat->{isPrimary} && $is_primary) {
unshift @cids, $cat->{categoryId};
$is_primary = 0;
} else {
push @cids, $cat->{categoryId};
}
}
my $res = Hatena::setHatenaCategories($user, $pass, $postid, @cids);
SOAP::Data->type(boolean => $res);
}

sub publishPost {
my $class = shift;
my ($entry_id, $user, $pass) = @_;
# nothing
SOAP::Data->type(boolean => 1);
}

sub supportedMethods {
[ 'blogger.newPost', 'blogger.editPost', 'blogger.getRecentPosts',
'blogger.getUsersBlogs', 'blogger.getUserInfo', 'blogger.deletePost',
'metaWeblog.newPost', 'metaWeblog.editPost', 'metaWeblog.getRecentPosts',
'metaWeblog.getPost', 'metaWeblog.newMediaObject',
'mt.getCategoryList', 'mt.setPostCategories', 'mt.getPostCategories',
'mt.getTrackbackPings', 'mt.supportedTextFilters',
'mt.getRecentPostTitles', 'mt.publishPost' ];
}


package blogger;
BEGIN { @blogger::ISA = qw( Hatena::XMLRPCServer ); }

package metaWeblog;
BEGIN { @metaWeblog::ISA = qw( Hatena::XMLRPCServer ); }

package mt;
BEGIN { @mt::ISA = qw( Hatena::XMLRPCServer ); }


use strict;
use XMLRPC::Transport::HTTP;

my $server = XMLRPC::Transport::HTTP::CGI->new;
$server->dispatch_to('blogger', 'metaWeblog', 'mt');
$server->handle;