Node.jsとPlaywrightでFirefoxを自動操縦する

これまで社内向けにPuppeteerにてChromeを自動操縦するアプリケーションを複数作成してきました。その自動化の結果として利用者の作業負担が減るとともに、ミスが減り余計なサイトの操作をする学習コストも減らすことが出来ました。

しかし、一部のPCで原因不明のPage CrashエラーSTATUS_STACK_BUFFER_OVERRUNのエラーが出るケースがあり、それをきっかけとして、Puppeteerと同じ源流を持つMicrosoftのPlaywrightを使ってみる事にしました。今回はファイルのダウンロードをするコードを記述してます。

今回使用するライブラリ等

Playwrightは本来Typescriptで記述するのが定石のようですが、通常のJavaScriptであっても利用は可能です。よって、今回は上記のライブラリだけをインストールして利用します。プロジェクトに対して以下のコマンドでインストールしておきます。

※puppeteer-coreがFirefox操作に対応したのでpuppeteer-firefoxは廃止されました。

npm i -D @playwright/test

もしくは

npm i playwright

また、Puppeteerと一定の互換性があるため、流儀やコードはそのまま利用可能な部分も非常に多いです。Puppeteerについては以下のエントリーを参考にしてみてください。

playwright-firefoxを入れておかないと初回起動時にエラーが出ます。Firefox Nightlyを使いたい場合は、playwright-firefoxもnpmで入れておきましょう(@playwright/testではなくplaywrightの場合、npmでインストール時に3つのコアをダウンロードしてくれますので不要です)

npmでインストールした場合、「C:\Users\ユーザ名\AppData\Local\ms-playwright」にfirefoxはダウンロードされます。

Node.jsとPuppeteerでChromeを自動操縦する

Playwrightについて

概要

PuppetterとPlaywrightはどうやら同じ作者さんが、それぞれGoogleに居たとき、Microsoftに転籍してから作ったプロダクトのようで、非常に親しい存在でありながら、かなり毛色の違う点もあります。

  • Puppeteerと異なり、FirefoxやSafari(Webkit)の操縦もすることが可能
  • 通常のNode.jsだけじゃなく、TypeScriptやPython, Java, .NET等でも利用する事も可能

しかし、親しいだけで完全互換ではありません。特に今回のファイルのダウンロード等に関しては、仕様が大分異なる点もあります。また、ドキュメント量に関しては正直、Playwrightは後発ということもあって、Puppeteerと比較すると少ないのが難点です。

※現在、PuppeteerでもFirefox Nightlyを操作出来ますが、Playwrightの場合Firefox Nightlyの改造版(自動ダウンロードされる)を操作することになります。

図:FireFoxの改造版を動作してる様子

図:Firefoxの別途インストールを要求

Electron等で配布するような場合

現在、exectablePathで指定出来るのは、EdgeとChromiumのみで、FirefoxやSafariはexeを指定しても動作しません。指定しても通常のFireFoxでは以下のようなエラーが出て起動できません。Nightlyをインストールしても同様のエラーで、jugglerという特殊なバージョンでなければ動作しないようです。

(node:17896) UnhandledPromiseRejectionWarning: browserType.launch: Protocol error (Browser.enable): Browser closed.
==================== Browser output: ====================
<launching> C:\Users\ユーザ名\AppData\Local\Mozilla Firefox\firefox.exe -no-remote -wait-for-browser -foreground -profile C:\Users\ユーザ名\AppData\Local\Temp\playwright_firefoxdev_profile-XPREVJ -juggler-pipe -silent
<launched> pid=15920
[pid=15920][err]
[pid=15920][err] ###!!! [Parent][PGPUParent] Error: RunMessage(msgname=PGPU::Msg_ShutdownVR) Channel closing: too late to send/recv, messages will be lost
[pid=15920][err]
=========================== logs ===========================
<launching> C:\Users\ユーザ名\AppData\Local\Mozilla Firefox\firefox.exe -no-remote -wait-for-browser -foreground -profile C:\Users\ユーザ名\AppData\Local\Temp\playwright_firefoxdev_profile-XPREVJ -juggler-pipe -silent
<launched> pid=15920
[pid=15920][err]
[pid=15920][err] ###!!! [Parent][PGPUParent] Error: RunMessage(msgname=PGPU::Msg_ShutdownVR) Channel closing: too late to send/recv, messages will be lost
[pid=15920][err]
============================================================
    at puppetrun (C:\Users\ユーザ名\Documents\tscheck-win32-x64\resources\app\index.js:11499:35)
    at C:\Users\ユーザ名\Documents\tscheck-win32-x64\resources\app\index.js:10699:9
