IEgrip

2004年ごろだと思うが、RubyからInternet Explorerを制御するライブラリを公開した。

しばらく特に触ってなかったのだが、久々に使うと、IEの挙動が変わってて、動かない部分が結構でてきた。 そこでGrip Serieseの一つにIEの制御ライブラリを加えてみようと思う。

RubyからIEを制御するときの基本

RubyからIEを制御する目的が情報の取得だとすると、基本は以下の3ステップになる。

  • WIN32OLEを使って、IEをさすオブジェクトを作成し、最初のページを表示する
  • 目的のページに遷移するために、必要に応じて、いくつかの入力項目に必要な値をセットし(検索文字列、セレクタ、チェックボックスなど)、画面遷移を引き起こすボタンやリンクをクリックする
  • 目的のページが開いたら、情報を保持している要素を特定し、ほしい情報(テキスト、画像、ファイルなど)を取得する

以下のコードはGoogleで"Ruby"を検索し、検索結果の件数を得るサンプル。一応、上記の基本の3ステップが入ってる。

  01: require 'win32ole'
  02:
  03: def wait_stable(ie)
  04:   loop do
  05:     sleep 0.5
  06:     p [ie.Busy, ie.ReadyState]
  07:     break if (ie.Busy == false) and (ie.ReadyState == 4)
  08:   end
  09: end
  10:
  11: ie = WIN32OLE.new("InternetExplorer.Application")
  12: ie.navigate("https://www.google.co.jp/")
  13: ie.visible = true
  14: wait_stable(ie)
  15:
  16: # 検索キーワードを入れる<input>を探して、キーワードを入れる
  17: input_element = ie.document.getElementByID("gbqfq")
  18: input_element.value = "Ruby"  # ここでページ構成がガラッと変わる
  19: sleep 1
  20:
  21: # 検索ボタンを見つけてクリック
  22: search_button = ie.document.getElementsByName("btnG").item(0)
  23: search_button.click(true)  # ここでページ遷移
  24: wait_stable(ie)
  25:
  26: result_div = ie.document.getElementByID("resultStats")
  27: puts result_div.innerText
  
  • 11行目のie = WIN32OLE.new("InternetExplorer.Application") でIEを起動。ieがIEそのものを指している。
  • 12行目でIEのnavigate()メソッドを呼んで、Googleのトップページに遷移
  • navigate()メソッドは表示の完了を待たずに抜けてくるので、IEの表示が完了するまで待つために、IE.BusyとIE.ReadyStateというプロパティをチェックしている
  • Googleのトップページでは、検索文字列を入力する<input>のIDが"gbqfq"であることをF12ツールで調べておく
  • 17行目ではHTML文書を指すDocumentオブジェクトをie.documentで取得し、DocumentクラスのgetElementByID()で入力枠の要素を取得している
  • input_elementは<input id="gbqfq" type="text">という要素を指すので、18行目でこれに"Ruby"という文字列をセットしている
  • Googleのトップページではここに文字列を入れると、ページの構成ががらっと変わるようにJavaScriptを仕込んである(ページ遷移はしてないと思う)
  • 22行目ではgetElementsByName("btnG")というメソッドで検索ボタンを探してる。IDで検索できれば簡単なのだが、F12ツールで調べたところ検索ボタンにはIDが指定されてなかったので、 しかたなくname属性で検索している。検索結果は配列のような形で返されるので、1番目の要素を取得。これで検索ボタンの<input name="btnG" type="text">要素を取得できる。
  • 23行目で検索ボタンをクリックしている。IE10からだと思うが、click()の引数には何かをセットしないと、click動作が走らないので注意が必要。引数は何でもいいみたい。
  • 26行目で探しているのは、検索結果に表示される「約 259,000,000 件 (0.15 秒)」の結果を保持している<div>要素。
  • 27行目で要素のinnerTextで結果の文字列を取得して、おしまい。

IEの制御でよく使うメソッド

ieはIEのオブジェクトだとする。

  • 指定ページに移動しないならie.navigate()
  • IEが安定するまで待つときにはie.Busyとie.ReadyStateをチェックするといいんだが、確実ではない
  • 要素を探す起点はie.document。HtmlDocumentElementクラスのオブジェクト
  • IDがわかっているなら、ie.document.getElementByID(id)で目的のHTML要素を探せる。
  • name属性で探すなら、ie.document.getElementsByName(name)で探す。こちらは配列のようなオブジェクトなので、item(index)で特定の要素を探し出す。
  • タグ名で探すなら、ie.document.bodyで<body>を起点にして、ie.document.body.getElementsByTagName("input")のように探す。こちらも配列のようなオブジェクトを返すので、 順序指定のitem(index)で要素を特定するか、eachメソッドでループしながら目的の要素を探し出す

