WWW::Mechanizeで入力フォームにない項目を送信するには

例えばWebで蔵書検索を行う館で元々ISBN検索出来る機能をフォームを変更することで検索機能を殺している場合に無理矢理Mechで送信すると

$mech->select('code_genre1', '2');
$mech->submit_form(
    fields => {
        'code_value1' => $isbn,
    }
);

当然の事ながらエラーになる

Input "code_genre1" not found at ff.pl line 76
No such field 'code_value1' at /usr/local/lib/perl5/site_perl/5.8.8/WWW/Mechanize.pm line 1371

フォームにない項目をセットするとエラーになるという事は、フォームにある項目ならOK
同じパッケージでISBN検索機能がある館にmechをかけてDumpすれば該当項目が表示される

bless(
    {
        'current' => 0,
        'menu'    => [
            {
                'seen'  => 1,
                'value' => '0',
                'name'  => 'foo',
            },
            {
                'value' => '2',
                'name'  => 'bar'
            }
        ],
        'name' => 'code_genre1',
        'type' => 'option'
    },
    'HTML::Form::ListInput',
),
bless(
    {
        'maxlength'  => '40',
        'value_name' => '',
        'value'      => '',
        'name'       => 'code_value1',
        'type'       => 'text',
        'size'       => '30'
    },
    'HTML::Form::TextInput',
),

この二つを$mech->{form}->{inputs}*1にpushしておくと、エラーにもならずキチンと検索結果が帰ってくる。
あとはscrapeするだけなのだがprocessを複数回指定するのが面倒だったり、ここで変数にセットしないでサブルーチンを呼ぶだけが出来なかったりと困っていたところ
http://perl-mongers.org/2008/07/mechanize-scraper.htmlのブックマークコメントに

$scraper->scrape(\$mech->content) すればいいだけだからほとんどかわらないんだけど。

http://b.hatena.ne.jp/miyagawa/20080731#bookmark-9489173

とのコメントが

#   複数回process
my $hoge =scraper {
    process 'XPATH1', 'foo'  => '@href';
    process 'XPATH2', 'bar'  => 'TEXT';
}->scrape(\$mech);
#   サブルーチン呼ぶだけ
scraper {
    process 'XPATH3', 'buzz' => ['@href', \&fizz];
}->scrape(\$mech);

素直にこんな感じで記述できた

備考

自分がやろうとしているような場合は

    1. 分館情報がない場合WWW::Mechanizeで特定のリンクを洗い出す
    2. 分館情報がある場合WWW::Mechanizeで特定のリンクを洗い出してリンクを踏む、結果をWeb::Scraperに渡して分館情報を洗い出す

を基本に判りやすくて楽な方法を柔軟に使っていけばいいんじゃないかという結論

*1:WWW::Mechanize::Pluggable::Web::Scraperの場合は$mech->{Mech}-{form}-{inputs}

図書館の所蔵状況をスクレイピングするスクリプトをWWW::Mechanize::Pluggableを使って書いてみた

以前に図書館の所蔵状況を拾ってくるスプリプトをWeb::Scraperを使うように書き換えるという記事を書いたが、今回更に一歩進んでWWW::Mechanize::Pluggableを使うように書き換えてみた。
WWW::Mechanize::PluggableはWWW::MechanizeからWeb::Scraperを動かすモジュールでなぜ使うかというと、なにかの呪文のような長い長いクエリ文字列を固定で持ちたくないのとTOPページからゴリゴリと掘っていかないとクエリが組み立てられないサイトを楽にスクレイピングしたいのが目的。
今回は長いクエリを楽にする方を

  • 以前は一段階目の検索を行うのに
〜略〜
#   冗談のような長い呪文
Readonly my $QUERY_URL_PREFIX =>
    'https://library.city.iwaki.fukushima.jp/wehome/we/opac/kensakucheck.jsp?'
    . 'kensaku.x=35&kensaku.y=22&sryskb0=1&allsryskb0=1&sryskb1=2&allsryskb1=2&sryskb2=3'
    . '&allsryskb2=3&sryskb_length=3&taisyokan1=0&kanmei_length=6&max_kensu=10&KSKNO1=019'
    . '&KEYWORD1=&ITTI1=1&f_kanzen1=0&ANDOR2=0&KSKNO2=020&KEYWORD2=&ITTI2=1&f_kanzen2=0'
    . '&ANDOR3=0&KSKNO3=005&KEYWORD3=&ITTI3=1&f_kanzen3=0&ANDOR4=0&KSKNO4=070&KEYWORD4='
    . '&ITTI4=1&f_kanzen4=0&tandoku=120&tandoku_keyword='
    ;
