Itsukaraの日記

最新IT技術を勉強・実践中。最近はDeep Learningに注力。

NodeJS+Seleniumを使った画像ダウンロードで困ったこと

某サイトのスクレイピングのためにNodeJS + Selelinum WebDriverでプログラムを作成したのですが、画像のダウンロードでかなり苦労したので、その顛末をメモしておきます。ブラウザでは画像のダウンロードは簡単にできるのに、こんなに苦労するとは思いませんでした。なお、初心者であることが主な原因なので、慣れた人には当たり前のことかもしれませんが... 

NodeJSでダウンロードしたファイルが画像ではない

NodeJS + Selelinum WebDriverで某サイトで画像ファイルをダウンロードするプログラムで、次のようなコードをnodeで実行したのですが、実行結果のファイルが画像ビューアIrfanViewで開けません。念のためバイナリエディッタで内容を確認してみると、中身はテキストデータで、「お探しのページが見つかりません」といった内容のhtmlになってました。

// Code-1.js
// urlとfnameは仮の値です
var request = require('request');
var fs = require('fs')
var url = "http://aaa.bbb.com/ddd.jpg";
var fname = "ddd.jpg";
request(url).pipe(fs.createWriteStream(fname));

ブラウザ側でもダウンロードできない(Forbidden)

某サイトはログインが必要なので、その関係と思いました。そこで、ファイルのダウンロードはブラウザ側で行う必要があると考え、とりあえず、ブラウザ側で実行するコードの断片を書いてみました。ブラウザで某サイトを表示し、Developer toolsで下記のコードを実行したところ、やはりエラーとなります。

// Code-2.js
// urlは仮の値です
var url = "http://aaa.bbb.com/ddd.jpg";
var xhreq = new XMLHttpRequest();
xhreq.open("GET", url, true);
xhreq.responseType = "blob";
xhreq.onreadystatechange = function() {
  console.log("xhreq.readyState = " + xhreq.readyState + ", xhreq.status = " + xhreq.status);
};
xhreq.onload = function(e) {
   console.log("url loaded");
};
xhreq.send();

Developer toolsに表示されたエラーメッセージは下記です。readyStateが4なので処理は完了しているようですが、statusが200(OK)ではなく0(エラー)になっています。「Forbidden」と出ているので、何らかの制限に引っかかったようです。

GET XHR http://aaa.bbb.com/ddd.jpg          [HTTP/1.1 403 Forbidden 62ms]
xhreq.readyState = 2, xhreq.status = 0
xhreq.readyState = 4, xhreq.status = 0

ブラウザ側でもダウンロードできない(クロスオリジン)

ブラウザで画像ファイルが表示される際のネットワーク通信のヘッダをFireFoxのDeveloper toolsで調べたところ、下記のようになっており、Cookieが付いていました。某サイトはログインが必要なので、その関係と思いました。

Host: samples.dotinstall.com
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:45.0) Gecko/20100101 Firefox/45.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Cookie: aaa=bbb; ccc=ddd; eee=fff
Connection: keep-alive

Cookieをつけるための手段を調べたところ、xhreqのwithCredentialsをtrueにすれば良いとのこと。さっそく、下記のようにコードを修正して試しました。

// Code-3.js
// urlは仮の値です
var url = "http://aaa.bbb.com/ddd.jpg";
var xhreq = new XMLHttpRequest();
xhreq.open("GET", url, true);
xhreq.responseType = "blob";
xhreq.withCredentials = true;
xhreq.onreadystatechange = function() {
  console.log("xhreq.readyState = " + xhreq.readyState + ", xhreq.status = " + xhreq.status);
};
xhreq.onload = function(e) {
   console.log("url loaded");
};
xhreq.send();

しかし、こんどは、「クロスオリジン要求をブロックしました」とのメッセージが出てうまくいきません。

GET XHR http://aaa.bbb.com/ddd.jpg          [HTTP/1.1 200 OK 123ms]
xhreq.readyState = 2, xhreq.status = 0
xhreq.readyState = 4, xhreq.status = 0
クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、
http://aaa.bbb.com/ddd.jpg にあるリモートリソースの読み込みは拒否されます
 (理由: CORS ヘッダ 'Access-Control-Allow-Origin' が足りない)。 <不明>

もともと、画像を引用しているページからDeveloper toolsを開いて、上記コードを実行しているので、うまくいくはずと思っていたのですが... 画像を引用しているページは「http://bbb.com/」で、画像のURLは「http://aaa.bbb.com/」なので、画像のURLは、引用しているページのサブドメインになっているのでうまくいくはずと思っていました。

そこで、メッセージに従って少し調べ、「xhreq.setRequestHeader('Access-Control-Allow-Origin', '*');」をつけてみたのですが... 「Forbidden」というメッセージに戻ってしまいます。

Developer toolsでネットワーク通信ヘッダを調べたところ、なぜか、ヘッダーから「Cookie」がなくなっています。「setRequestHeader("Cookie", "aa=bbb; ccc=ddd; eee=fff");」といった文で無理やりCookieをつけようとしましたが、ヘッダにCookieは付かず、「Forbidden」のままでした。しかも、Developer toolsでよく見ると、「setRequestHeader」を付けた場合は、メソッドが「GET」ではなく「OPTIONS」になっており、ブラウザが画像を表示する場合と異なります。なんか、間違った方向に向かっている感じです。

画像はダウンロードできたが...