(Use `electron --trace-warnings ...` to show where the warning was created)
(node:17896) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:17896) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
(node:17896) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

よって、Electron等にPlaywrightを組み込んで配布するようなプログラムの場合は、クライアントPCにてnpmでインストール等当然できないので、以下のような措置が必要となります。

  1. C:\Users\ユーザ名\AppData\Local\ms-playwright\firefox-1323\firefox」の場所にFirefox Jugglerがインストールされてる必要性がある
  2. 1.のファイル群は開発マシンの中にだけ存在するので、上記のファイル群をパッケージにして別途配布する。
  3. 1.のケースの場合、exectablePathにてfirefox.exeを指定しても動作します

コードと解説

ソースコード

Puppeteerと大きくことなる点がいくつかあるので、そこを注意しながら記述する必要があります。

//firefoxで起動する
const { firefox } = require('@playwright/test');

//ファイルの読み書きモジュール
const fs = require('fs');

//デスクトップのパスを取得
var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];
var dir_desktop = require("path").join(dir_home, "Desktop"); 

(async () => {
	//ブラウザオプションを指定する
	const browser = await firefox.launch({
		acceptDownloads: true,        //ダウンロードの許可
		downloadsPath: dir_desktop,   //ダウンロード先の指定
		headless : false,
		slowMo:500
	});

	//新しいページを追加
	const page = await browser.newPage();

	try {
		//ブランクページを開く
		await page.goto('about:blank');

		//ダウンロードイベントを定義
		var event = page.waitForEvent('download');

		//ファイルを開いてダウンロードする
		await page.goto('https://officeforest.org/wp/library/note.odg');

		//ダウンロードイベントを待機
		var download = await event;
		var downloadError = await download.failure();
		if (downloadError != null) throw new Error(downloadError);

		//ダウンロードしたテンポラリファイルのパスを指定
		await sleep(3000);
		var result = await download.path();
		console.log('Download started  to:' + result);

		//テンポラリファイルをリネームする
		fs.renameSync(result,dir_desktop+"\\test.odg");

		//ブラウザを閉じる
		await browser.close();
	} catch (e) {
		//エラー発生時の処理
		console.error('CAUGHT JS-EXCEPTION: ' + e.name + '\n' + 'MESSAGE: ' + e.message + '\n' + 'LINE: ' + e.lineNumber + '\n' + 'STACK: ' + e.stack + '\n');
	}

})();

//スリープする処理
function sleep(milliSeconds) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, milliSeconds);
  });
}

解説

どのブラウザを利用するか?を決めるのが1行目。変数名でfirefoxを指定するとFirefoxを利用する事が可能になります。Firefox自体は初回に自動ダウンロードされるため、ユーザが別途インストールする必要はありません(またこのときダウンロードされるのは、Firefox Nightlyになります)

webkitを指定すればSafariを、Chromiumを指定すればChromiumを操作するようになります。

次に、browserの定義でfirefox.launchにオプション指定することが可能です。ダウンロードをさせる場合には以下の2つの指定が必要です。Puppeteerの場合は、cdpsessionにてdownload先の変更をしていましたが、Playwrightの場合はオプション指定で変更が必要になっています。

acceptDownloads: true, //ダウンロードの許可
downloadsPath: dir_desktop, //ダウンロード先の指定

