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を通せば済むと思いますが、何かログインが絡む手頃な例が思いつかなかったので。
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();
});
});
}
function launchChrome(url){
return new Promise((resolve, reject)=>{
chromeLauncher.launch({
}).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();
});
});
});
}
function closeChrome(port){
CDP.List({port:port}, (err, targets)=>{
if(!err){
console.log(targets);
targets.forEach((target)=>{
CDP.Close({port:port, id:target.id});
});
}
});
}
launchChrome("https://mail.google.com/").then((env)=>{
const {chrome, devtools} = env;
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();
});
}
const LOGIN_ID_INPUT = "input#identifierId";
const LOGIN_ID_NEXT = "#identifierNext";
const LOGIN_PASS_INPUT = "input[name=password]";
const LOGIN_PASS_NEXT = "#passwordNext";
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:"))
.then((totp)=>sendTOTP(totp))
.then(()=>{ resolve();});
}
else{
resolve();
}
});
});
}
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();
closeChrome(chrome.port);
});
});
ページを開いたときにGoogleのログインフォームが検出されたらログイン処理をします。ユーザー名、パスワード、二段階認証コードをコンソールから受け付けてフォームへ書き込み「次へ」ボタンをクリックしてログインを完了させます。
ログインが済んでいる(済んだ)ならGmailが開くのを待ちます。title要素に未読件数などが表示されるので、これが出るまで一定時間間隔でチェックするようにしました。出たら未読件数の数字部分を取り出して出力します。
Chromeを閉じる処理は最初 chrome-launcher のkill()を使用してみたのですが、2回目以降の起動時にChromeが前回正常に終了しなかったと言われてしまう(クッキーも保存されていない)ので chrome-remote-interface を通して閉じる方法も試しました。
--headless
を指定していないときはこれでうまく動いたのですが、指定したら二つ問題がありました。一つは毎回ログインを要求されること。どうもユーザープロファイルをちゃんと読み込んでくれていないようです。ヘッドレスモードはデフォルトでシークレットモードになるらしいことが関係しているのかもしれません。二つ目はChromeが終了しないこと。 chrome-remote-interface を通して全てのタブを閉じるコード(closeChrome)ではなぜか終了しないみたいです。
参考URL: