今ごろ scraping に苦労している話

今回、ある HTML の断片を定期的に取得するという要求ができたので喜び勇んで yapra を使えば一発じゃーんとやり始めて恐ろしく苦労したのでその顛末を書いておく。なお、苦労したのは pragger や yapra が問題なのではなく、自分の思い描いていた動作と pragger の思想がズレていたこと及び Hpricot の不備が原因である。pragger ダメじゃんとか言うつもりは毛頭ないです。

まぁバカは要らぬ苦労をするということの記録です。言い直すとやはり道具は使ってみないとクセが分からない。なお、使ったのは yapra の git tip です。1

yapra の conf はちゃんと書きましょう

実は最初のつまずきの話は超簡単で、

存在しない class based plugin を呼び出そうとしていた。

という実にくだらないことでした。以下その trace

./bin/../lib/yapra/pipeline.rb:76:in `run_class_based_plugin':
LoadError (LoadError)
       from ./bin/../lib/yapra/pipeline.rb:52:in `execute_plugin'
       from ./bin/../lib/yapra/pipeline.rb:45:in `run'
       from ./bin/../lib/yapra/inflector.rb:53:in `inject'
..

はい、そのまんまですね。でもぼくはこのメッセージを素直に受け取れなくなっていた。理由は「class based plugin と legacy plugin を一つずつ呼び出していて、class based plugin は絶対に間違いなく load できているはず。」と思い込んでしまったから。

このメッセージは以下の conf で発生していた。

- module: RSS::Save

実はこれ、こう書いていたつもりだった。

- module: RSS::save

お分かりだろうか。s の大文字小文字を間違っているのだ。これに丸1日気づかなかった。というか作者の yuanying さんの手を煩わすまで気づかなかった。ひどい。ひどすぎるぞ > オレ。

この違い、実は RSS::Save と書くと class based plugin の呼び出し、RSS::save と書くと legacy plugin の呼び出しとして動作する。

詳しくは以下に yuanying さんにメールで教えていただいた内容をもとに現時点での自分の理解を書いておきます。

yapra の plugin の load

何のことやら分からない方と自分のためにもう少し詳細を書いておくと、yapra の plugin には

  1. pragger オリジナルの plugin(これを legacy plugin と呼ぶ)
  2. yapra 用で Ruby の標準的な module/class 構造の plugin

の2種類の plugin がある。今回自分は

1 を呼び出しているつもりで間違って 2 を呼び出していたことに気づかず、立ち往生していた

のだ。バカすぎる。ちゃんと 2 の呼び出しに失敗したというメッセージを何回も目にしているのに。で、呼び出しの切り替えはまさに書き間違った大文字、小文字によって行われている。

言い訳すると、Perl や Ruby の常識的には module や package は大文字で始まるじゃないですか。だからぼかぁてっきり plugin の名前は常に大文字で始まるもんだと思い込んじゃってたんですね。

でも違ったのです。pragger の場合は基本的に plugin の名前はメソッドの名前なので、メソッドの名前は当然 Ruby 的には小文字で始まる、結果として pragger 由来の legacy plugin の名前は小文字で始まります。RSS::save の RSS が大文字なのは単にディレクトリ名が大文字だから。そうです、ご想像通りこの plugin の実体は RSS/save.rb に書かれています。

yapra の class based plugin の場合は読んで字のごとく class based であり、class ってことは Ruby 的には定数であり、これは大文字で始まる必要があるわけです。

というわけで legacy plugin の呼び出しと class based plugin の呼び出しを分ける決定的な部分を完全に思い込みでミスっていたので、何が起きているのか正しく読み取れず行き詰まってしまったというわけであります。ちゃんとエラーメッセージはそのまま読まなきゃダメです。はい。

ちなみに yapra の最新版は

rubyforge のページに書かれているように git の方なのだそうです。

yuanying's yapra at master — GitHub

自分は以前 svn co しておいたコードを持っていたので、今回 svn up しただけで作業を始めてしまいましたが、今後は git の方だけでいくことにします。git の操作がよく分かってませんが、どうせ clone と pull しかしないだろう2から、悩むことはあるまい。

ところで今回初めて git を使いましたが、clone の速いこと速いこと。changeset が番号にならないのでどうも直感的な感じがしませんが、このスピードは魅力かも。(pull のスピードには特別感動しなかったので、日常的にはそんなに svn up と差が出ないかもしれませんけど。)

話はまだ終わらない

実はこの件の目的はまだ達成できていません。すいません、バカで。そもそもやりたいことは

  • HTML の一部の情報を抜き出したい

しか決まってません。

これをどう出力したいのかは曖昧なままでした。この状態で Feed::Custom を使い始めました。これが feed を組み立てるためのものだと気づくのに少々時間が掛かりました。オレの目的は単なる scraping で feed の組み立てじゃない気がするなぁと思いながらも、いやいやイマドキみんな plagger/pragger だよ、何ゆってんだよ wtnabe はバカだなぁ。scrape したら feed にするものなのさ、と思い込み、まともに plugin が動いたことに気をよくして作業を進めます。この時点での conf はこんな感じです。

- module: Feed::Custom
  config:
  ..
- module: print

print plugin は受け取った data をそのまま p してくれます。これで Feed::Custom で目的の scraping が行えているかどうかを確認します。

しかしまず XPath による切り出しでつまずきます。目的の node は、

  • あるclass属性を持っている子nodeを含むもの

