2017-09-10

Headless Chromeを試してみる(Node.jsとchrome-remote-interfaceでGoogleログイン)

Chrome 59からヘッドレスモードが標準搭載されたそうです。ヘッドレスモードは、 --headless オプション(現在は --disable-gpu も?)を付けてchromeを起動すると画面無しで起動するというものです。

画面が無いのでどうやって操作するんだという話になるのですが、開発用のリモートAPI(Chrome DevTools Protocol)があるので、それを通して操作することになります。

node.js用のモジュールとして chrome-remote-interface というものがあります。これを使用するとnode.js上のコードからこのリモートAPIを経由してChromeを操作できます。

chrome-remote-interface を使う前にヘッドレスモードでChromeを起動する必要があるのですが、これは "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --disable-gpu --remote-debugging-port=9222 https://www.google.com/ ようにコマンドラインから起動しても良いのですが、 chrome-launcher というnode.jsモジュールもあります。それを使うと次のコードでheadless chromeが起動できます。

const chromeLauncher = require('chrome-launcher');

chromeLauncher.launch({
  startingUrl: "https://google.com",
  chromeFlags: ["--headless", "--disable-gpu"]
}).then(chrome => {
  console.log(`Chrome debugging port running on ${chrome.port}`);
});

chrome-launcher はchromeへのパスを補ってくれるだけでは無く、クリーンなユーザープロファイルを用意するなどの処理も行ってくれます。これは自動テストの時などに便利な場合がありますが、普段使っているプロファイルを元に自動処理をしたい場合には不便かもしれません。一応userDataDirオプションでディレクトリを指定できるようになっています(chromeFlagsに–user-data-dirを直接指定するのは良くないと思われる)。

というわけで、試しにGmailの未読件数を求めるコードを書いてみました。 chrome-launcher でChromeを起動し、 chrome-remote-interface を使用してChromeへアクセスしてGoogleにログインしGmailの未読件数を求めます。GmailなんてAPIを通せば済むと思いますが、何かログインが絡む手頃な例が思いつかなかったので。

//
// chrome-remote-interfaceを使用してGmailの未読件数を求める。
// 2017-09-10
//

const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');

// 標準入力から文字列を受け付ける。
function prompt(question) {
    return new Promise((resolve, reject) => {
        process.stdout.write(question);
        process.stdin.resume();
        process.stdin.once("data", (data) => {
            resolve(data.toString().trim());
            process.stdin.unref(); //call Socket.unref()
        });
    });
}

// Chromeを起動してdevtoolsのドメインを有効にしてurlを開く。
function launchChrome(url){
    return new Promise((resolve, reject)=>{
        chromeLauncher.launch({
            //startingUrl: "https://google.com",
            //chromeFlags: ["--headless", "--disable-gpu"], // <= ヘッドレスモードにするなら指定する。なぜかuserDataDirが効かない? なぜかcloseChrome()が効かない?
            //userDataDir: "c:\\home\\k-aki\\tmp\\chromeuser" // <= 毎回ログインしたくないなら指定する。
        }).then(chrome => {
            console.log(`Chrome pid=${chrome.pid} port=${chrome.port}`);

            CDP({port: chrome.port}, (devtools) => {
                // ドメインを有効にする
                Promise.all([
                    devtools.Page.enable(),
                    devtools.Runtime.enable()
                ]).then(() => {
                    // ページを移動する。
                    devtools.Page.navigate({url: url});
                    devtools.Page.loadEventFired(() => {
                        resolve({chrome: chrome, devtools:devtools});
                    });
                })
            }).on('error', (err) => {
                console.error('Cannot connect to browser:', err);
                chrome.kill();
                reject();
            });
        });
    });
}

// 全てのタブを閉じます。
// chrome-launcherのkill()だとクッキー等が保存されない場合があるので。
function closeChrome(port){
    CDP.List({port:port}, (err, targets)=>{
        if(!err){
            console.log(targets);
            targets.forEach((target)=>{
                CDP.Close({port:port, id:target.id});
            });
        }
    });
}