「クロスオリジン」について、もっと調べてみました。画像のURL「http://aaa.bbb.com/」は、画像を引用しているページ「http://bbb.com/」のサブドメインになっていますが、サブドメインでも別ドメインとして扱われるとのこと。通常は、サブドメインに対するアクセス要求も許可するようにサーバ側の.htaccessで設定することが多いようですが、某サイトでは、サブドメインへのクロスオリジン要求は拒否しているようです。

それならば、「ブラウザで『http://aaa.bbb.com/ddd.jpg』を表示させ、そのページでスクリプトを実行すれば良いのでは?」と考えて、Developer toolsで試したところ、Code-3.jsでうまくダウンロードできました。下記が実行結果です。

GET XHR http://aaa.bbb.com/ddd.jpg          [HTTP/1.1 200 OK 121ms]
xhreq.readyState = 2, xhreq.status = 200
xhreq.readyState = 3, xhreq.status = 200
xhreq.readyState = 4, xhreq.status = 200
url loaded

statusがちゃんと200になっており、Developer toolsで確認すると、xhreq.responseに結果が設定されています。

> xhreq.response
Blob { size: 44459, type: "image/jpeg" }

これに基づいて、下記のコードを書きました。ブラウザから「xhreq.response」をnodeに返し、node側でファイルに書き込んだところ、なぜか、ファイルのサイズが1KBしかなく、中身はテキストで"null"となっていました。

// Code-4.js
// urlは仮の値です
var webdriver = require('selenium-webdriver'),
    By = webdriver.By,
    until = webdriver.until;
var driver = new webdriver.Builder().forBrowser('firefox').build();
// ログイン処理(省略)

var url = "http://aaa.bbb.com/ddd.jpg";
driver.manage().timeouts().setScriptTimeout(10000);
driver.get(url).then(function() {
  driver.executeAsyncScript(get_image, url).then(function(content) {
    require('fs').writeFile("ddd.jpg", content);
  });
});

function get_image(url) {
  var callback = arguments[arguments.length - 1];
  var xhreq = new XMLHttpRequest();
  xhreq.open("GET", url, true);
  xhreq.responseType = "blob";
  xhreq.withCredentials = true;
  xhreq.onload = function(e) {
     callback(xhreq.request);
  };
  xhreq.send();
}

画像ファイルのダウンロード完了!

ファイルに書きだす前のcontentの値をconsole.logで出力したところ、これも「null」になっていました。Blobは、ブラウザ側からreturnできない模様です。

また、writeFileの仕様をよく見ると、writeFileの第2引数は、StringまたはBufferしか使えないとのこと。任意のデータを書きだせるわけではないようです。

更に、ブラウザ側ではBufferは使えないようです。代わりにArrayBufferというものがあります。

nodeにreturnできる形式にブラウザ側で変換し、これを、node側でBufferに変換する必要があります。どの形式に変換するのが良いか少し悩みました。

base64に変換するのが良さそうですが、ブラウザ側に送る関数の中にbase64エンコード処理を入れる必要があり少し面倒ですし、無駄なコードは書きたきたくないと考えました。

Webでいろいろ調べたところ、XMLHttpRequestではBlob以外にArrayBufferも指定でき、ArrayBufferならば直ぐにUint8Arrayに変換でき、Unit8Arrayはnodeにreturnできることが分かりました。結局下記のようなコードでうまくいきました。なお、色々確認したところ、withCredentialsを設定しなくてもCookieは自動的に付くようなので、設定を削ってます。

// Code-5.js
// urlは仮の値です
var webdriver = require('selenium-webdriver'),
    By = webdriver.By,
    until = webdriver.until;
var driver = new webdriver.Builder().forBrowser('firefox').build();
// ログイン処理(省略)

var url = "http://aaa.bbb.com/ddd.jpg";
driver.manage().timeouts().setScriptTimeout(10000);
driver.get(url).then(function() {
  driver.executeAsyncScript(get_image, url).then(function(content) {
    require('fs').writeFile("ddd.jpg", new Buffer(content));
  });
});

function get_image(url) {
  var callback = arguments[arguments.length - 1];
  var xhreq = new XMLHttpRequest();
  xhreq.open("GET", url, true);
  xhreq.responseType = "arraybuffer";
  xhreq.onload = function(e) {
     callback(new Uint8Array(xhreq.response));
  };
  xhreq.send();
}

ちなみに、JSON.stringify()が使えないか試したのですが、BlobもArrayBufferも、JSON.stringify()での変換結果はnullになってしまいます。

あとがき

今回学んだ点は下記です。

  • ファイルをダウンロードしたら、中身が正しいか確認すべき。
    • ファイルは作成されるが、中身がテキストで「お探しのページが見つかりません」だったり、"null"だったり、誤った内容の可能性がある。
  • nodeでダウンロードできないファイルは、ブラウザ側でダウンロードする。
  • クロスオリジン問題は、対象データをブラウザで表示させ、そのページでXMLHttpRequestでデータを取得すれば解決できる。
  • テキスト以外のデータをブラウザからnodeにreturnする場合は、一度Uint8Arrayに変換するのがが良い(手間が掛らない)。
  • fs.writeFileは、StringまたはBufferしか書き出せないので要注意。

nodeのfsの仕様は十分に確認していませんでした。色々と場当たり的に調べながらやっていますが、基礎知識がないと、つまらないところでつまずいて時間がかかってしまいますね。やはり、体系的に勉強した方が良さそうです。(Javascript 第6版は一応目を通したつもりではありますが、1回読んだだけでは頭に残っていない...)