目的の要素が見つかったら、以下のメソッドで情報を取得する。

  • <a>要素のリンク先のURLを取得したいなら、element.href で取得できる(elementが要素であるHtmlElementオブジェクトだとして)。
  • <td>要素の中にセットされている文字列を得たいなら。element.innerTextで取得できる。仮にJavaScriptで動的に文字列を決めている場合でも、innerTextは画面に表示される文字列を返してくれる

IEのクラス構成

IEを制御して、情報を取得するには、IEのクラス構成を抑えておく必要がある。クラス名がわかれば、そのクラスが用意しているメソッドやプロパティを調べればいい。

IEのクラス構成の一次資料は、MicrosoftのWindows Internet Explorer API referenceになると思う。

ただ、これを読んでも、いまいち全体像がつかめない。

HAKUHIN's home pageドキュメントオブジェクトモデル(DOM)についての解説がとてもわかりやすかった。

このページによると、IEのクラス構成は以下のように理解するとよさそう。

IEのクラス構成

あるサンプルコードでオブジェクトのクラス名を記載すると、以下のようになる。

IEのクラス構成

これを踏まえて、Microsoftの解説ページを読めば各クラスのメソッドやプロパティを調べることができる。

IEgripの紹介

WIN32OLEを使えば、Rubyを使ってIEの各種オブジェクトを自由に操作できるのはわかってもらえたと思う。

ただ、見た目はすべてWIN32OLEクラスのオブジェクトに見えるので、ちょっとデバッグしにくい。

そこで、生のIEのオブジェクトをラップする(包む)クラスを用意して、快適にアプリを操作できるしようというのがGripSeriesのコンセプト。 生のオブジェクトをラップしているので、元のオブジェクトが持っているすべてのメソッドが使える上に、独自のメソッドもいくらでも追加できる。

こんな感じのコードが書ける。

  #!ruby -Ks
  require 'iegrip'
  
  ie = IEgrip::IE.new
  ie.navigate("www.yahoo.co.jp")
  p ie.document      # <IEgrip::Document, Name:#document>
  p ie.document.body # <IEgrip::HTMLElement, TAG:body>
  ie.document.body.elements("a").each {|element|
    p element        # <IEgrip::HTMLElement, TAG:a>
  }
  target = ie.document.body.elements("div")[3] # 3つめの<div>要素
  puts target.struct()  # 構造の簡易表示
  
  image = ie.document.body.elements("img")[3]  # 3つめの<img>要素
  p [image, image.href]
  basename = File.basename(image.href)
  image.export("./#{basename}")  # ファイルを保存
  

基本的な使い方

IEgripはIEを制御して、目的の文書を表示させ、その文書内の特定の情報を取り出すためのライブラリである。

gemにおいてあるので、以下のコマンドでインストール可能。

  gem install iegrip
  

基本は、先ほどの3ステップと同じで、目的のページをIE#navigate()で表示させ、その文書内の目的の情報を保持するHTML要素を取り出せば、あとはそこから情報を取り出すことができる。

また、何か入力したり選択してから情報を取り出すために、入力系のHTML要素に値をセットして、ボタンをクリックすればいい。

Yahooのニュースの見出しを拾ってくる

サンプルとして、Yahooのトップページのニュース見出しを拾ってきてみよう。

まずは、さらっと全体の構造を把握してみる。ie.document.body.struct()で簡易的な構造の確認ができる。

  require 'iegrip'
  ie = IEgrip::IE.new
  ie.navigate("www.yahoo.co.jp")
  puts ie.document.body.struct()
  

