ものすごく久しぶりにブログを書きます、開発チームのハッタです。

Chromeにheadlessモードが追加されてから、Chromeを操作できるライブラリが色々出てきています。
その中でも、ぱっと見で書きやすそうだった2つのライブラリを、
簡単なブラウザテストを例にとって比較してみようと思います。

chromy
https://github.com/OnetapInc/chromy
結構早い時期から公開されていて、Headless Chrome操作のパイオニアといった感じでしょうか。
実行すると、Chromeが起動します。
chromyとは別にChromeのインストールが必要です。


puppeteer
https://github.com/GoogleChrome/puppeteer
Chrome DevToolsの開発チームが作っているとのことで、後発ながら本家と言えるでしょう。
実行すると、chromiumが起動します。
npmでpuppeteerをインストールするとchromiumも同時にインストールされるので、こちらのほうが環境構築の手間がかかりません。


比較用のブラウザテストとして、
弊社コーポレートサイトのサイトマップページにあるURL全てに遷移し、スクリーンショットを撮る
という処理を行ってみます。

0. インストール

// chromy
$ npm i chromy
// puppeteer
$ npm i puppeteer
両者とも普通にnpmで入れるだけですね。
なお、ここから下に書いたコードは Ubuntu 17.10 + Node.js v8.9.1 で動かしています。

1. テストの前にプロセスを確認してみる
上記、各ライブラリの紹介文の中で、
chromy => Chromeが起動
puppeteer => chromiumが起動
と書きましたが、実際にどのようなプロセスが起動しているのか、
インタラクティブモードでスクリプトを実行して確認してみましょう。

まずはchromyから。
const execSync = require('child_process').execSync; // プロセス確認用
const Chromy = require('chromy');
const chromy = new Chromy();
chromy.goto('https://www.raccoon.ne.jp/company/sitemap.html');
console.log(execSync('ps fx').toString());
このようにページを開いてからプロセスを確認すると、
\_ node
\_ /usr/bin/google-chrome-stable ...
| \_ /opt/google/chrome/chrome ...
| | \_ /opt/google/chrome/chrome ...
| \_ /opt/google/chrome/chrome ...
いくつかのChromeプロセスが"--headless"というオプション付きで出てきます。
これらのプロセスを終了させるには、close()処理が必要です。
chromy.close();
console.log(execSync('ps fx').toString()); // => Chromeプロセスは出てこない

次はpuppeteerです。
const puppeteer = require('puppeteer');
var browser;
(async() => {browser = await puppeteer.launch();})(); // ※
console.log(execSync('ps fx').toString());
※rootで実行すると「Running as root without --no-sandbox is not supported.」というエラーが出ます。一般ユーザで実行しましょう。
※一般ユーザでも同様のエラーが出ることがあります。その場合は"--no-sandobox"オプションを付けるか、OSを変えましょう。
 試した中では、CentOS 7.2ではエラーが出ましたが、Ubuntu 17.10では出ませんでした。

chromyはページを開いてからプロセスが作られましたが、
puppeteerはpuppeteer.launch()の時点でchromiumのプロセスが確認できます。こちらも"--headless"オプション付きです。
Chromeではなく、npmでインストールされたcromiumが起動しているのが分かります。
\_ node
\_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...

さらにページを開くためのpageオブジェクトを生成すると、末端のプロセスが1つ増えます。
var page;
(async() => {page = await browser.newPage();})();
console.log(execSync('ps fx').toString());
    ↓
\_ node
\_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...
| \_ /home/techblogger/node_modules/puppeteer/.local-chromium/...

なお、この後に実際にページを開いてもプロセスは変わりません。
page.goto('https://www.raccoon.ne.jp/company/sitemap.html');
console.log(execSync('ps fx').toString()); // => 結果は変わらず
プロセスを終了させるには、chromyと同じようにclose()を行います。
browser.close();
console.log(execSync('ps fx').toString()); // => chromiumプロセスは出てこない

以上でプロセスの確認は終わりです。
次は実際にブラウザテストを実行してみます。

2. サイトマップページを開き、リンク先のURLを全て取得する
とりあえず、サイトマップページ内のリンク先URLをコンソールに出力する処理を書いてみます。
// chromy
(async() => {
const Chromy = require('chromy');
const chromy = new Chromy();
await chromy.goto('https://www.raccoon.ne.jp/company/sitemap.html');
const result = await chromy.evaluate(() => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
);
});
console.log(result);
await chromy.close();
})();
// puppeteer
(async() => {
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.raccoon.ne.jp/company/sitemap.html');
const result = await page.evaluate(() => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
);
});
console.log(result);
await browser.close();
})();
両者はほとんど同じですね。
evaluate()は、ブラウザ内で実行するスクリプトを渡し、結果を取得することができます。
"ブラウザ内で実行するスクリプト"なので、Node.js側で定義した変数などは明示的に渡してあげないと参照できません。
両者ともにREADMEやサンプルコードにはやり方が書いていなかったので、ソースコードを追ったりして調べました。
あまり需要の無い機能なのでしょうか。。

chromyは、evaluate()が内部で呼んでいる、_evaluateWithReplaces()というプライベートっぽい関数を直接実行します。
なおこの関数は、
https://github.com/OnetapInc/chromy/blob/master/src/document.js
に書かれています。
実装内容を読むと、第1引数のソースコードを第3引数で置換して実行、という動きをするので、
文字列を渡す場合は、下記のようにクォーテーションを含んだ文字列で渡さなければいけません。
// chromy
(async() => {
const Chromy = require('chromy');
const chromy = new Chromy();
await chromy.goto('https://www.raccoon.ne.jp/company/sitemap.html');
let arg = 'https://www.raccoon.ne.jp/company/recruit/'; // 採用関連のみ抽出
const result = await chromy._evaluateWithReplaces(() => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
).filter(
a => a.href.startsWith(_arg)
);
}, {}, {'_arg': '\'' + arg + '\''});
console.log(result);
await chromy.close();
})();
puppeteerはevaluate()に下記のように引数を追加することで実現できます。
こちらのほうが簡単ですね。
// puppeteer
(async() => {
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.raccoon.ne.jp/company/sitemap.html');
let arg = 'https://www.raccoon.ne.jp/company/recruit/'; // 採用関連のみ抽出
const result = await page.evaluate((_arg) => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
).filter(
a => a.href.startsWith(_arg)
);
}, arg);
console.log(result);
await browser.close();
})();

