Capybara+PoltergeistでCSVダウンロードをするためにやった(割とマニア向けかもしれない)こと
このCP組み合わせ自体は需要がマニヤックすぎる気が大いにするんだが、個々の要素的には汎用性が高いんじゃないかと。たぶん。
- 対象ページ(サイト)が要ログイン領域
- CSVダウンロードをフォーム送信によって起動
- さらにそのフォームがsubmitではなくaタグクリックによってJS呼び出し、JSによるsubmit()呼び出しでPOST送信
既存のとあるECサイトから販管データをCSVでダウンロードする、これを日次でバッチ化するだけの簡単なお仕事。ログインを要するだけならまだ話は簡単なのだが、そこから先が随分な変態構成で変な涙出た。
まずは骨組みを作る。最初はこのページの記述に沿って組んだ。
Gemfileがこちら。あとで構成管理しやすいように、よっぽどすぐに捨てるの前提なスクリプトでない限りはGemfileを置いてる。
# frozen_string_literal: true source "https://rubygems.org" # gem "rails" gem "capybara" gem "poltergeist"
あとnpmでPhantomJSをインストールしておく。
$ npm install phantomjs -g
ログインページ
# coding: utf-8 require 'capybara' require 'capybara/poltergeist' class CSVBot def self.execute Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, {:js_errors => true, :timeout => 1000 }) end Capybara.default_selector = :xpath session = Capybara::Session.new(:poltergeist) session.driver.headers = { 'User-Agent' => "Mozilla/5.0 (Macintosh; Intel Mac OS X)" } session.visit = "https://xxx.com/admin/" #ログインページに行って session.fill_in "userid", with: "foobar" #IDを入力 session.fill_in "password", with: "******" #パスワードを入力 session.find(:xpath, "//a[@href='yyy']").click #ログインボタンを押す # ... end end
とりあえずここまででログインは完了。サイトによっては「ログインボタンを押す」のところでaタグではなくsubmitボタンへのxpathを通したりする必要があると思われるがそこは適宜。User-Agentも適当でいいと思うよ。
ダウンロードフォームのJavaScriptを「乗っ取り」
<div class="buttonA"><a href="javascript:csvdownload()"><img src="/images/csvdownload.png" class="alpha"></a></div>
function csvdownload() { // フォームパラメータを色々といじってる document.form1.submit(); // 後処理 }
上記のように対象となるフォームがdocument.form1だったのは把握していたので、最初falseを返すonsubmitコールバックを登録して置いて単純にsubmit遷移のみを阻止し、フォーム送信部分は別途処理しようと思っていたら不発。onsubmitはJSからの呼び出し時にはコールバックされないことを見逃していた。
Browsers don't fire the form's onsubmit handler when you manually call form.submit().
さて困った。csvdownload()の内容は送信パラメータの内容を生かすためできるだけ残したい上に、submit()呼び出しの部分はサイト側のJSなので編集することができない。submit()さえ居なければ。お兄ちゃんどいてそ(ry
結局どうしたか。
submit()ごと乗っ取ってしまえ。
# ... session.execute_script(%! document.form1.submit = function() { var kvpairs = []; var f = document.form1; for (var i = 0; i < f.elements.length; i++) { var e = f.elements[i]; kvpairs.push(encodeURIComponent(e.name) + '=' + encodeURIComponent(e.value)); } var q_str = kvpairs.join('&').replace(/%20/g, '+'); var xhr = window._xhrDownloadCSV; xhr.open(f.getAttribute('method'), f.getAttribute('action'), false); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.send(q_str); return false; } window.simulateDownloadCSV = function() { window._xhrDownloadCSV = new XMLHttpRequest(); csvdownload(); return window._xhrDownloadCSV.responseText; } !) csv = session.evaluate_script("simulateDownloadCSV();")
csvdownload()の直前でXMLHttpRequestインスタンスを生成してフォーム送信を代行、乗っ取った偽submit()の中でそのXHRインスタンスを使ったPOSTを送信。csvdownload()でform1のパラメータを準備する処理はそのままなので、準備が整いdocument.form1.submit()に入ったあとにその準備済みのパラメータを取り出してXHRにくれてやる。csvdownload()を抜けたらHTTP応答で取得したresponseTextからCSVを取り出せば良い。
だいぶ托卵感が強いな。
Shift_JISの罠
ここまではそこそこすんなりたどり着けたが、最大の問題はここからである。
ダウンロードしてみると容赦ない文字化けの嵐。どこでエンコーディングミスが発生しているのかが特定できず難儀したがsimulateDownloadCSV()の中でcharCodeAt()などを使って調べるとそもそもJSの中ですでに化けている。そうだった。CSVというくらいで文字コードがUTF-8とは限らないのだった。それでもContent-Typeでcharsetが入っていればまだマシなんじゃないか?と思いつつ応答ヘッダを見る。
HTTP/1.1 200 OK Date: Tue, 03 Jul 2017 08:50:24 GMT Server: Apache X-Frame-Options: SAMEORIGIN Content-Disposition: attachment; filename=download.csv Pragma: private Cache-control: private, must-revalidate Expires: 0 Content-Length: 4825 Connection: close Content-Type: application/octet-stream
まあ、ダウンロードならraw dataを保存するだけcharsetいらないもんね…。やっぱり入ってなかったね…。そりゃエンコーディング判別失敗するよね…。
こういう時のためのoverrideMimeType()である。
XHRでopen()からsend()までの間に、overrideMimeType()を追加して応答時のMIMEタイプを上書きする。Shift_JISデータ列としてXHRに読み取らせることでエンコーディング誤検出を防ぐというもの。
xhr.open(f.getAttribute('method'), f.getAttribute('action'), false); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.overrideMimeType("text/csv; charset=Shift_JIS"); xhr.send(q_str);
これでsimulateDownloadCSV()の返す内容は正常なCSVデータとなり、所定の目的を完遂することができましたとさ。