この出力結果を見ると、ニュースの見出しが<div id='topicsfb'>の中にあり、一番目の<ul>の中に集まっているのがわかる。

  <div id='topicsboxbd'>
    <div id='topicsfb'>
      <div>
        <em text='1時16分更新' />
        <ul>
          <li>
            <a href='http://rdsig.yahoo....' text='xxxx' />
            <span text='写真' />
            <span text='NEW' />
          </li>
          <li>
            <a href='http://rdsig.yahoo....' text='xxxx' />
            <span text='写真' />
          </li>
          <li>
            <a href='http://rdsig.yahoo....' text='xxxx' />
            <span text='NEW' />
          </li>
          <li>
            <a href='http://rdsig.yahoo....' text='xxxx' />
            <span text='写真' />
          </li>
        </ul>
        <ul>
          <li>
            <a href='http://rdsig.yahoo....' text='もっと見る' />
          </li>
          <li>
            <a href='http://rdsig.yahoo....' text='記事一覧' />
          </li>
        </ul>
      </div>
      <div>
        <div>
          <div>
            <a id='tpcsimgfilter' href='http://rdsig.yahoo....' text='xxxx' />
          </div>
          <p>
            <a href='http://rdsig.yahoo....' text='xxxxx' />
          </p>
          <em text='10月18日21時54分配信' />
          <cite text='xxxx' />
        </div>
      </div>
    </div>
    <div id='economyfb' />
    <div id='entertainmentfb' />
    <div id='sportsfb' />
    <div id='othersfb' />
  </div>
  

<div id='topicsfb'>のHTML要素はie.document.body.getElementByID('topicsfb')で手に入る。 一つの文書内でidが重複することはないので、idを指定してくれていれば一発で要素が特定できる。

<div id='topicsfb'>の中は<ul>要素が2つあり、1つめにニュースの見出しが並んでいるのがわかる。この1つめの<ul>を手に入れるには以下のように書く。

  target_div = ie.document.getElementByID("topicsfb")
  target_ul = target_div.elements("ul")[0]
  puts target_ul.struct()
  

ここまできたら、<a>要素を集めてきて、それぞれのinnerTextをもってくればいい。

  target_ul.elements("a").each {|link|
    p link.innerText
  }
  

ちなみに、見出しのリンク先の詳細記事を得たかったら、別のIEオブジェクトを用意して、そいつにリンク先のURLを表示させるといい。

  ie2 = IEgrip::IE.new
  target_ul.elements("a").each {|link|
    p link.innerText
    ie2.navigate(link.href)
  }
  

ie自体にリンク先を表示させてもいいが、いったり戻ったりさせるのはちょっと面倒。 別のIEにページを表示させて、上記の説明と同じ要領で記事の詳細をGetしてくるといい。

ただ、これまでの説明を見ればわかるとおり、IEにページを表示させて、そこから情報を取得するのは、全部決めうちでやっている。 よって、ページの構成がちょっと変わっただけで、スクリプトは動作しなくなるので注意。

フレーム構成のページから情報を得る方法

複数のフレームから構成されているページから情報を得る場合、こんな感じになる。 ちょうどいいフレーム構成のページが無いので感じだけになるが。Frameが手に入ったら、その先のDocumentにアクセスして、後は単一ドキュメントの文書と同じ方法で情報を得られる。

  ie = IEgrip::IE.new
  ie.navigate(url)
  
  index=0
  ie.document.frames.each {|frame|
    puts "-----#{index}-----"
    puts frame.document.struct
    puts ""
    index += 1
  }
  

ただし、Frameが別のドメインの場合なのか、アクセスが拒否されることがある。その場合の対処方法はまだよくわかってない。 (frame.srcでは参照先がつかめないんだよなぁ)

入力項目を操作して検索

いろんな入力項目や選択項目を操作することもできる。

ここでは、Yahooの路線検索を行ってみる。

この手の処理は記入対象の要素を特定し、値をセットしたりクリックしたりするのが基本。 もちろん操作対象の選択は全部決めウチなので、ページの構成がちょっと変わっただけで動かなくなるのでご了承ください。

  #!ruby -Ks
  require 'iegrip'
  
  ie = IEgrip::IE.new
  
  ie.navigate("http://transit.loco.yahoo.co.jp/")
  #puts ie.document.body.struct
  
  from = ie.document.getElementByID('sfrom') # 出発駅
  to = ie.document.getElementByID('sto')  # 到着駅
  from.text = "立川"
  to.text = "三鷹"
  
  ie.document.getElementByID("ym").text = "2014年10月"  # 日時の年月選択
  ie.document.getElementByID("d").text = "20日"    # 日付選択
  
  到着radio = ie.document.getElementByID('tsArr')
  到着radio.click
  
  div = ie.document.getElementbyID("mdRouteSearch")
  toggle_element = div.elements("p").getElementByText("新幹線、飛行機の利用、歩く速度の設定")
  toggle_element.click
  
  sleep 3
  
  検索 = ie.document.getElementByID("searchModuleSubmit")
  検索.click()
  
  • ie.document.body.struct()で全体の構成を把握。F12キーで表示される解析ツールも使って入力項目にidが設定されてないか調べる
  • idが指定されてたら、getElementByID()で入力要素を特定し、入力項目をセットする
  • コンボボックスタイプの選択肢も、選択肢を入力してやればその項目が選択されるようにしている
  • ラジオボタンも選択対象を特定し、click()を呼んでやれば、その項目が選択される
  • JavaScriptなどでイベントを設定されていて、クリックすると選択領域の表示が切り替わる部分があるのだが、getElementByText()を使えば簡単に特定できる。あとはclick()を呼んでやれば、表示が切り替わる
  • 最後に検索ボタンをclickして検索結果を表示するページに遷移させる。あとは最初のサンプルの要領で、ほしい情報を取得すればいい

