はてなダイアリーキーワード抽出モジュール
一昨日、同僚の竹迫さんに、文書内からのキーワード抽出技術について教えてもらっていた時、わざわざ TF-IDF注1 用に別のコーパスを用意しなくても、MeCab だったら生起コストを辞書内に持っているんだから、それを使えばいいのではないか、という話になりました。
竹迫さんがその日のうちに作ってくれたプロトタイプで、アルゴリズムの改善とパラメータのチューニングを行ったところ、十分な品質が出そうなので、書き直して公開することにしました。
と、サイボウズラボの奥さんが Lingua::JA::Summarize という Mecab を使ったキーワード抽出モジュールをリリースして GJ です。
で、これにインスパイアされてというか、そういえばはてなブックマークもエントリーのキーワード抽出とかやってるなあと思って、中を見てみたらえらい実装が汚かったw もとい、中の実装方法はというと、普通にはてなダイアリーのキーワードリンクAPIを叩いてキーワードを抽出してたりする。なんですが、このキーワードリンクAPIにはドキュメンテーションされてないパラメータに mode というのがありまして、これに lite を指定するとテキストをマークアップするのではなく、与えたテキストに含まれるキーワードのリストを返してくれたりします。はてな内のサービスで、キーワード抽出ロジックを使い回すために作ったものだったと思うんですが。
暇つぶしにこの API を使ったキーワード抽出を抽象化したモジュールを書いてみました。
use Hatena::Keyword; my $body = 'はてなダイアリーのキーワードをリンクして。'; my @keywords = Hatena::Keyword->retrieve($body); print $_->score, "?t", $_->euc, "?n" for @keywords;
とすると
73 はてなダイアリー 42 キーワード 23 リンク 25 リンク 20 リンク 16 リンク
という結果が返ってくる。もうすこし高機能な SYNOPSYS としては
my $body = 'はてなダイアリーのキーワードをリンクして。'; # EUC
my @keywords = Hatena::Keyword->retrieve(
$body
{
score => 20
cname => [qw(hatena)]
},
);
print $_->score, "?t", $_->euc, "?n" for @keywords;という感じ。retrieve の引数にスコアの閾値や、抽出するキーワードのカテゴリ一覧を指定できる。あと、このクラスにとってはちょっと冗長な機能かもだけど、返却されてきたオブジェクトの文字コードをメソッドで変換できる。TT とかで使うことを考慮したもの。(Template::Plugin::Jcode 使えという説もあるけど。)
実装はこんな具合です。
package Hatena::Keyword;
use strict;
use warnings;
use base qw(Class::Data::Inheritable Class::Accessor Class::ErrorHandler);
use overload '""' => ?&as_string, fallback => 1;
use Carp;
use URI;
use RPC::XML;
use RPC::XML::Client;
use Jcode;
our $VERSION = 0.02;
my @Fields = qw(refcount word score cname);
__PACKAGE__->mk_accessors(@Fields);
__PACKAGE__->mk_classdata(rpc_client => RPC::XML::Client->new(
URI->new_abs('/xmlrpc', 'http://d.hatena.ne.jp/'),
[ useragent => join('/', __PACKAGE__, __PACKAGE__->VERSION) ]
));
BEGIN {
no strict 'refs';
for my $code (qw(sjis euc jis ucs2 iso_2022_jp)) {
*$code = sub {
my $self = shift;
$self->{$code} and return $self->{code};
return $self->{$code} = Jcode->new($self->word, 'utf8')->$code;
};
}
}
sub retrieve {
my $class = shift;
my $body = shift or croak sprintf 'usage %s->retrieve($text)', $class;
my $args = shift || {};
$args->{mode} = 'lite';
my $res = $class->_call_rpc($body, $args)
or $class->error($class->errstr);
my @keywords = map { $class->_instance_from_rpcdata($_) }@{$res->{wordlist}};
wantarray ? @keywords : ?@keywords;
}
sub markup_as_html {
my $class = shift;
my $body = shift or croak sprintf 'usage %s->markup_as_html($text)', $class;
my $args = shift || {};
$args->{mode} = '';
my $res = $class->_call_rpc($body, $args)
or $class->error($class->errstr);
return $res->value;
}
sub _call_rpc {
my ($class, $body, $args) = @_;
my $params = {
body => RPC::XML::string->new($body),
score => RPC::XML::int->new($args->{score} || 0),
mode => RPC::XML::string->new($args->{mode} || ''),
cname => defined $args->{cname} ? RPC::XML::array->new(
map { RPC::XML::string->new($_) } @{$args->{cname}}
) : undef,
a_target => RPC::XML::string->new($args->{a_target} || ''),
a_class => RPC::XML::string->new($args->{a_class} || ''),
};
# For all categories, It doesn't need an undefined cname value.
delete $params->{cname} unless defined $params->{cname};
my $res = $class->rpc_client->send_request(
RPC::XML::request->new('hatena.setkeywordlink', $params),
);
ref $res ? $res : $class->error(qq/RPC Error: "$res"/);
}
sub _instance_from_rpcdata {
my ($class, $data) = @_;
$class->new({
map {$_ => $data->{$_}->value } @Fields,
});
}
sub as_string { $_[0]->word }
1;誰が使うか分からないけど、いちおうモジュールファイルを置いておきます。
勢いつけて CPAN に up しようかなと思ったんだけど、ちょっと名前空間が微妙で。先日リリースした Hatena::API::Auth とかと整合性を整えるなら Hatena::API::Keyword かなとか、Hatena::Keyword でリリースするならテキストをマークアップする機能も搭載しなきゃいけないかなとか、markup_as_html を追加した。 あと CPAN に上げるなら Jcode の機能を内包してるのは外してもいいかなとか、その辺を考えてます。