// Gmailの未読件数を取得する。
launchChrome("https://mail.google.com/").then((env)=>{
    const {chrome, devtools} = env;

    // devtools
    function eval(expr){
        return devtools.Runtime.evaluate({expression: expr});
    }
    function check(cond){
        return eval("!!(" + cond + ")");
    }
    function dqs(selector){
        return ("document.querySelector(\"" + selector + "\")");
    }
    function waitFor(cond){
        return new Promise((resolve, reject)=>{
            function checkCond(){
                check(cond).then((result)=>{
                    if(result.result.value) {
                        resolve();
                    }
                    else {
                        setTimeout(checkCond, 500);
                    }
                });
            }
            checkCond();
        });
    }

    // Googleログイン(2017-09-10)
    //   ID
    const LOGIN_ID_INPUT = "input#identifierId";
    const LOGIN_ID_NEXT = "#identifierNext";
    //   Password
    const LOGIN_PASS_INPUT = "input[name=password]";
    const LOGIN_PASS_NEXT = "#passwordNext";
    //   Time-based One-time Password
    const LOGIN_TOTP_INPUT = "input#totpPin";
    const LOGIN_TOTP_NEXT = "#totpNext";

    function sendLoginEntry(inputSelector, nextButtonSelector, value){
        const js =
            '(()=>{'+
            '    const input = ' + dqs(inputSelector) + ';'+
            '    if(input){'+
            '        input.value = "' + value + '";'+
            '        ' + dqs(nextButtonSelector) + '.click();'+
            '    }'+
            '})()';
        return devtools.Runtime.evaluate({expression: js});
    }
    function sendUserId(id){
        return sendLoginEntry(LOGIN_ID_INPUT, LOGIN_ID_NEXT, id);
    }
    function sendPassword(password){
        return sendLoginEntry(LOGIN_PASS_INPUT, LOGIN_PASS_NEXT, password);
    }
    function sendTOTP(totp){
        return sendLoginEntry(LOGIN_TOTP_INPUT, LOGIN_TOTP_NEXT, totp);
    }
    function loginGoogle(){
        return new Promise((resolve, reject)=>{
            check(dqs(LOGIN_ID_INPUT)).then((result)=>{
                if(result.result.value){
                    prompt("User ID:")
                        .then((id)=>sendUserId(id))
                        .then(()=>waitFor(dqs(LOGIN_PASS_INPUT)))
                        .then(()=>prompt("Password:"))
                        .then((password)=>sendPassword(password))
                        .then(()=>prompt("One-time Password:")) ///@todo TOTPを要求しない場合でもTOTPを受け付けて送信している。
                        .then((totp)=>sendTOTP(totp))
                        .then(()=>{ resolve();});
                }
                else{
                    //already logged in.
                    resolve();
                }
            });
        });
    }

    // for Gmail
    function waitForGmailOpen(){
        return waitFor('document.title != "Gmail"');
    }
    function getUnreadCount(){
        return new Promise((resolve, reject)=>{
            eval("document.title").then((result)=>{
                const title = result.result.value;
                resolve(/\(([0-9]+)\)/.exec(title)[1]);
            });
        });
    }

    loginGoogle()
        .then(()=>waitForGmailOpen())
        .then(()=>getUnreadCount())
        .then((result)=>{
            // 結果を出力する。
            console.log("unread=" + result);
            // 閉じる。
            devtools.close();
            //chrome.kill();
            closeChrome(chrome.port);
        });

});

ページを開いたときにGoogleのログインフォームが検出されたらログイン処理をします。ユーザー名、パスワード、二段階認証コードをコンソールから受け付けてフォームへ書き込み「次へ」ボタンをクリックしてログインを完了させます。

ログインが済んでいる(済んだ)ならGmailが開くのを待ちます。title要素に未読件数などが表示されるので、これが出るまで一定時間間隔でチェックするようにしました。出たら未読件数の数字部分を取り出して出力します。

Chromeを閉じる処理は最初 chrome-launcher のkill()を使用してみたのですが、2回目以降の起動時にChromeが前回正常に終了しなかったと言われてしまう(クッキーも保存されていない)ので chrome-remote-interface を通して閉じる方法も試しました。

--headless を指定していないときはこれでうまく動いたのですが、指定したら二つ問題がありました。一つは毎回ログインを要求されること。どうもユーザープロファイルをちゃんと読み込んでくれていないようです。ヘッドレスモードはデフォルトでシークレットモードになるらしいことが関係しているのかもしれません。二つ目はChromeが終了しないこと。 chrome-remote-interface を通して全てのタブを閉じるコード(closeChrome)ではなぜか終了しないみたいです。

参考URL: