notebook

都内でWEB系エンジニアやってます。

job形式のファイルダウンロードをスクレイピングでやってみる

この記事はクローラー/Webスクレイピング Advent Calendar 2017 - Qiitaの25日目の記事です。

qiita.com

最近仕事でもスクレイピングをすることが多くなってきました

今回はCSVダウンロードをフロント側でポーリングしてるページにスクレイピングでダウンロードさせてみた話を書きます

実際に本番では使いませんでした(そもそもスクレイピングしない方向になった)が何とかCSV取得するところまで持って行けたので書いておきます

下記のようなCSVダウンロードをスクレイピングでやってみようというお話です

動作環境は capybara, selenium webdriver, headless chromeです

1. (ユーザー)DLボタンクリック
2. (javascript)csv生成のリクエストが送られる(おそらくjob的な感じ)
3. (javascript)一定間隔でcsv生成完了かどうかのステータスを見るリクエストを送る
4. (javascript)完了した段階でレスポンスにCSVのurlが返ってくる
5. (javascript)urlをたたいてcsv取得
6. (ユーザー)ファイル保存のダイアログが出てくるので保存する

こんな感じのフローでダウンロードが行われるページに対してスクレイピングでダウンロードまでやってみます

手順

よくあるのはDLボタンをクリックしたらレスポンスとして'text/csv'のコンテンツが返ってくるパターンですね

そもそもcapybara(selenium webdriver)でダウンロードができるようであれば特に問題はないのですが、色々試したけどうまくいきませんでした

なのでjavascriptが気を利かせて色々やっていくれている部分をなんとかしてCSVを取得します

具体的には下記手順になるかと思います

  1. evaluate_scriptでxhrのメソッドを書き換える
    • 通信内容を記録する
    • 記録した通信内容をreturnする関数を仕込む
  2. capybara経由でDLボタンをクリック
  3. javascript側で色々やってくれたであろうタイミングで仕込んだ関数を呼び出し通信内容を取り出す
  4. 通信内容からCSVのURLをゲット!
  5. Curlで叩いてCSVゲット!

といった感じで取ってきます

まずはxhrの中身を書き換えてみます

xhrのurl履歴を取る

参考

ajax - How to view the last GET HTTP request in JavaScript - Stack Overflow

stackoverflow.com

  • コードを差し込む

ブラウザのconsoleから下記コードを実行します(実際には1行にまとめて突っ込みます)

var ajaxCalls = [];
XMLHttpRequest.prototype._originalOpen = XMLHttpRequest.prototype.open;

XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
   ajaxCalls.push(url);
   this._originalOpen(method, url, async, user, password);
}
  • DLボタンを押す

    • xhrを発生させる
  • consoleからajaxCallsの中身を見る

  • url履歴が入ってる

  • うまー!!!!!

ということで本題のCSVのURLを取得してみます

xhrのレスポンスを取得

window.responses = [];
window.XMLHttpRequest.prototype._originOpen = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function(method, uri, async, user, pass) {
    this.addEventListener("readystatechange", function(event) {
    if(this.readyState == 4){
       var self = this;
       var response = {
         method: method,
         uri: uri,
         responseText: self.responseText
      };
      window.responses.push(response);
    }
  }, false);
  this._originOpen.call(this, method, uri, async, user, pass);
};
window.getResponses = function(){
   return window.responses;
}

上記のコードを差し込んで先ほどと同様の手順でhxrで送っている通信のレスポンスを取得することができました

ここで仕込んだgetResponses関数をevaluate_scriptで実行、中身を取得しruby側でごにょごにょできるようにします

capybara経由でDLできるようにする

あとはcurlでリクエストを投げるだけです

cookieをcapybaraのsessionから取得して突っ込んで認証などを通せるようにします

実際のコードの一部です(対象ページに合わせて修正が必要です)

def click_dl_button

  script = <<-EOS
window.responses = [];
window.XMLHttpRequest.prototype._originOpen = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function(method, uri, async, user, pass) {
    this.addEventListener("readystatechange", function(event) {
    if(this.readyState == 4){
       var self = this;
       var response = {
         method: method,
         uri: uri,
         responseText: self.responseText
      };
      window.responses.push(response);
      console.log(response);
    }
  }, false);
  this._originOpen.call(this, method, uri, async, user, pass);
};
window.getResponses = function(){
   return window.responses;
}
EOS

  session.execute_script(script)
  session.find(:xpath, '//dl-button[.//i[text()="file_download"]]').click
  sleep 30

  json = session.evaluate_script('getResponses()')

  res = json.select{|row| row['responseText'].match('csv.s3.url') }.first['responseText']
  parsed = JSON.parse(res)
  # {"a": "b": "https//csv.s3.url....."}といった感じのレスポンスが返ってくる
  # 取得したURLにunicode文字列が入っていたためunescape処理する
  parsed["a"]["b"].gsub(/\\u([\da-fA-F]{4})/) { [$1].pack('H*').unpack('n*').pack('U*') }
end

def csv2data(csv_url,filename)
  Curl::Easy.download(csv_url, filename){|curl|
    curl.headers["Cookie"] = session.driver.browser.manage.all_cookies.each_with_object([]) {|row, array|
      array.push("#{row[:name]}=#{row[:value]}")
    }.join('; ')
  }
  sleep 1

  raw = ""
  open(filename, "rb:BOM|UTF-16:UTF-8"){|f|
    raw = f.read
  }

  CSV.parse(raw, headers: :first_row)
end

url = click_dl_button
csv = csv2data(url,'sample.csv')

まとめ

案件の途中で方針が変わったのでこの方法はいらなくなったのですがせっかく結構調べたので形になるところまで持っていきました

xhrのメソッドを書き換えて色々やるっていうのは考えたことがなかったので面白いなと思いました