変数だけでなく、関数を渡すやり方もあります。
こちらは変数とは逆に、chromyに標準機能として実装されていて、puppeteerのほうがやや力技になります。

chromyはdefineFunction()という関数があり、外部で定義した関数を渡すと、eveluate()の中で使えるようになります。
// chromy
(async() => {
// 採用関連のみ抽出
function _filter(href) {
return href.startsWith('https://www.raccoon.ne.jp/company/recruit/');
}
const Chromy = require('chromy');
const chromy = new Chromy();
await chromy.goto('https://www.raccoon.ne.jp/company/sitemap.html');
await chromy.defineFunction(_filter);
const result = await chromy.evaluate(() => {
let i = 0;
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
).filter(
a => _filter(a.href)
);
});
console.log(result);
await chromy.close();
})();

puppeteerには同じような機能が見つかりませんでしたが、
関数を文字列で渡して、evaluate()の中でeval()で復元、という方法で実現できました。
// puppeteer
(async() => {
// 採用関連のみ抽出
function _filter(href) {
return href.startsWith('https://www.raccoon.ne.jp/company/recruit/');
}
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.raccoon.ne.jp/company/sitemap.html');
const result = await page.evaluate((_filter) => {
eval(_filter);
let i = 0;
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
).filter(
a => _filter(a.href)
);
}, String(_filter));
console.log(result);
await browser.close();
})();

以上が、evaluate()を使ってリンク先URLを取得する方法と、ちょっとした小技です。
次は取得したURLにアクセスして、スクリーンショットを撮ってみます。

3. 取得したURLに1秒インターバルを置いてアクセスし、スクリーンショットを撮る
ブラウザテストを行う際、なるべく人間の動きに近いものにしたかったり、サーバーの負荷を考えたりすると、
ある程度のインターバルを置いて動作するように書かなければいけません。
ここではスクリーンショット保存だけでなく、JavaScriptを書き慣れていないとハマりやすいタイマー処理についても触れたいと思います。

// chromy
(async() => {
const assert = require('assert');
const execSync = require('child_process').execSync;
const fs = require('fs');
const Chromy = require('chromy');
const chromy = new Chromy();
await chromy.goto('https://www.raccoon.ne.jp/company/sitemap.html');
const result = await chromy.evaluate(() => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
);
});

for (let i = 0; i < result.length; i++) {
// ファイルパスとしてNGな文字を置き換え
let path = 'chromy.' +
result[i].text
.replace('/', '/')
.replace('&', '&')
.replace('\n', '_') + '.png';
await chromy.chain()
.goto(result[i].href)
.screenshotDocument()
.result((png) => {
fs.writeFileSync(path, png)
})
.sleep(1000) // 1秒インターバル
.end();

// 最後にプロセスを落とす
if (i == result.length - 1) {
assert(result.length == execSync('ls -l chromy.*.png | wc -l').toString());
console.log('finish');
await chromy.close();
}
}
})();
// puppeteer
(async() => {
function _sleep(msec) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()}, msec)
})
}
const assert = require('assert');
const execSync = require('child_process').execSync;
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.raccoon.ne.jp/company/sitemap.html');
const result = await page.evaluate(() => {
return Array.from(
document.querySelectorAll('div.sitemap-01 > dl > dd > a')
).map(
a => ({href: a.href, text: a.innerText})
);
});

for (let i = 0; i < result.length; i++) {
// ファイルパスとしてNGな文字を置き換え
let path = 'puppeteer.' +
result[i].text
.replace('/', '/')
.replace('&', '&')
.replace('\n', '_') + '.png';
await page.goto(result[i].href);
await page.screenshot({path: path, fullPage: true});
await _sleep(1000); // 1秒インターバル

// 最後にプロセスを落とす
if (i == result.length - 1) {
assert(result.length == execSync('ls -l puppeteer.*.png | wc -l').toString());
console.log('finish');
await browser.close();
}
}
})();

上記例のchromyは、forループの中でメソッドチェーンで書いていますが、puppeteerと同じように1行ずつ await chromy... と書くこともできます。
スクリーンショットを撮る部分はほぼ同じですが、puppeteerはファイル保存までやってくれるのでコード量が少し減ります。
両者の一番の違いはやはりsleep()の有無でしょうか。他言語のsleepと同じように使える機能が標準で実装されていると便利ですね。
自力で実装すると上記のようなコードになります。
大したコード量ではないので、実装してしまうのもいいかもしれません。

4. 性能面
上記1. ~3. の処理で、puppeteerのほうが2~3割処理時間が短いです。
chromiumとChromeの違いでしょうか。
スクリーンショットは、ファイルサイズの微妙な差はありますが、画質等に大きな違いは無いように見えます。

5. 結論
以上、簡単なブラウザテスト用のコードを書いたり、githubのドキュメントを見たりした上での両者の印象は、
chromy => サクッと書いて、簡単にテストを回すならこちら
puppeteer => ゴリゴリ書いて、複雑なスクレイピングなどをやりたいならこちら
といった感じでしょうか。

個人的には、最初に触ったこともあって chromy 推しです。