Readonly my $QUERY_URL_SUFFIX =>
    '&siborikomi=040&hanni1=&hanni2='
    ;
Readonly my $XPATH_1 =>
    '//p[3]/table/tr/td[3]/a';
〜略〜
scraper {
        process $XPATH_1, \&get_urls;
    }->scrape(URI->new($QUERY_URL_PREFIX . $isbn . $QUERY_URL_SUFFIX));

sub get_urls {
    my $node = shift;
    my $url  = URI->new_abs($node->attr('href'), $DETAIL_URL)->as_string;
    push(@{ $obj->{uri} }, $url);
    scraper {
            process $XPATH_2, \&get_detail;
        }->scrape(URI->new($url));
}

上で定義しているから多少見やすいけれど・・・

  • 今回
〜略〜
Readonly my $QUERY_URI  =>
    "https://library.city.iwaki.fukushima.jp/wehome/we/opac/kensaku.jsp";
Readonly my $DETAIL_URI =>
    'https://library.city.iwaki.fukushima.jp/wehome/we/opac/';
〜略〜
my $mech = WWW::Mechanize::Pluggable->new();
$mech->get( $QUERY_URI );
#   必要な部分だけ穴埋めすればあとはmechがよしなに
$mech->submit_form(
    fields => {
        tandoku_keyword => $isbn,
    }
);

#   詳細ページをリンクがあれば所蔵している
my $uris = $mech->scrape(
            '//a[@href=~/itiranview/]',
            'uris[]',
#   絶対パスに変換
                sub{ URI->new_abs($_->attr('href'), $DETAIL_URI) }
);

コメントにも書いたけれど検索フォームの必要な部分だけセットして終わりというのはすごい楽

今回ちょっとはまったところ(ドキュメントをちゃんと読まないので)

Web::ScraperとWWW::Mechanize::Pluggableとではscrapeする時の構文が違う
Web::Scraper

my $foo = scraper {
    process XPATH, 'bar[]', '@href';
}->scrape(URI->new(URI));

WWW::Mechanize::Pluggable

my $mech = WWW::Mechanize::Pluggable->new();
$mech->get(URI);
my $foo = $mech->scrape(
    XPATH, 'bar[]', '@href'
);

ソース全文

続きを読む

県内図書書像マップの関連図書検索のロジックを変えてみた

WebcatPlusにISBNを喰わせて連想検索を行い、それぞれのISBNと書名をJSONで返す部分を書き直した。
以前、Web::Scraperでスプレイプしようとした時に図書情報のページの構造がtableのネストが多く、また書名やISBNの位置がかなり不定なのでちょっとムリかなと思っていたが、久しぶりにWebcat Plusが吐くHTMLを眺めたら必要な文字列にすべて同一のclass名が付いていることを発見
大雑把にこんな感じにコードを書くと

my $uri = URI->new($ISBN_SEARCH_URL_PREFIX . $isbn . $ISBN_SEARCH_URL_SUFFIX);
print Dump scraper {
    process '//a[@href=~/DocDetail/]',
    'values' => ['@href', \&get_detail];
}->scrape($uri);

sub get_detail{
    my $associative = scraper {
            process '//font[@class="fs100"]',
                'values[]' => 'TEXT';
            result 'values';
        }->scrape($_);
}

まるっといい感じで取れる

---
values:
  - ' フィリップ K.ディック著 ; 友枝康子訳 -- 早川書房, 1989.2, 375p. -- (ハヤカワ文庫 ; SF-807) '
  - '<BA33052047>'
  - ' 所蔵図書館 11館'
    〜 略 〜
  - タイトル
  - ' 流れよわが涙、と警官は言った [ナガレヨ ワガ ナミダ ト ケイカン ワ イッタ] '
  - ' (ハヤカワ文庫 ; SF-807) '
  - 責任表示
  - ' フィリップ K.ディック著 ; 友枝康子訳 '
  - 資料種別
  - ' '
    〜 略 〜
  - 形態事項
  - ' 375p ; 16cm '
  - ISBN
  - ' 4150108072 '
  - 内容著作注記
  - ' '
    〜 略 〜

必ず"タイトル"の後に書名、"ISBN"のあとにISBN(付番されてないときはないが)が存在
頭から配列舐めていって"タイトル"と"ISBN"の次のデータを引っ張るればもってこれる
フラグ作って読み飛ばしとか格好わるいしなと思ったが、List::UtilかList::MoreUtilsに何かあるはず。

first_index BLOCK LIST

