Headless Chrome をさわってみた

Headless Chromeのリリースをうけて、PhantomJS のメンテナーが開発の終了を宣言したりとか、ちょっと話題になった Headless Chrome について試してたことをメモっておく。

試したやつのリポジトリ:https://github.com/cyokodog/headless-chrome

概要

  • ヘッドレス(GUIを表示しない状態)で実行できる Chrome の機能
  • Chromium と Blink が提供する機能をコマンドラインで利用できる
  • Chrome 59 から利用可(2017/06/08時点ではMAC、Linuxのみ)
  • 活用例
    • ウェブページのテスト
      • 表示・動作テスト、画像やPDFによる画面のスクリーンショット
    • スクレイピング
      • 認証が必要なサイトでも対応

ヘッドレスで起動する

  • --headless フラグと --disable-gpu フラグ(そのうち指定不要になる)を指定する
// mac環境の場合
alias chrome='/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome'
chrome --headless --disable-gpu --その他のフラグ

DOM を出力する

  • --dump-dom フラグで document.body.innerHTML が標準出力される
chrome --headless --disable-gpu --dump-dom https://www.pxgrid.com/ > pxgrid-body.html

PDF を作成する

chrome --headless --disable-gpu --print-to-pdf https://www.pxgrid.com/

スクリーンショットを撮る

chrome --headless --disable-gpu --screenshot https://www.pxgrid.com/

# window サイズの指定
chrome --headless --disable-gpu --screenshot --window-size=1280,1696 https://www.pxgrid.com/

リモート接続でデバッグする

  • --remote-debugging-port=9222 フラグで DevTools Protocol が有効化され、リモート接続によるデバッグが可能になる
chrome --headless --disable-gpu --remote-debugging-port=9222 https://www.pxgrid.com/
  • 他のブラウザ(chrome or opera)で、localhost:9222 につなぐと、ヘッドレスで遷移してるウェブページとリモートデバッグ UI が表示される

Node.jsから起動する

  • child_process で実行
  • プラットフォーム毎に chrome の path を変える必要がある
const execFile = require('child_process').execFile;
execFile(

  // chromeのpath(Macの場合のpath)
  '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome',

  // パラメータ
  [
      '--headless',
      '--disable-gpu',
      '--remote-debugging-port=9222',
      url
  ],

  // callback
  (err, stdout, stderr) => {  }
);

Lighthouse の ChromeLauncher で起動する

npm i lighthouse --save
const {ChromeLauncher} = require('lighthouse/lighthouse-cli/chrome-launcher');
const launcher = new ChromeLauncher({
  port: 9222, // remote-debugging-port
  additionalFlags: [
    '--headless',
    '--disable-gpu'
  ]
});
launcher.run()
  .then(() => {
    // ここに処理を書く    
  })
  .catch(err => {
    return launcher.kill().then(
      () => { throw err; },
      console.error
    );
  });
  • chrome を見つけて起動してくれる
  • デフォルトだと Canary を起動しようとする( autoSelectChrome: false で手動指定になる)

  • プログラムから Chrome を操作するには port を指定して DevTools Protocol を有効化する
