凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

PhantomJS でログインが必要なページでも自由自在にスクレイピング

はじめに

PhantomJS はヘッドレスな(ブラウザ画面のない)QtWebKit ベースのブラウザで、JavaScriptAPI を通じて、そのブラウザを自由自在にあやつることが出来ます。使用シーンとしては、Jenkins などの CI ツールとの組み合わせによる Web ページの GUI の自動テストや、Web ページのスクリーンキャプチャ、スクレイピングなどが挙げられます。
今回は、ログインが必要なページの情報をパースして自分専用に RSS 化したいなと思い、3つ目のスクレイピング用途をベースに調べてみました。その内容を備忘録として残しておきます。

出来るようになること

  • ログインが必要なページの HTML を取ってくる
  • ログインが必要なページのスクリーンキャプチャを撮る

PhantomJS の導入

Mac
$ brew install phantomjs
Ubuntu
$ sudo aptitude install phantomjs

PhantomJS の基礎

ブラウザの起動とデータの取得とスクリーンキャプチャ

ブラウザを作ってタイトルを調べてみます。

// Headless ブラウザの生成
var page = require('webpage').create();

// URL を開く
page.open('http://www.google.co.jp', function(status) {
	if (status === 'success') {
		// スクリーンキャプチャ
		page.render('google.png');
		// ブラウザ内で JS を実行してデータを受け取る
		var title = page.evaluate(function() {
			var title = document.title;
			return title;
		});
		console.log(title);
	}
	// exit しないと終了しない
	phantom.exit();
});
$ phantomjs hoge.js
Google

キャプチャ画像:

page.open() で URL を開いて、コールバック内で page.evaluate() をし、その中で JavaScript コンソールを使っているような気分で JS を実行、return するとそのデータが外側に返ってくる、という世界観です。

外部 JS の利用

DOM 操作も jQuery を利用できたほうが楽ですよね。includeJs を使うと外部の JS を読み込んで、それを evaluate 内で使用することができます。

// Headless ブラウザの生成
var page = require('webpage').create();

// URL を開く
page.open('http://www.google.co.jp', function(status) {
	// jQuery を使う
	page.includeJs('http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js', function() {
		var title = page.evaluate(function() {
			var title = $('title').text();
			return title;
		});
		console.log(title); // Google
		phantom.exit();
	});
});
evaluate 外の変数を利用

evaluate 内に外の世界の JS の変数を持ち込みたい場合は外部 JS を読みこませれば良いようです。

ページの遷移

ページを遷移するには以下のようにすれば良いようです。

コードを抜粋させていただくと、以下のようになります。

var page = require('webpage').create();

var funcs = [
	function(){
		page.open("http://www.p-rex.net/c15/");
	},
	function(){
		page.render('1.jpg');
		page.open("http://www.p-rex.net/c17/");
	},
	function(){
		page.render('2.jpg');
		page.open("http://www.p-rex.net/c23/");
	},
	function(){
		page.render('3.jpg');
		page.open("http://www.p-rex.net/c36/");
	},
	function(){
		page.render('4.jpg');
	}
];

var recursive = function(i){
	if(i < funcs.length){
		page.onLoadFinished = function(){recursive(i+1);};
		funcs[i]();
	}else{
		phantom.exit();
	}
};

recursive(0);

ここでは page.onLoadFinished にコールバックを登録しておき、ページが読み終わったら順に呼ぶようにしているようです。
しかしながら、onLoadFinished は iframe の読み込み終了時にも呼ばれます、つまり iframe を 10 個使っているページでは 11 回(自分自身 + iframe)呼ばれてしまうことになります。
そこで調べてみたところ、以下のページにて解決法が議論されていました。

gist に貼られたコードを抜粋すると以下のようになります。

var page = require('webpage').create();

page.onInitialized = function() {
  page.evaluate(function(domContentLoadedMsg) {
    document.addEventListener('DOMContentLoaded', function() {
      window.callPhantom('DOMContentLoaded');
    }, false);
  });
};

page.onCallback = function(data) {
  // your code here
  console.log('DOMContentLoaded');
  phantom.exit(0);
};

page.open('http://phantomjs.org/');

PhantomJS では window オブジェクトに callPhantom というメソッドが追加されています。これを呼ぶと外側の世界の page.onCallback を引数を伴って呼ぶことができます。そこでブラウザ側の DOMContentLoaded イベントハンドラをフックしてサーバ側で呼ばれる onCallback を onLoadFinished コールバックの代わりに利用することで、複数回呼ばれてしまう問題を解決しています。
そこで、これを利用して新しいページが読み込まれ次第、登録した関数が順々に実行されていく処理を書いてみました。内容としては、hatena にログインしてその画面をスクリーンキャプチャ、更に HTML を取得したものになっています。

var page = require('webpage').create();
var fs   = require('fs');

// ページが読み込まれたら page.onCallback を呼ぶ
page.onInitialized = function() {
	page.evaluate(function() {
		document.addEventListener('DOMContentLoaded', function() {
			window.callPhantom('DOMContentLoaded');
		}, false);
	});
};

// ページが読み込まれたら登録した関数の配列を順次実行してくれるクラス
var funcs = function(funcs) {
	this.funcs = funcs;
	this.init();
};
funcs.prototype = {
	// ページが読み込まれたら next() を呼ぶ
	init: function() {
		var self = this;
		page.onCallback = function(data){
			if (data === 'DOMContentLoaded') self.next();
		}
	},
	// 登録した関数の配列から1個取り出して実行
	next: function() {
		var func = this.funcs.shift();
		if (func !== undefined) {
			func();
		} else {
			page.onCallback = function(){};
		}
	}
};

// 順次実行する関数
new funcs([
	function() {
		console.log('ログイン処理');
		page.open('https://www.hatena.ne.jp/login'); // 次ページヘ
	},
	function() {
		console.log('ログイン画面');
		page.evaluate(function() {
			document.getElementById('login-name').value = 'はてなの ID';
			document.querySelector('.password').value   = 'パスワード';
			document.querySelector('form').submit(); // 次ページヘ
		});
	},
	function() {
		console.log('ログイン中画面');
		// 自動で次ページヘ
	},
	function() {
		console.log('ログイン後画面');
		page.render('mypage.png');
		// ログイン後の HTML を書き出し
		var html = page.evaluate(function() {
			return document.getElementsByTagName('html')[0].innerHTML;
		});
		fs.write('mypage.html', html, 'w');
		phantom.exit();
	}
]).next();

fs モジュールを使うとファイル書き出しも出来るので、Node.js との連携も簡単ですね。

おわりに

やろうと思ったら色々できてしまいそうなので悪用しないようにお願いします (-人-)。
Twitter で話しかけるとキーワード検索して自動で CHAN-TORU で録画処理を nasne に飛ばすボットとか出来そう。