Returns the index of the first element in LIST for which the criterion in BLOCK is true. Sets $_ for each item in LIST in turn:

my @list = (1, 4, 3, 2, 4, 6);
printf "item with index %i in list is 4", firstidx { $_ == 4 } @list;
__END__
item with index 1 in list is 4

Returns -1 if no such item could be found.

first_index is an alias for firstidx.

http://search.cpan.org/~vparseval/List-MoreUtils-0.22/lib/List/MoreUtils.pm

で、最終的にこう書いた。多分まだまだ直す余地あり

#!/usr/bin/perl
use FindBin::libs;
use strict;
use warnings;
use Business::ISBN;
use CGI;
use Data::Alias;
use Encode;
use JSON;
use List::MoreUtils qw( first_index );
use Readonly;
use URI;
use Web::Scraper;

Readonly my $ISBN_SEARCH_URL_PREFIX =>
   'http://webcatplus-equal.nii.ac.jp/libportal/EqualFromForm?'
 . 'hdn_from=top&txt_title=&radio_partialMatch=1&txt_author=&'
 . 'authorPartialMatch=0&txt_publisher=&txt_year1=&txt_year2=&'
 . 'txt_isbn='
 ;
Readonly my $ISBN_SEARCH_URL_SUFFIX =>
   '&txt_keyword=&check_book=on&check_magazine=on&'
 . 'select_sorttype=0&select_dmax=10&x=0&y=0'
 ;

#  Associative Search 
Readonly my $ASSOCIATIVE_SEARCH_URL_PREFIX =>
   'http://webcatplus.nii.ac.jp/assoc.cgi?hdn_mode=equal_assoc&'
 . 'select_dmax=20&check_dsel=1%2C'
;
Readonly my $BLANK => q{};

my $json  = new JSON;
$json->pretty;
my $details;
my $obj = {
    flg    => JSON::false,
};

my $q    = new CGI;
my $isbn = $q->param('isbn');

#   ISBNから連想検索用のNCID等を取得
my $uri = URI->new($ISBN_SEARCH_URL_PREFIX . $isbn . $ISBN_SEARCH_URL_SUFFIX);
my $uris = scraper {
                process '//a[@href=~/DocDetail/]',
                    'values' => ['@href', \&get_associative];
                result 'values';
}->scrape($uri);

#   連想検索の結果からそれぞれの詳細ページ
for my $url (@{$uris}) {
    $details
        = scraper {
            process '//font[@class="fs100"]',
                'values[]' => ['TEXT', \&trim];
            result 'values';
          }->scrape($url);

    set_title();
}

#   テーブルが一件でもあればTrue
if ($obj->{series}) {
    $obj->{flg} = JSON::true;
}

print $q->header(-type => "application/x-javascript; charset=utf-8");
print $json->canonical->encode($obj);

sub get_associative{
#   uriの=以降がNCID
    my (undef, $ncid) = split /=/, $_->as_string;
    my $uri = URI->new($ASSOCIATIVE_SEARCH_URL_PREFIX . $ncid);
    my $associative = scraper {
            process '//a[@href=~/DocDetail/]',
                'values[]' => '@href';
            result 'values';
        }->scrape($uri);
}

sub trim {
    my $val = $_;
       $val =~ s{\A \s* | \s* \z}{}gxm;
       $val = encode('UTF-8', $val);
    return $val;
}