ダウンロードについても単純にボタンをクリックして終わり・・・ではなく、イベントの定義が必要となっていて、定義をしておき、その結果としてダウンロードされるファイルは通常のファイルではなくテンポラリファイルになっています。よって、fs.renameSyncにてそれを本来のファイル名にリネームする処理を追加しています。パスは変数downloadの中に格納されるので、download.path()でフルパスを取得することが可能となっています。

page.waitForEvent('download')が、ダウンロード待機できるのは、デフォルトでは30秒までなので、操作が多いコードの上部でセットしてしまうと操作中にTimeoutしてしまうので、ダウンロード直前にセットすると良いでしょう。またTimeoutの指定秒数を指定する事も可能です。

//ダウンロードイベントを定義
var event = page.waitForEvent('download',{ timeout: 90000 });

ややこしい反面、きちんとダウンロード待機してくれるので、コード自体はPuppeteerよりもスッキリしてると思います。また、公式サイトにはPromiseでダウンロードボタンをクリックして完了するまでを括って、ダウンロード完了待ちの事例が掲載されています。

(async () => {
    const browser = await playwright['chromium'].launch();
    const context = await browser.newContext({ acceptDownloads: true });
    const page = await context.newPage();
    await page.goto(pageWithFiles);

    const [ download ] = await Promise.all([
        page.waitForEvent('download'), // wait for download to start
        page.click('.download-button')
    ]);

    // save into the desired path
    await download.saveAs(reliablePath);
    // wait for the download and delete the temporary file
    await download.delete()
    await browser.close();
})();

Puppeteerを使ってボタンクリックとダウンロード

viewportの指定

上記のコードではブラウザの画面サイズ等していせずに起動していますが、予め開くサイズを指定した状態で起動させる事も可能。以下のようにviewportを指定する事が可能です。Puppeteerとは指定方法が異なる点の1つです。

const browser = await firefox.launch({
		acceptDownloads: true,        //ダウンロードの許可
		downloadsPath: dir_desktop,   //ダウンロード先の指定
		headless : false,
		slowMo:500
});

//viewportの指定
const context = await browser.newContext({
	viewport: {
		width : 1200,
		height: 800
	}
});

//pageの定義
const page = await context.newPage();

pkgでEXE化できるのか?

Puppeteerの場合、puppeteer-coreを利用していたので、pkgにて単体のEXE化するにあたって以下のコマンドで簡単にパッケージにできました。ブラウザコンポーネントを含んでいないので、node_modulesなどがパッケージにされずといったこともありませんでした。

pkg index.js -t win --public

しかし、今回のplaywrightの場合、バイナリのEXEが含まれている為、@playwright/testの場合はCannot include directory %1のエラーが出て失敗。playwrightの場合はUnexpected characterのエラーが出て失敗。

playwright-coreの場合、インストール済みのブラウザは利用できるものの、firefoxはexectablePathで指定ができない為使えません。

よって現時点ではEXEでパッケージにしてどうこうはできそうにないです。

タイムアウト設定

Puppeteerの場合、page.setDefaultNavigationTimeout(90000);とするだけで、デフォルトのタイムアウト設定を90秒に指定することが可能です。Playwrightの場合も同様の設定が出来るのですが、通常は以下のようにBrowserContextに対してオプション指定するようです。

//Browserを指定
const browser = await firefox.launch({
		headless : false,
		slowMo:500
});

//コンテキスト取得
const browserContext = await browser.newContext();

//タイムアウト指定
browserContext.setDefaultNavigationTimeout(90000)

他にも、browserContext.setDefaultTimeoutなどがありますが、page.setDefaultNavigationTimeout, page.setDefaultTimeout(timeout), browserContext.setDefaultNavigationTimeoutが優先されるようになっているみたい。

他の操縦手段

puppeteer-coreで操縦

前述にもあるように、FireFoxを操縦出来るといっても、現状通常配布されてるFireFoxが動くのではなく、jugglerと呼ばれる特殊なバージョンが必要で、しかもこれがnpmでなければ入手ができない。これでは配布するにはこの版を別途用意して配布するインストーラ等に含めなければならず、実用的ではないです。

