[Ruby] XPathとか

結局HTML Scrapingですか?

Amazon ECSのシークレットキー(だっけ?うろおぼえ)を入れたままだと、アプリが完成しても公開できない。どうしよう?
いろいろ考えたのだけど、当面はHTMLからガシガシデータを抜きだしていく泥臭いアプローチで行く事に決めました。

一応検討したのは…

  1. Hashを作る部分をObjective-Cにしてその部分はソースを公開しない。ソースは公開するけど、ソースからビルドしたい人は自分でECSのアカウント作ってくださいというスタンス
  2. APIプロシキを使わせてもらう(and/or 自分でも立てる)

という代替案。
1の方は、リバースエンジニアリングされたら簡単にキーをひっこぬかれるだろう、という事でやめた。キーを知られてしまう事がどの程度問題なのかがよくわからないので。前にも書いたな。オープンソースのライセンス的な事は別に問題にならないと思われる。その気になれば別の実行ファイルにすればいいわけだし。プロキシーをアプリに同梱するみたいなイメージでやれば問題なし。

2の方の問題はプライバシー。プロキシーのサーバーにショッピングカートの中身が送られてしまうというのが気になる。個人を特定する情報はIPだけしか送られないので、そんなに問題ないかなと思うけれど、やっぱり気にする人は気にするかもしれない。世の中には「本屋さんで買うのが恥しい本をAmazonで買ってます」という人が意外に多いみたいなので*1。

問題になるかどうかそれ自体よりも、その注意事項を説明する事で「なんか面倒くさそう…」という印象を与えてしまうのがいやなので、この方法はパス。

HTML Scrapingしちゃう事のダウンサイドですが、サーバーに発行するリクエストの量はたいした事ないので*2、アプリ側のパフォーマンス意外の面ではあんまり気にする必要はなさそうです。実はそれもやってみたらたいして変らない事が判明。

XPath便利だよXPath

実は最近仕事でXMLを処理する機会があったので、XPathの使い方を覚えておきたいな、と思ってたところです。で、今回のお題を使って練習。
XPathについては、だいたいの事は知ってたので、細かい点を以下の文書で調べたらだいたい用が足りました。
http://dret.net/lectures/xml-fall06/xpath-chapter.pdf

アイテムの抜きだし

もともと、ショッピングカートのページから各アイテムのへのリンクを抜きだす処理は、けっこうごちゃごちゃになっていました。
まずは元々どんな感じでやってたかを説明しますと…

カートの中のアイテムのIDは

  <a href="http://www.amazon.co.jp/exec/obidos/ASIN/4877832068/ほにゃららら">ここにタイトル</a>

みたいな感じで出現します。この中のASINの次にくる4877832068とかがアイテムのIDなわけです。

なのでページからリンクを全部抜きだして、URLが↑のパターンにマッチするものだけを抜きだせばいい、という方針でやっていました。

ただ、カートのページのアイテムの中にはカートの中のアイテム意外にもアイテムのリンクがたくさんあります(最近チェックした商品とか)。それらと本当に見分けられるかどうかが心配でした。一応パッと見た感じだと、大丈夫そうなんですが…

それと、カートの最初のページには、カートの中の商品(以下Activeといいます)と「今はかわない」にした商品(以下Saved)の、両方のリンクがあります。これらを見分ける安定した方法が欲しいところ。

そこで、注目したのが

<a name="1" />

みたいなリンク。こんな感じのnameの指定が、必ずActiveの商品の直前に置かれています(数字は1番目のアイテムは1、2番目は2という感じで変化)。またSavedの商品には

<a name="s1" />

というnameのアンカーがあります。

Nokogiri#search("a")して

  • まず目的のnameのaタグがくるまでループ
  • nameが見付かったらその種類応じて、次に来るリンクをActiveまたはSavedのアイテムのリンクと認識する

というアプローチを取っていました。

   def parse
     next_item = nil
     @page.parser.search("a").each do |a|
       name = a.get_attribute("name") || ""
       url = a.get_attribute("href") || ""
       case name
       when /^\d+$/
         next_item = :active
         next
       when /^s\d+$/
         next_item = :saved
         next
       end
       if not next_item.nil?
         url =~ %r{/ASIN/([^/]+)/}
         if(next_item == :active)
           @active_items.push AmazonOrganizer::Item.new($1)
         else
           @saved_items.push AmazonOrganizer::Item.new($1)
         end
         next_item = nil
         next
       end
    end
  end