なお、IE8までは<a>や<input>要素に対してclick()を呼んでやれば、クリック操作したときと同じ動作になっていたのだが、IE10からそのあたりの動作がかわったようで、click()では動作しなくなった。 最初、fireEvent('oncCick')を作用させてみたり、hrefのURLを読み込んでnavigate()させてみたり、<input type='submit'>だったら、親の<form>を探してsubmit()を作用させてみたり、いろいろやった。

が、結局、click(true)のように何か引数をセットすればいいだけだとわかった。なんだかなぁ。

IEの処理の終了タイミングの検出

IEで特定のページを開く時には、ie.navigate(url)のように書く。本来のnavigate()メソッドはページ遷移を行うと、ページの情報をすべて読み込む前にメソッドを抜けてくるのだが、情報取得が目的の 場合、それではやや都合が悪いので、IEgripではnavigaet()やclick()の後に、処理が完了するまで待つようにしている。

が、このIEの処理の完了を確認するのが意外と難しい。

IE.BusyとIE.readyStateを使う方法

基本はこの2つのステータスをチェックする方法。これでうまくいきそうなものだが、確実性はない。サーバの負荷が高いときなどは、よく誤動作して、HTML要素が整う前にこのループを抜けることがある。 そうなると、その要素の情報がとれずに、エラーになってしまう。

  if (@raw_object.Busy == false) and (@raw_object.ReadyState == COMPLETE_STATE)
    puts "*** Complete!"
    break
  end
  

イベントを使う方法

もう一つのやり方として、イベントを使って処理の終了判定をする方法がある。

まず、WIN32OLEでイベントを扱うには、以下のようにする。ieがIEをさすオブジェクトだとする。

  event = WIN32OLE_EVENT.new(ie, "DWebBrowserEvents2")
  event.on_event("DocumentComplete") {|param|
    puts "  DocumentComplete. #{param.LocationURL}"
  }
  event.on_event("NavigateComplete2") {|param|
    puts "  NavigateComplete2. param.LocationURL = #{param.LocationURL}"
  }
  
  • DWebBrowserEvents2はIEの処理関係のイベントをまとめたものの名前
  • eventにはon_eventでイベントハンドラを定義できる

IEでページ遷移をするときには、以下の順序でイベントが発生する。ただし、1つのページ内にはいろいろHTTPリクエストをあげるトリガーがあるので、どのDocumentCompleteが最後なのかを判定するのは難しい。

  • NavigateComplete2
  • DocumentComplete

それぞれのイベントにはパラメータがついてて、そのLocaltionURLをチェックすると、どのURLへのリクエストに対応するイベントかわかる。 ページ遷移直後の最初のNavigateComplete2のLocationURLを覚えておき、そのURLと同じDocumentCompleteが発生したら処理の完了とみなす、という方法があるらしい。

  def wait_stable()
    @location_url = nil
    @complete_flag = nil
    loop do
      break if @complete_flag
      WIN32OLE_EVENT.message_loop
    end
  end
  
  def setup_event()
    @event.on_event("NavigateComplete2") {|param|
      if @location_url  # Keep First location
        puts "  Secondary NavigateComplete2. param.LocationURL = #{param.LocationURL}"
      else
        puts "  First NavigateComplete2. param.LocationURL = #{param.LocationURL}"
        @location_url = param.LocationURL
      end
    }
    @event.on_event("DocumentComplete") {|param|
      puts "  DocumentComplete. #{param.LocationURL}"
      if @location_url == param.LocationURL
        puts "Complete!!!"
        @complete_flag = true
      end
    }
  end
  