調べてみると、playwrightにはpuppeteer-coreのようにplaywright-coreと呼ばれる版も存在し、なおかつpuppeteer-coreでも現在はfirefox Nightlyが操縦できるそうで。puppeteer-coreの場合は、オプションとして以下のように指定すると操縦が可能になる。

var firefoxpath = "C:\\Users\\ユーザ名\\AppData\\Local\\Firefox Nightly\\firefox.exe"

var browser = await puppeteer.launch({
            headless: false,
            product: "firefox",
            executablePath: firefoxpath,
            //userDataDir: path.join(userHome, "AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\ppce0615.default-nightly-1668556568513"),
            args: ['-wait-for-browser'],
            ignoreDefaultArgs: ["--guest",'--disable-extensions','--start-fullscreen','--incognito', '--no-sandbox'],
            slowMo: 250,
            dumpio: true,
});
  • userDataDirを指定しろということが言われてるのですがこれが、変動するので、今調査中(指定していなくても動く)
  • argsで['-wait-for-browser']を指定しないと起動する前に次の処理に行ってしまうので必ず指定します。
  • 管理者がインストールしてて排除できない拡張機能などがある場合、クラッシュするのでその場合は--disable-extensionsを外しましょう。ただし、skyseaの拡張機能が入ってると必ずクラッシュするようです。
  • puppeteer-coreが新しすぎると最新のNightly Buildでクラッシュするので、現在自分はpuppeteer-core@13.2.0をインストールして社内運用でも問題なく動作しています。
  • Chromeの場合正常に動作する「await page.waitForNavigation({waitUntil:"networkidle2"})」がFirefoxの場合正常に動作しません。まだサポートされていないようです。

FireFox Nightlyはインストーラを実行すると最初に管理者権限を要求されるものの、キャンセルする事でユーザディレクトリにインストールしてくれるので、「C:\Users\ユーザ名\AppData\Local\Firefox Nightly」にインストールされるようになりますので、これをexecutablePathに指定します。管理者権限の場合は普通にc:\program files\以下に入るので、要注意です。

また、productオプションを指定し、firefoxとすることで使い慣れたpuppeteer-coreからfirefoxを操縦が可能になります。

また、Nightlyのインストーラを直リンクでダウンロードさせるならば、このURLから対応のバージョンを探してダウンロードさせることも可能です。Version109aのWindows x64版だとこちらのリンクになります。

図:nightly buildをダウンロード

Firefox Nightlyのプロファイルディレクトリの特定

前述のpuppeteer-coreで操縦する場合、userDataDirにFirefox Nightlyのプロファイルディレクトリの指定をしておいがほうが良いです。しかしこのディレクトリ名は固定ではなく、作成された時にランダムな文字列で生成されるために、またこれをユーザに登録させるというのは賢い手法とは言えないので、プログラムから特定して変数に格納する必要があります。

Node.jsの場合以下のようなコードで特定することが可能です。

//Firefoxプロファイルディレクトリ基準
var ffdir = process.env.APPDATA + "\\Mozilla\\Firefox\\Profiles\\"
var prodir = ""

//プロファイルディレクトリがあるかどうかチェック
if(fs.existsSync(ffdir)){
    //Firefoxプロファイルディレクトリのうち、nightlyに関するものを取得する
    const files = fs.readdirSync(ffdir)
    const dirList = files.filter((file) => {
        return fs.statSync(path.join(ffdir, file)).isDirectory()
    })

    //dirListのうちdefault-nightlyが文字列の真ん中にあるものを取得
    for(let key in dirList) {
        //フォルダ名を取得する
        let str = dirList[key]

        //default-nightly-を含むものを取り出す
        if(str.match(/default-nightly-/)){
            //profile directoryを格納する
            prodir = ffdir + str
            console.log(prodir)
            break;
        }
    }
}

図:この値を取得して指定したい

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)