うげー。まあ、どう書いてもある程度ぐだぐだなのはしかたがない。
仕事柄ステートマシンには馴染があるので、こういうコードにはあまり抵抗がなかったり…

問題点

上のやり方の問題点は、価格の情報をとってくるのが難しいところ。
価格は

<b class="price">ï¿¥ 3,360</b><br />

こんな感じで入ってます。珍しくclassが設定されてるので、拾うのは簡単なんですが()、これのある位置をどう見付けるかが問題。
上のアイテムを検出する方法はあくまでaタグを見付けるループの処理でしかないので、見付かった位置から「次にでてくるclassがpriceのbタグ」というのを見付けるのが難しいわけです。

XPathでラクラク

実は上では書いてませんが、カートはtableで構成されてて、各アイテムはtrタグの中にまとまってます。↓みたいな感じ

<tr>
  <td>
    <a name="1"/>
    <input name="saveForLater.1" alt="今は買わない" />
  </td>
  <td>
    <a href="http://www.amazon.co.jp/exec/obidos/ASIN/4877832068/ほにゃららら">ここにタイトル</a>
  </td>
  <td>
    <b class="price">ï¿¥ 3,360</b><br />
  </td>
</tr>

見易いように大分はしょってますが、構造はこういう形。
なので、以下の事ができればリンクも、タイトルも、価格も抜き出す事ができそうです。

  • アイテムを格納しているtrのノードを順番に処理する
  • trタグの下にぶらさがっているタグを抜きだす

まさにXPathが得意としそうな感じではありませんか*3。

ただ、件のname付きaタグは名前が短すぎてちょっと使いづらそうなので、trを見付けてくる目印としてinputタグを使います。nameがsaveForLater.とかmoveToCart.sで始まっているinputがターゲットです。その親の親ノードが問題のtrだという見付け方にしておきます。

実際のコードはこんな感じ

  def parse_item(key)
    item_rows = @page.parser.xpath("//input[starts-with(@name, '#{key}')]/../..")
    item_rows.collect do |row|
      link_to_item = row.xpath("descendant::a[starts-with(@href, 'http://www.amazon.co.jp/exec/obidos/ASIN')]")
      url = link_to_item.xpath("@href").to_s
      asin = %r{/ASIN/([^/]+)/}.match(url)[1]
      title = Iconv.conv("UTF-8", "SJIS", link_to_item.xpath("text()").to_s)

      price_string = row.xpath("descendant::b[@class='price']/text()").to_s
      price = /\d+/.match(price_string.sub(",", ""))[0].to_i

      AmazonOrganizer::Item.new(asin, {:title => title, :price => price})
    end
  end

  def parse
    @active_items += parse_item("saveForLater.")
    @saved_items += parse_item("moveToCart.s")
  end

まず以下の部分がtrタグの一覧をひっぱってきてるところです。@pageはWWW::Mechanize::Pageのインスタンス。parserてのがNokogiriを返してきます。

    item_rows = @page.parser.xpath("//input[starts-with(@name, '#{key}')]/../..")

"../.."というはちょっと汚いかもしれませんね。ancestor方向にtrを探しにいくべきかもしれません。
ま、それはそうと、これだけで、

  • アイテムを格納しているtrのノードを順番に処理する

のところは何とかなってしまいそうなわけです。すごい楽。

  • trタグの下にぶらさがっているタグを抜きだす

の部分も相対パスのXPathを使えば楽々。

このtrタグの中にあるlinkならアイテムのリンクだとわかってるわけですから、安心して使えるわけです。

こんな感じ。

      link_to_item = row.xpath("descendant::a[starts-with(@href, 'http://www.amazon.co.jp/exec/obidos/ASIN')]")
      url = link_to_item.xpath("@href").to_s
      asin = %r{/ASIN/([^/]+)/}.match(url)[1]
      title = Iconv.conv("UTF-8", "SJIS", link_to_item.xpath("text()").to_s)