sub set_title {
    my $ix   = first_index{ $_ eq 'ISBN' } @{ $details };
    return if @{ $details }[$ix + 1] =~ /^[^\d{9]/;
    my $temp = @{ $details }[ $ix + 1 ];
       $temp =~ s{ \s | [)] }{}gxm;
       $temp =~ s{ [(]      }{>}gxm;
    my @tbls = split /[,]/, $temp;
#   タイトルを取得(読みは除く)
       $ix = first_index{ $_ eq 'タイトル' } @{ $details };
    my ($title, undef) = split /\s\[/, @{ $details }[ $ix + 1 ];

    for my $tbl (@tbls) {
        my ($cd, $sub_ttl) = split /[>]/, $tbl;
        next unless $cd;
#   常にISBN-10に変換(ASINとして用いるため)
        my $isbn = Business::ISBN->new($cd)->as_isbn10->isbn;
        if ($sub_ttl) {
            ${ $obj->{series} }{ $isbn } = $title . ' ' . $sub_ttl;
        }
        else {
            ${ $obj->{series} }{ $isbn } = $title;
        }
    }

}

多分あと二回ぐらいゼロから書き直すと結構マシになりそう

県内図書所蔵マップの初回表示速度をやや改善しました

全面書き換え予定の県内図書所蔵マップですが、一度にリニューアルというのもなかなかしんどいので目立つところだけ修正してみた。
変更前はページ下部の天気予報を表示させるのに、クライアントからサーバを叩いてJSONでもらってDOMを組み立てるというやり方をしていたが、
別にそんな事をしなくても最初に表示されるhtmlにその情報が予め記述してあればそれで済む話。

実現する方法として調べたもの

  1. 動的にWEBページに文章やスクリプトの実行結果を組み込むにはSSI(Server Side Includes)という技術を使う
  2. Perlスクリプトでゴリゴリhtmlを生成しなくてもTemplate-Toolkitを楽にわかりやすく記述できる

トライ&エラー

  • SSI
    1. 借りているサーバ(さくら)だと拡張子が.shtmlであればSSIが動作する
    2. .shtmlに変名すると広報面倒だし、リダイレクトもちょっと
    3. .htaccessに記述すれば.htmlでも動作する
    4. 上記記述をするとすべて一回SSIの判断をするので遅くなる
    5. Xbithackを記述すると、実行権のあるものだけSSIが動作

.htaccessにXbithack fullを記述したらWPも動作しなくなったので、AddType text/html .shtmlでお茶を濁す事にした。

最終的に以下のイメージで表示されて欲しい
一つ目の地方へのリンク、当日の日付、予報画像、翌日の日付、予報画像、翌々日の画像、二つ目の地方のリンク、予報画像、予報画像、予報画像・・・

読み込むデータ(RSS)が、地方毎、日付(当日、翌日、翌々日)

    1. TT側で頑張らないなら、スプリプト側でゴリゴリ判定を入れて初回だったら日付をセットとかゴリゴリする必要がある
    2. TT側で頑張るなら、スクリプト側はベタなテーブルで良い

結果的に第二案でこんなTTとデータ構造に

<div id="Weather">
[% FOREACH d = data -%]
    [% IF loop.first %]
        [% FOREACH l = d.loc -%]
            [% IF loop.first %]
                <a href="#"
                    onClick="return GB_showFullScreen(
                        '[% l.title %]', '[% l.link %]')">
                    [% l.title %]
                </a>
            [% END %]
            [% l.date %]<img src="[% l.img %]"/>
        [% END %]
    [% ELSE %]
        [% FOREACH l = d.loc -%]
            [% IF loop.first %]
                <a href="#"
                    onClick="return GB_showFullScreen(
                        '[% l.title %]', '[% l.link %]')">
                    [% l.title %]
                </a>
            [% END %]
            <img src="[% l.img %]"/>
        [% END -%]
    [% END -%]
[% END -%]
<br>[% desc %]
</div>
$VAR1 = [
          {
            'loc' => [
                       {
                         'link' => 'http://weather.livedoor.com/area/5/20.html?v=1',
                         'date' => '31(Sun)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/10.gif',
                         'title' => '秋田'
                       },
                       {
                         'link' => 'http://weather.livedoor.com/area/5/20.html?v=1',
                         'date' => '01(Mon)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/10.gif',
                         'title' => '秋田'
                       },
                       {
                         'link' => 'http://weather.livedoor.com/area/5/20.html?v=1',
                         'date' => '02(Tue)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/9.gif',
                         'title' => '秋田'
                       }
                     ]
          },
          {
            'loc' => [
                       {
                         'link' => 'http://weather.livedoor.com/area/5/21.html?v=1',
                         'date' => '31(Sun)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/10.gif',
                         'title' => '横手'
                       },
                       {
                         'link' => 'http://weather.livedoor.com/area/5/21.html?v=1',
                         'date' => '01(Mon)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/10.gif',
                         'title' => '横手'
                       },
                       {
                         'link' => 'http://weather.livedoor.com/area/5/21.html?v=1',
                         'date' => '02(Tue)',
                         'img' => 'http://natu-n.sakura.ne.jp/img/9.gif',
                         'title' => '横手'
                       }
                     ]
          }
        ];

備考

    • 最初exec cgiでパラメータが渡せなくて、シンボリックリンクを張って自分の名前がhogeだったらって判断をいれて試したけれどコマンドラインでは上手くいってもブラウザからだと本名になってしまった
    • 結局include virtualであっさり解決(指定するパスの意味するところにちょっと悩んだが)
    • TTのAタグのなかがぐちゃぐちゃしているのは今動作しているGreybox.jsのバージョンが降る過ぎるから、いずれprototype.jsベースからjQueryベースに書き換えるときにThickbox化するので今はこれで