こんな感じのメソッドを組んでおき、navigate()やclick()の後に、wait_stable()を呼んでみるようにしてみる。

Yahooにアクセスすると、こんな感じになる。NavigateComplete2とDocumentCompleteが入れ子で発生して、うまく設計通りに動いている。

  wait_stable()開始....
    First NavigateComplete2. param.LocationURL = http://www.yahoo.co.jp/
    Secondary NavigateComplete2. param.LocationURL = http://i.yimg.jp/images/listing/tool....
    DocumentComplete. http://i.yimg.jp/images/listing/tool/yads/yads-iframe-trb.html?t=f&....
    DocumentComplete. http://www.yahoo.co.jp/
  Complete!!!
  wait_stable()完了
  

が、これでもうまくいかないケースがある。フレーム構成のページで、1番目以外のフレームのNavigateComplete2から始まってしまい、目的の情報が格納されているフレームのNavigateComplete2と DocumentCompleteがwait_stable()を抜けた後で発生するということがあった。

結局はsleep()に頼らないといけなくなることもあるのが残念。 なお、sleepするときにはできるだけ頻繁にWIN32OLE_EVENT.message_loopをまわさないと、IEの動作が極端に遅くなるので注意。 (WIN32OLE_EVENT.new(ie, "DWebBrowserEvents2")を実行した時点で、たんなるsleepではだめっぽい)

要素を見つけるまでRetry方式

これまで説明した方法は、navigate()とかclick()を呼んだ後、IEの処理が終わるまで待たせておいて次の処理に進ませるという方法だったが、ちょっと別の方法でIEの処理が終了したことを検出してみる。

IEから情報を取得する場合、特定の要素を指定してそこから情報を取得する。つまり、あらかじめ特定の要素があることがわかっているわけだ。 ということは、IEの処理が終了するタイミングは、目定の要素が取得できたとき、と考えてもいいはずだ。

ということでIEgripを改造して、条件を指定して要素を取得するタイプのメソッドに、リトライの機構を設けてみた。こんな感じで。

  def getElementById(element_id)
    loop do
      raw_element = @raw_object.getElementById(element_id)
      return HTMLElement.new(raw_element, @ie_obj) if raw_element
      sleep 1
    end
  end
  

もちろん、要素があるかどうかを調べて処理を変えることもあるので、リトライの上限は設ける必要がある。 要素を取得するメソッドは結構数があるので、上記のようなリトライ機構をメソッドにしないとめんどう。 ということでこんな感じにしてみた。

  RETRY_INTERVAL = 0.1
  def retryGetTarget(&proc)
    retry_count = (@ie_obj.timeout / RETRY_INTERVAL).to_i
    retry_count.times do
      target = proc.call
      if target
        return target 
      end
      sleep RETRY_INTERVAL
    end
    return nil
  end
  
  def getElementById(element_id)
    retryGetTarget {
      raw_element = @raw_object.getElementById(element_id)
      raw_element ? HTMLElement.new(raw_element, @ie_obj) : nil
    }
  end
  

場所によって、目的の要素が現れるまでの時間も異なるので、リトライの回数も可変にしておく必要があるので@timeoutにタイムアウトまでの時間をセットしておく。

この方法だと、以下のようにサイトの反応が悪くても、sleepを調整する必要があまりない。

  ie.navigate("www.yahoo.co.jp")  # ここでは待ちは行わない
  
  target = ie.document.elements("a")[4]  
  # document, elements()、elements[index]それぞれで何か取得できるまで待ちをいれる
  
  target.click # ここでは待ちは入らない
  

ただ、IEGrip独自のstruct()メソッドだけは、特定の要素を探すわけではないのでIEの終了タイミングを計れないので、sleep待ちを入れる必要はある。

「待ちを入れない」といっていたが、BusyとreadyStateのチェックだけはやっておかないと、IEを指すオブジェクトがDocumentなどのメソッドさえ受け付けない状態があるようで、これだけはやっている。

IEgripのドキュメント

IEgripのドキュメントはこちら

ライセンス

ライセンスはMITのライセンスに従います。

Copyright (C) 2014 Yac <iegrip@tech-notes.org>

This software is released under the MIT License, see LICENSE.txt.

免責事項

本プログラムは無保証です。作者は、プログラム自身のバグ、あるいは、本プログラムの実行など から発生するいかなる損害に対しても責任を持ちません。

変更履歴

2014/9/7

Initial Release