new ChromeLauncher({
  port: 9222, // remote-debugging-port
  ...

chrome-remote-interface で chrome を操作する

const CDP = require('chrome-remote-interface');
launcher.run().then(() => {

  CDP(async protocol => {

    // 使用する機能を有効化して
    const {Page, Runtime} = protocol;
    await Page.enable();
    await Runtime.enable();

    // 処理を書く

  }).on('error', err => {
    throw Error('Cannot connect to Chrome:' + err);
  });

web ページにスクリプトを追加する

// 画面遷移
await Page.navigate({url: 'http://www.cyokodog.net/'});

// 埋め込むスクリプトをテキストにする
var source = (function(){
  document.addEventListener("DOMContentLoaded", function(){
    if (window.location.href === '...') {
      // 処理
    }
  });
}).toString();

// ページにスクリプトを埋め込む
await Page.addScriptToEvaluateOnLoad({
  scriptSource: `(${source})()`
});
  • Page.addScriptToEvaluateOnLoad() を使用する
  • ページ移動や iframe によるページの読み込みが行われる都度、スクリプトが追加される
    • DOM を参照する場合は、DOMContentLoaded イベント内に処理を記述
    • 必要に応じて処理を適用すべき url を window.location.href でチェックする

追加したスクリプトから値を得る

await Page.navigate({url: 'http://www.cyokodog.net/'});

var source = (function(){
  return document.querySelector('.foo').innerHTML; // 値を返す
}).toString();

// ページロードを待つ
await Page.loadEventFired();

const result = await Runtime.evaluate({ // => { result: { type: 'string', value: 返却値 } }
  expression: `(${source})()`
});
  • Page.loadEventFired() と Runtime.evaluate() を使用する
  • DOMContentLoaded は不要
  • ページ毎に実行する場合は callback に処理を記述する
await Page.loadEventFired(async () => {
  const result = await Runtime.evaluate({
    expression: `(${source})()`
  });
  console.log(result);
});

Node.js から直接 DOM を得る

await Page.navigate({url: 'https://www.pxgrid.com/'});
await Page.loadEventFired();
const doc = await DOM.getDocument();
const hero = await DOM.querySelector({
  nodeId: doc.root.nodeId,
  selector: '.pxg3-hero__text'
});
if (hero.nodeId) {
  const html = await DOM.getOuterHTML({   // => { outerHTML: nodeIdのhtml }
    nodeId: hero.nodeId
  });
}
  • DOM.getDocument() や DOM.querySelector() で要素の nodeId を得て
  • DOM.getOuterHTML() や DOM.getAttributes() でその nodeId に該当する要素の情報を得る

フルサイズでスクリーンショットを撮る

  • (1)画面の表示サイズを適当に設定
  • (2)DOM.getBoxModel() でページの高さを得る
  • (3)Emulation.setVisibleSize() で高さを設定
  • (4)Page.captureScreenshot() でスクショしたデータを得る
const {Page, DOM, Emulation} = protocol;

const format = 'png';
const defaultScreenSize = {
  width: 1440,
  height: 900,
};

await Page.enable();

// (1)画面の表示サイズを適当に設定
await Emulation.setDeviceMetricsOverride(
  Object.assign({
    deviceScaleFactor: 0,
    mobile: false,
    fitWindow: false,
  }, defaultScreenSize)
);
await Emulation.setVisibleSize(defaultScreenSize);

await Page.navigate({url: 'https://www.pxgrid.com/'});
await Page.loadEventFired();

const {root: {nodeId: documentNodeId}} = await DOM.getDocument();
const {nodeId: bodyNodeId} = await DOM.querySelector({
  selector: 'body',
  nodeId: documentNodeId,
});

// (2)DOM.getBoxModel() でページの高さを得る
const {model: {height}} = await DOM.getBoxModel({nodeId: bodyNodeId});

// (3)Emulation.setVisibleSize() で高さを設定
await Emulation.setVisibleSize(
  Object.assign(defaultScreenSize, {height})
);

// バグ対策?これがないとうまくいかない
await Emulation.forceViewport({x: 0, y: 0, scale: 1});

// (4)Page.captureScreenshot() でスクショしたデータを得る
const screenshot = await Page.captureScreenshot({format});

const buffer = new Buffer(screenshot.data, 'base64');
file.writeFile('screen_shot.png', buffer, 'base64', function(err) {
  if (err) {
    console.error(err);
  } else {
    console.log('Screenshot saved');
  }
  protocol.close();
});

スクレイピングしてみた

マークアップが荒いサイトのコンテンツをJSON化する

なんだかとってもなつかしい匂いのする AJSA のウェブサイトからプロ選手のリストを JSON 化して取得してみる。

const CDP = require('chrome-remote-interface');
const {ChromeLauncher} = require('lighthouse/lighthouse-cli/chrome-launcher');
const cheerio = require('cheerio');
const launcher = new ChromeLauncher({
  port: 9222, // remote-debugging-port
  additionalFlags: [
    '--headless',
    '--disable-gpu'
  ]
});

launcher.run().then(() => {

  CDP(async protocol => {
    const {Page, Runtime} = protocol;

    await Page.enable();
    await Runtime.enable();

    // 画面遷移
    await Page.navigate({url: 'http://www.ajsa.jp/pro/2016pro/02_ka.html'});

    // ロードを待つ
    await Page.loadEventFired();

    // HTMLを得る
    let bodyHtml = await evaluate(() => {
      return document.body.innerHTML;
    });

    const skaters = [];
    const $ = cheerio.load(`<div>${bodyHtml}</div>`);
    $('table').each((i, table) => {
      const skater = {
        name: '',
        nameKana: '',
        birthDay: '',
        sponsors: []
      };
      const el = $(table).find('tr').find('td font');
      const text = el.text().replace(/HP or Blog/g,'');
      var arr = text.split('\n')
      var t1 = arr[0];
      skater.name = t1.replace(/\n/g,'').replace(/((.+)/,'');
      skater.nameKana = t1.replace(/\n/g,'').replace(/^(.+)\/(\s*)(.+)(生年月日.+)/,'$3');
      skater.birthDay = t1.replace(/\n/g,'').replace(/^(.+生年月日:)(.*)( 身長.+)$/,'$2').replace(':','');

      arr.shift();
      arr.forEach(item => {
        var v = item.replace('スポンサー:','');
        v.split('/').forEach(item => {
          var v = item.replace(/\t/g,'').replace(/ /g, '').replace(/^\s|\s$/g,'');
          if(v) skater.sponsors.push(v);
        });
      });
      skaters.push(skater);
    });

    console.log(skaters);

    protocol.close();
    launcher.kill();

    async function evaluate(f) {
      const str = f.toString();
      const result = await Runtime.evaluate({expression: `(${str})()`});
      return result.result.value;
    }

  }).on('error', err => {
    throw Error('Cannot connect to Chrome:' + err);
  });
}).catch(err => {
  return launcher.kill().then(
    () => { throw err; },
    console.error
  );
});

こんな感じで取れる。

[ { name: '春日 美夢',
    nameKana: 'かすがみゆ',
    birthDay: '1997.12/30',
    sponsors: [ 'SK8MAFIA', 'e\'S', 'JSLV', 'ムラサキスポーツスペイン坂', 'DICE' ] },
  { name: '金盛 壮一郎',
    nameKana: 'かなもりそういちろう',
    birthDay: '1985.9/18',
    sponsors: [ 'Raccos Burger', ' ムラサキスポーツ倉敷', 'FVK ZIPPERS' ] },
  { name: '兼本理玖',
    nameKana: 'かねもと りく',
    birthDay: '1999.9/28',
    sponsors: 
     [ 'ムラサキスポーツ岡山',
       'IFO',
       'DC',
       'NINJA ',
       'ソースボルト',
       'NAGI skatepark',
       'みやおか接骨院' ] },
  { name: '岸田 義己',
    nameKana: 'きしだ よしき',
    birthDay: '1991.3/13',
    sponsors: [ 'ムラサキスポーツ岸和田', 'ZooYork', 'SATORI' ] },
  { name: '清野玲生',
    nameKana: 'きよのれお',
    birthDay: '2000.10/4',
    sponsors: [ 'ムラサキパーク東京' ] },
  { name: '清原 和也',
    nameKana: 'きよはらかずや',
    birthDay: '1982.4/22',
    sponsors: [ 'Tre\'sb Skateshop', 'REVEL ROYAL' ] },
  { name: '久保田 敏弘',
    nameKana: 'くぼたとしひろ',
    birthDay: '1979.8/16',
    sponsors: [] },
  { name: '桑本 透伍',
    nameKana: 'くわもと とうご',
    birthDay: '1996.5/27',
    sponsors: 
     [ 'ムラサキスポーツ',
       'BONES',
       'PLAN B',
       'PUMA',
       'THEEVE',
       'SKULL CANDY',
       'CRYSTAL GEYSER',
       'http:',
       'ameblo.jp',
       '105-sk8' ] },
  { name: '郡山 智',
    nameKana: 'こおりやまさとし',
    birthDay: '1976.10/16',
    sponsors: [ 'JOCKS', 'NEWS AIR FORCE' ] },
  { name: '小鈴 大和',
    nameKana: 'こすずやまと',
    birthDay: '2000.7/5',
    sponsors: [ 'ELEMENT', 'SRH', 'RONIN EYE WEAR' ] },
  { name: '小林 紗輝',
    nameKana: 'こばやしさき',
    birthDay: '1997.9/18',
    sponsors: [ 'ダブルフェイス', 'SHOE GOO', '久里浜さいとう整骨院' ] } ]

qiita でログインしてトップページのフィードをJSON化して取得する

const account = require("../.account.json");
// {
//   "qiita": {
//     "user": "user_name",
//     "pass": "password"
//   }
// }

const CDP = require('chrome-remote-interface');
const {ChromeLauncher} = require('lighthouse/lighthouse-cli/chrome-launcher');
const cheerio = require('cheerio');
const launcher = new ChromeLauncher({
  port: 9222, // remote-debugging-port
  additionalFlags: [
    '--headless',
    '--disable-gpu'
  ]
});

launcher.run().then(() => {

  CDP(async protocol => {
    const {Page, Runtime} = protocol;

    await Page.enable();
    await Runtime.enable();

    // 画面遷移
    await Page.navigate({url: 'https://qiita.com'});

    // ロードを待つ
    await Page.loadEventFired();

    // ログイン処理
    const loginSrc = `
      document.querySelector('#identity').value = '${account.qiita.user}';
      document.querySelector('#password').value = '${account.qiita.pass}';
      document.querySelector('.landingLoginForm').submit();
    `;
    await Runtime.evaluate({expression: loginSrc});

    // ロードを待つ
    await Page.loadEventFired();

    // SPAの描画を待つ
    await sleep(3000);

    // HTMLを得る
    let result = await evaluate(() => {
      return document.body.innerHTML;
    });

    // リスト部分を抜き出す
    const $ = cheerio.load(result);
    const list = [];
    $('.streamContainer_streams .item-box-title h1 a').each((i, link) => {
      const $link = $(link);
      list.push({
        title: $link.text().trim(),
        url: $link.prop('href').trim()
      });
    });

    console.log(list);

    protocol.close();
    launcher.kill();

    async function sleep(time){
      return new Promise( resolve => {
        setTimeout(async function(){
          resolve();
        }, time);
      });
    }

    async function evaluate(f) {
      const str = f.toString();
      const result = await Runtime.evaluate({expression: `(${str})()`});
      return result.result.value;
    }

  }).on('error', err => {
    throw Error('Cannot connect to Chrome:' + err);
  });
}).catch(err => {
  return launcher.kill().then(
    () => { throw err; },
    console.error
  );
});

こんな感じでとれる。

[ { title: 'Rails + Materialize でCollapsibleの不具合を直した',
    url: 'http://qiita.com/uenoryo/items/e78bae038e25ac743baa' },
  { title: 'macを購入してやったこと',
    url: 'http://qiita.com/_am_/items/189132cf2d1312f42ce3' },
  { title: 'ステーキ定食の画像から物体を選別できないかやってみた - クラスタリング編',
    url: 'http://qiita.com/neriai/items/6df233918513a51a7147' },
  { title: 'Facebookの過去の記事・コメントをすべてぶっこ抜くjavascript(DeveloperTools活用版)',
    url: 'http://qiita.com/nori4k/items/e4c5c0685a0e42dc3d31' },
  { title: 'gulp-sassの起動エラーメモ Error: Node Sass does not yet support your current environment',
    url: 'http://qiita.com/n0bisuke/items/7c683fd70e398ad678b9' },
  { title: '[javascript] Markdown でライトニングトーク用の高橋メソッド式スライドを作る',
    url: 'http://qiita.com/hidao/items/03604a0ada7987125a0e' },
  { title: '逆引きWebpackの使い方',
    url: 'http://qiita.com/bobu_web/items/dc72e19f0e548c484f0e' },
  { title: '逆引きWebpackの使い方',
    url: 'http://qiita.com/bobu_web/items/dc72e19f0e548c484f0e' },
  { title: 'vue ファイルをリント(ESLint)する',
    url: 'http://qiita.com/komatzz/items/3cc7cc34699747efb675' },
  { title: 'JSON形式のNEMトランザクションから送金情報を抽出する。',
    url: 'http://qiita.com/nem_takanobu/items/327a7997587cce9e80f8' },
  { title: '配列を無理やり(破壊的/非破壊的)に操作する',
    url: 'http://qiita.com/jkr_2255/items/59c01b276a2abbfd2cc8' },
  { title: '「 ⌘ + Tab 」まわりの意外と知られていないTips',
    url: 'http://qiita.com/mtmtkzm/items/69e4f217ddaf234a5f18' },
  { title: '#jQuery JSON で SyntaxError',
    url: 'http://qiita.com/komuchan/items/2c49bb8c3c78f9603dee' },
  { title: 'z-indexが言うことを聞いてくれない場合はこの辺のことが原因だと思うよ',
    url: 'http://qiita.com/ykyk1218/items/155ec7a8552da9fb2d8e' },
  { title: 'お前ら今すぐそのCSSフレームワーク使うのやめろ!',
    url: 'http://qiita.com/isuke/items/e132669d54523c934b96' },
  { title: 'Qiita の Markdown では class が使える',
    url: 'http://qiita.com/8x9/items/4f3e120d0f2ee4915ce5' },
  { title: '利用ケースからWeakMapを理解する',
    url: 'http://qiita.com/Anders/items/fca56dcdfedd7bd8e241' },
  { title: 'python3環境にmatplotlibを追加',
    url: 'http://qiita.com/ryuhjyu/items/3c606058fd5a07e4f5a2' },
  { title: 'python3.5環境構築メモ',
    url: 'http://qiita.com/ryuhjyu/items/7ef56b10423fa72f3cbc' },
  { title: 'javascript のコード中に Markdown をヒアドキュメント風に直書きする方法',
    url: 'http://qiita.com/hidao/items/71213093b4f469340664' } ]

参考サイト

気持ち

仕事でも使えるかもなので E2E テストも試してみたい...(けどまだあんまり情報がない)