なのですが、これの書き方が分かりません。子nodeの指定はできますが、目的は子nodeじゃない。XPather と格闘すること数時間。

親node//descendant::子node[@class='FOO']

という書き方で目的を達成できることに気づき狂喜乱舞! しかし、Hpricot でこの書き方が使えないらしく、print で何も出てきません。3

イヤになってきました。

Hpricot は便利だけどある程度いい加減な実装であるということは知っていました。でも最初に引っかかってしまうとは。だいたい、何も出力されないんじゃ何が起きているか分からない。

ここでいったん yapra は諦め、生 Hpricot で再チャレンジすることにしました。descendant:: の書き方が使えないってことは

Hpricot::Doc::search().each { |ele|
  if ( ele.search() )
    ..
  end
}

だよね、と直感的に思ったからです。そして XPath 一発で書けないルールを Feed::Custom 上で再現する方法は知らない。だったらまずは Hpricot で目的の node を取り出すところまでをどうにか動かしてみなくては、と思いました。

これはなんとかなりました。やはり descendant:: の書き方は通じません。うむうむ。状況は把握できた。ここではたと気づきます。

yapra では grep 掛ければいいんじゃなかろうか。

今回の目的は ある node の繰り返しのうち、class="FOO" が設定されている node を取り出す、というものです。これを XPath 一発で書くには descendant:: が必要だけど、とりあえず class があろうがなかろうが切り出しちゃって、class のないものをあとでフィルタで捨てちゃえばいいじゃん、と。つまり、

- module: Feed::Custom
- module: grep
- module: print

で、うまいこと書くと目的の node だけを取り出せるんじゃないかということです。

しかし Feed::Custom のことを自分は分かっていなかった。

Yapra で Pixiv – BONNOH FRACTION 13

だけを頼りにしていたのですが、config: extract_xpath: の中に各 item の個々の要素(link とか)を直接書けることに気づかず、apply_template_after_extraced: の中で組み立て作業を行おうとするもこの時点で渡ってくるのは Hpricot オブジェクトではなく文字列なので、属性値を取り出したい場合は文字列処理が必要です。

「なんかおかしくね?」

と思い始めます。頑張って XPath でやってきて最後は文字列処理なのか?と。せっかくの DOM node, せっかくの Hpricot オブジェクトじゃないのかと。じゃあ extract_xpath の方で頑張れるんじゃないだろうか。

しかし自分の力でできたのはここまででした。

capture: (ry
split: (ry
title: '//node/text()'

そうです。「内容」の取り方は分かったけど、「属性値」の取り方が分からない。XPath は node の指定のためのものだもん。属性値が取りたければやっぱ Hpricot オブジェクトを直接扱えないとダメだよなぁ。と諦めたのが昨日の夜のことでございます。

とりあえず今は生 Hpricot で書いて切り出しだけはできているんですが、今度はこれを feed まで組み立てるのもだるい。(もはや目的が feed の出力だったかどうかすらどうでもよくなっている。)というか yapra に挫折したという事実を受け止めたくない。feed の組み立てなんて自分で書いちゃダメだ、とさえ思い始めています。とりあえず YAML にでも吐き出しておいて、形はあとで考えるかと思ったら

今度は Syck の to_yaml() は日本語を正しく扱えない。もう完全にイヤになりました。

scraping ムズイっす。

たぶん conf だけで解決しようとせずに plugin を書けばいいんですよね。Hpricot を直接いじって、あとは誰か他の plugin に回す。でもまぁ、とりあえず頭を冷やすために放置します。こんなたった数行の処理すらまともに書けないという事実に対する怒りがさらに頭の回転を鈍くしている感じなので。

あと思ったこと

  • WWW::Mechanize はローカルのファイルを読み取れないので scraping 自体のテストをしたいときに不便

自分は今回手元で動かしていた Apache の適当な場所に取得済みの HTML を置いてテストしていたけど、そういう環境を用意できない人はどうすればいいんだろう? 毎回本番のサーバにアクセスしに行くのもイヤだよね。まぁパッチ書いている人もいるけど、パッチである以上は本家の更新で動かなくなる危険性は絶対残っちゃうし。

  • Syck の to_yaml() は結構致命的

なんとなく 1.9 による m17n が進むと解決しそうな気もするけど、DBMS とか使わずにお手軽に他のシステム(言語から全部違うとか)と連携したい場合、標準の Ruby + Syck は使いものにならない。

まぁだからこそ XML である feed を使う plagger 系ツールの出番なのかもしれないけど、まだ使いこなせてないし。あうー。

RubyForge: Ya2YAML - An UTF8 safe YAML dumper: Project Info

使えばいいっていう話ではあるんだけど。なんかこう釈然としないものは残る。

とは言え今回は

  • git
  • yapra
  • WWW::Mechanize
  • Hpricot
  • XPath(完全に初めてではないけど)

と初めて使った道具ばかりで、いい経験になったと言えばいい経験になった。次に繋げよう。というか近日中には目的は達成しないといけないわけだけど。

いちばん早いのは生 Hpricot で進める方向かな…。

  1. tip っていう表現でいいんだよね? svn で言う trunk の head のつもりなんだけど。 

  2. 一応 push できるようになるのは目標の一つですけど。 

  3. なんで Hpricot かっていうと、Feed::Custom の中で WWW::Mechanize を使っていて、Mechanize が Hpricot を使っているからです。 

More