# anidb.pm: AniDB screen-scraper module
#
# Copyright (C) 2006 Tristan Willy <tristan.willy at gmail.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 2
# as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
package anidb;
use strict;
use LWP::UserAgent;
use Compress::Zlib;
use DBI;
use Encode;
use HTML::TreeBuilder;
use HTML::Entities;
use URI::Escape;
sub new {
my $type = shift;
my %params = @_;
my $self = {};
# required information
if(not defined $params{Database}){
return undef;
}
# set optional paramaters
map {
$params{$_} = exists $params{$_} ? $params{$_} : undef;
} ('Username', 'Password', 'Server');
# connect to the DB
my $connect_str = "dbi:Pg:dbname=$params{Database}";
$connect_str .= ";host=$params{Server}" if defined $params{Server};
$self->{dbh} =
DBI->connect($connect_str,
$params{Username}, $params{Password},
{ RaiseError => 1, PrintError => 0, AutoCommit => 0 })
or return undef;
# change to anidb schema
$params{'Schema'} = exists $params{'Schema'} ? $params{'Schema'} : 'anidb';
eval {
$self->{dbh}->do("SET search_path TO $params{'Schema'}");
};
if($@){
$self->{dbh}->disconnect;
return undef;
}
# make a LWP object for quering anidb
my $ua = LWP::UserAgent->new() or return undef;
$ua->timeout(30);
$ua->agent('Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1) ' .
'Gecko/20061010 Firefox/2.0');
$self->{ua} = $ua;
return bless $self, $type;
}
sub close {
my $self = shift;
eval { $self->{dbh}->rollback };
eval { $self->{dbh}->disconnect };
}
sub DESTORY {
my $self = shift;
anidb::close($self);
}
# public title_query(<search string>)
# Searches local DB for title hits and queries anidb if a miss occurs.
# Returns: array of hashes where hash contains ID and title
sub title_query {
my ($self, $query) = @_;
my $titles;
$query = strip($query);
$titles = title_search($self, $query);
if(not defined $titles){
$titles = anidb_search($self, $query);
if($#{$titles} >= 0){
title_insert($self, $query, $titles);
}
}
if(defined $titles){
return @{$titles};
} else {
return ();
}
}
# public anime_query(<id>)
# searches the local DB for anime ID hits and queries anidb if a miss occurs
sub anime_query {
my ($self, $id) = @_;
my $info;
$info = anime_search($self, $id);
if(not defined $info){
$info = anidb_anime($self, $id);
if(defined $info){
anime_insert($self, $info);
}
}
return $info;
}
# queries the local database for titles
sub title_search {
my ($self, $query) = @_;
my @results;
eval {
# select title results if we can match a query
my $sth = $self->{dbh}->prepare
("SELECT id_num, title FROM search_cache, search_hits " .
"WHERE search_cache.sid = search_hits.sid " .
"AND terms = ?");
$sth->execute($query);
while(my $row = $sth->fetchrow_arrayref()){
push @results,
{ 'id' => $row->[0],
'title' => $row->[1] };
}
$sth->finish();
};
if($@){
#warn "anidb.pm: Database error: $@\n";
return undef;
}
# return results if any
if($#results >= 0){
return \@results;
} else {
return undef;
}
}
# search the local database, return info hash on hit
sub anime_search {
my ($self, $id) = @_;
my %info =
('aid' => $id,
'key descriptions' =>
{ 'titles' => 'titles [array ref]',
'genres' => 'genre names [array ref]',
'rating' => 'rating [scalar, float]',
'type' => 'type (ex: Movie, OVA, TV, etc) [scalar, string]',
'numeps' => 'number of episodes [scalar, integer]',
'url' => 'url of official website [scalar, string]',
'startdate' => 'first aired/released date [scalar, string, YYYY-MM-DD format]',
'enddate' => 'last aired/released date [scalar, string, YYYY-MM-DD format]',
'aid' => 'AniDB anime id [scalar, integer]'
}
);
# validity check
if(not defined $id){
#warn "anidb.pm: stupid programmer alert";
return undef;
}
if($id !~ /^\d+$/){
#warn "anidb.pm: anime_search: $id is not a number\n";
return undef;
}
# can we find it?
my @table_metadata = ( 'aid', 'type', 'numeps', 'rating',
'startdate', 'enddate', 'url' );
eval {
my $sth;
$sth = $self->{dbh}->prepare
("SELECT * FROM anime WHERE aid = ?");
$sth->execute($id);
die "DB anime miss" if $sth->rows != 1;
# put the row data into the info hash
map {
$info{shift @table_metadata} = $_;
} $sth->fetchrow_array();
$sth->finish();
};
if($@){
return undef; # not found
}
eval {
my $sth;
# grab genres
$sth = $self->{dbh}->prepare
("SELECT gname FROM genre, genre_names " .
"WHERE genre_names.gid = genre.gid " .
"AND genre.aid = ?");
$sth->execute($info{aid});
while(my @row = $sth->fetchrow_array()){
push @{$info{genres}}, $row[0];
}
$sth->finish();
# grab titles
$sth = $self->{dbh}->prepare
("SELECT title FROM titles WHERE aid = ?");
$sth->execute($info{aid});
while(my @row = $sth->fetchrow_array()){
push @{$info{titles}}, $row[0];
}
$sth->finish();
};
if($@){
#warn "anidb.pm: error: $@\n";
return undef;
}
return \%info;
}
# inserts titles into the local database
sub title_insert {
my ($self, $query, $titles) = @_;
eval {
my $sth;
# insert search term
$sth = $self->{dbh}->prepare
("INSERT INTO search_cache_table VALUES (DEFAULT, ?, 'now')");
$sth->execute($query);
$sth->finish();
# retrieve search term's sid
$sth = $self->{dbh}->prepare
("SELECT sid FROM search_cache WHERE terms = ?");
$sth->execute($query);
die "Failed to retrieve search ID" if $sth->rows != 1;
my $sid = $sth->fetchrow_arrayref()->[0];
$sth->finish();
# insert title results
$sth = $self->{dbh}->prepare
("INSERT INTO search_hits VALUES (?, ?, ?)");
foreach my $title (@{$titles}){
$sth->execute($sid, $title->{id}, $title->{title});
}
$sth->finish();
# commit transaction
$self->{dbh}->commit();
};
if($@){
#warn "anidb.pm: database error: $@\n";
eval { $self->{dbh}->rollback() };
}
}
sub download_and_parse {
my ($self, $url, $parser) = @_;
# prepare a request
my $request = HTTP::Request->new(GET => $url);
$request->header('Referer' => 'http://www.anidb.info/perl-bin/animedb.pl');
# download the page
my $page = $self->{ua}->request($request);
if($page->is_success){
if(defined $page->content_encoding and
$page->content_encoding eq 'gzip'){
return $parser->($self,
decode('utf8', Compress::Zlib::memGunzip($page->content)));
} else {
return $parser->($self, decode('utf8', $page->content));
}
} else {
#warn "anidb.pm: error downling anidb page: " . $page->status_line() . "\n";
}
return undef;
}
# runs a anime query off anidb
sub anidb_anime {
my ($self, $id) = @_;
my $info =
download_and_parse
($self,
"http://www.anidb.info/perl-bin/animedb.pl?show=anime&aid=$id",
\&anidb_anime_parse) or return undef;
$info->{aid} = $id;
return $info;
}
# Runs a search off anidb
sub anidb_search {
my ($self, $query) = @_;
$query = uri_escape($query); # support for kanji
return download_and_parse
($self,
"http://www.anidb.info/perl-bin/animedb.pl?show=animelist&adb.search=" .
$query . "&do.search=search", \&anidb_search_parse);
}
# parses anidb search HTML
# note: anidb will return the anime title page if there is only one hit
# this should deal with that case to reduce load
sub anidb_search_parse {
my ($self, $content) = @_;
my $tree = HTML::TreeBuilder->new_from_content($content);
my @titles;
if($tree->look_down('_tag' => 'input', 'name' => 'show', 'value' => 'anime')){
# Single hit. Parse the page and return a single-element array with the title
if(my $ele_aid = $tree->look_down('_tag' => 'input', 'name' => 'aid')){
my $page = anidb_anime_parse($self, $content);
anime_insert($self, $page);
push @titles, { 'id' => $ele_aid->attr('value'), 'title' => $page->{'maintitle'} };
}
} else {
# Multiple hits. Lets walk down the table.
my @hits = $tree->look_down('_tag', 'tr', \&test_tr);
foreach my $hit (@hits){
# Get the AID from the title link
my $link = $hit->look_down('_tag', 'a', \&test_aid_link);
my $aid = 0;
if($link->attr('href') =~ /aid=(\d+)/){ $aid = $1 }
# Titles are either there or in a italic subtag
my $title = '';
if(my $t = $link->look_down('_tag', 'i')){ # Non-Japanese title
$title = decode_entities($t->content_array_ref->[0]);
} else { # Japanese title
$title = decode_entities($link->content_array_ref->[0]);
}
push @titles, { 'id' => $aid, 'title' => $title };
}
}
$tree->delete;
if($#titles >= 0){
return \@titles;
} else {
return [];
}
}
sub test_tr {
my $title_link = $_[0]->look_down('_tag', 'a') or return 0;
return test_aid_link($title_link);
}
sub test_aid_link {
my $tmp;
if(defined ($tmp = $_[0]->attr('href')) and $tmp =~ /aid=/){
return 1;
}
return 0;
}
# inserts an anidb anime page into the local database
# typical usage: anime_insert($self, anidb_anime_parse(HTML))
sub anime_insert {
my ($self, $info) = @_;
return undef if not defined $info;
if(not exists $info->{aid} or
not exists $info->{titles}){
return undef; # not enough info scrapped
}
# put in any genres that may be needed
anime_insert_genres($self, $info->{genres});
# put in the title
eval {
my $sth;
# put in basic anime info
$sth = $self->{dbh}->prepare("INSERT INTO anime VALUES (?,?,?,?,?,?,?)");
$sth->execute($info->{aid}, $info->{type}, $info->{numeps}, $info->{rating},
$info->{startdate}, $info->{enddate}, $info->{url});
$sth->finish();
# Genre association
$sth = $self->{dbh}->prepare
("INSERT INTO genre VALUES ($info->{aid}, " .
"(SELECT gid from genre_names where gname = ?))");
foreach my $genre (@{$info->{genres}}){
$sth->execute($genre);
}
$sth->finish();
# Title association
$sth = $self->{dbh}->prepare("INSERT INTO titles VALUES (?,?)");
foreach my $title (@{$info->{titles}}){
$sth->execute($info->{aid}, $title);
}
$sth->finish();
# update cache status
$sth = $self->{dbh}->prepare("INSERT INTO details_cache VALUES (?, 'now')");
$sth->execute($info->{aid});
$sth->finish();
$self->{dbh}->commit();
};
if($@){
#warn "anidb.pm: error: $@\n";
eval { $self->{dbh}->rollback() };
}
}
# insert genres from a array ref
sub anime_insert_genres {
my ($self, $genres) = @_;
eval {
my ($sth_select, $sth_insert);
$sth_select = $self->{dbh}->prepare("SELECT * FROM genre_names WHERE gname = ?");
$sth_insert = $self->{dbh}->prepare("INSERT INTO genre_names VALUES (DEFAULT, ?)");
foreach my $genre (@{$genres}){
$sth_select->execute($genre); # locate
$sth_insert->execute($genre) if $sth_select->rows == 0; # missing, insert
}
$sth_select->finish();
$sth_insert->finish();
$self->{dbh}->commit(); # commit any changes
};
if($@){
#warn "anidb.pm: Database error: $@ [@{[$self->{dbh}->errstr()]}]\n";
eval { $self->{dbh}->rollback() };
}
}
# parses an anidb anime page and returns the scraped info
sub anidb_anime_parse {
my ($self, $content) = @_;
my %info;
my %translation =
( 'Title' =>
sub {
$info{'maintitle'} = decode_entities($_[1]);
$info{'maintitle'} =~ s/\s+\(\d+\)\s*$//; # get rid of that ID junk
add_title($_[0], $info{'maintitle'});
},
'Kanji/Kana' => \&add_title,
'English' => \&add_title,
'Synonym' =>
sub {
my ($infohref, $value) = @_;
map { add_title($infohref, $_) } split(/,\s*/, $value);
},
'Genre' =>
sub {
my $str = $_[1];
$str =~ s/\s+-\s+.*$//;
push @{$info{'genres'}}, split(/,\s+/, $str);
},
'Type' =>
sub { $info{'type'} = $_[1] },
'Episodes' =>
sub { $info{'numeps'} = $_[1] },
'URL' =>
sub {
$info{'url'} = $_[1];
if($info{'url'} eq ''){ $info{'url'} = undef }
},
'Rating' =>
sub { if($_[1] =~ /(\d+\.\d+)/){ $info{'rating'} = $1 } },
'Year' =>
sub {
my ($info, $val) = @_;
if($val =~ /(\d{1,2})\.(\d{1,2})\.(\d{4})\s+till\s+(\d{1,2})\.(\d{1,2})\.(\d{4})/){
my @cpy = ($1, $2, $3, $4, $5, $6);
map { $_ =~ s/^0+// } @cpy;
$info->{'startdate'} = "$cpy[1]/$cpy[0]/$cpy[2]";
$info->{'enddate'} = "$cpy[4]/$cpy[3]/$cpy[5]";
}
},
'Tmp. Rating' =>
sub {
if(not defined $info{'rating'} and $_[1] =~ /(\d+\.\d+)/){
$info{'rating'} = $1
}
}
);
# This is the core parser for a show page.
# The trick here is to locate the definition piece and find field/value
# pairs. Each pair is passed into a translation table that changes the data
# as it sees fit.
my $tree = HTML::TreeBuilder->new_from_content($content);
my $anime_deflist = $tree->look_down('_tag', 'div', 'class', qr/.*g_definitionlist\s+data.*/i)
or do { return undef }; # we're interested in the core anime info
foreach my $field ($anime_deflist->look_down('_tag', 'th', 'class', qr/field/i)){
if(not $field->parent()){ next } # this shouldn't arise
my $value = $field->parent()->look_down('_tag', 'td', 'class', qr/value/i)
or next; # this shouldn't arise either
my ($left, $right) = map {
$_ = decode_entities(space_collapse($_->as_text()));
} ($field, $value);
if(defined $translation{$left}){
$translation{$left}->(\%info, $right);
}
}
$tree->delete;
return \%info;
}
sub add_title {
push @{$_[0]->{'titles'}}, decode_entities($_[1]);
}
sub strip {
my $query = lc(shift); # lowcase input
$query =~ s/\s+/ /g; # collaspe spaces
$query =~ s/^\s+//; # strip starting spaces
$query =~ s/\s+$//; # strip ending spaces
return $query;
}
sub space_collapse {
my $str = shift;
$str =~ s/ +/ /g;
$str =~ s/^ +//;
$str =~ s/ +$//;
return $str;
}
1;