AmazonのHTMLはSJISなのでタイトルはUTF-8に変換してやる必要があります。

priceの方も簡単にとりだせます。

      price_string = row.xpath("descendant::b[@class='price']/text()").to_s

XPath便利。

しかし、我に返るとなんかまた何をいまさら的なエントリーですね。これも。XPathっていつごろからあるんでしたっけ?
いや、いいの。自分にとってはニュースなので書いた。反省はしてない。

Unit Test

けっこううろ覚えだけど、Unit Testをどうするかで試行錯誤した記憶がある。

基本の方向性としてはMochaを使ってMechanizeの偽物をつくって、あらかじめセーブしておいたHTMLをパーズしたNokogiriのインスタンスを返すという方針。

# Mechanizeの偽物
class DummyAgent
  include Mocha::API
  
  def initialize(testcase)
    @testcase_path = File.dirname(__FILE__) + "/" + testcase
    @page = nil
  end

  attr_reader :page

  # urlに対応する、セーブ済みhtmlファイルを@testcase_pathからreadする
  def get_html(url)
    # ...
  end

  def get(url)
    parser = Nokogiri::HTML.parse(get_html(url), nil, "SJIS")
    @page = stub(:parser => parser, :forms => make_dummy_forms(parser, url), 
                 :form_with => stub_everything)
  end
end

この準備ができてれば、以下のような感じで

class TC_AmazonTest < Test::Unit::TestCase
  def setup_amazon_stub(testcase)
    agent = DummyAgent.new(testcase)
    WWW::Mechanize.expects(:new).returns(agent)
  end

  def test_active_list
    setup_amazon_stub("testcase_dir1")
    amazon = Amazon.new()

    # Mechanizeを使ってあれこれする
    page = amazon.get(AmazonOrganizer::Amazon::URL_SHOPPING_CART)

    # ここがNokogiriを使う処理 (この時NokogiriはテストケースのHTMLで作成されてる)
    page.parse
  end
end

ま、実際のコードとはちょっと違いますが、だいたいこんな感じでうまくテストできてます。

Xpathを持ち出すまでもない時

既に書いた通り、もともとアイテムのIDはHTMLから抜きだしていたのだけど、それ意外の情報はこのIDをもとにAPIから得ていた。
その情報とは

  • タイトル
  • 価格
  • 画像 (smallimage)

で、タイトルと価格はカートから取れるけど、画像だけはやっぱり別の方法で調達する必要がある。
どうすればいいか?

もちろん普通に商品紹介ページを開いて、そこに張られている画像のURLを取ってくればいい。
でも、気付いたのですよ、それを楽にやる方法に。

それは、「モバイル向けページ」

例えば http://www.amazon.co.jp/gp/aw/d.html?a=4798023809

実はPC向けページだとsmallイメージではなくて中サイズのイメージが張られてるので、そっちを使うしかないかな
と思っていた。でも、モバイルページなら都合よく小さいサイズのイメージが貼ってあるではないですか。

という事で

    Net::HTTP.start("www.amazon.co.jp") do |http|
      response = http.get("/gp/aw/d.html?a=#{@asin}")
    end
    page = Nokogiri::HTML.parse(response.body, nil, "SJIS")
    @attr[:smallimage] = page.xpath("//img[contains(@src, '.jpg')]/@src").to_s

楽々。

でもさ、後で気付いたのだけど、さすがにこれはやりすぎだろう。モバイルのページには.jpgは一つしか貼ってない。だから正規表現で十分抜きだせるハズだ。

  /<img src="([^"]+.jpg)">/.match(response.body)[1]

とかそんな感じ(試してないけど)。
まだXpathのままにしてあるけど、そのうち直そう。

*1:イマイチ理解できない考え方だけどなぁ…。オンラインに個人情報つきでバッチリ記録が残る方が嫌じゃないですか?

*2:1アイテムにつき一回だけ、結果はキャッシュされる

*3:という事を考えた時点ではXPathを使った事が一度もなかったので、具体的にどうやるかはわかってなかったのだけど… 結果的には正解でした。