Seleniumなしで直接WebDriverを操作してブラウザ画面をキャプチャするスクリプトをささっと作る

:::note

2024年9月14日現在，起動させると「msxml3.dll: 指定されたリソースが見つかりません。」というエラーメッセージが表示されて正常に動作しません。

2024年9月26日，不具合の原因は msedgedriver ver.128 以降，これまで9515固定だったポート番号がランダムになったためと判明しました。この仕様変更に対応して正常動作するようになりました。
2024年9月27日，サイドバーおよび「自動テストソフトウェアによって制御されています」の表示を消すようにしました。
:::

# 0. はじめに
会社の e-learning が面倒だ。ブラウザで数十枚のスライドを見た後でテストがあるので，スライドを飛ばして見ると後で後悔する。以前は，自作のプログラムでブラウザ画面をキャプチャしていたのだが，Windows10 以降キャプチャできなくなった。FireFox の画面はキャプチャできるけど Edge の画面はできないので，Edge は GPU を使ってるとか何とかのせいだろう・・・


ところが世の中には WebDriver というものがあることを知った。おそらく一般的な用途としてはブラウザ操作の自動化だと思うが，その中にブラウザ画面のスクリーンショットを取る機能があることを知り，これは使えないかと思った次第である。

# 1. 基本方針
対象ブラウザは Edge（Chromium版）である。Edge 用の WebDriver（msedgedriver）はマイクロソフト社謹製なので会社でも使えるが，Selenium は使用不可。なので WebDriver を直接操作しなくてはならない。

WebDriver とのデータのやり取りは JSON 形式で行われるので，JavaScript で組むのが良いだろう。

# 2. 課題
## 2.1 WebDriver とのやり取り
WebDriver とのやり取りには `MSXML2.XMLHTTP` オブジェクトを用いる。データ送信はよく使うので共通部品を用意した。

```javascript:初期化部分
//------------------------------------------------------------------------------
// HTTP クライアントの生成
//------------------------------------------------------------------------------
var	client = WScript.CreateObject("MSXML2.XMLHTTP");
//------------------------------------------------------------------------------
// データ送信
//------------------------------------------------------------------------------
var	send = function(method, addr, str) {
	client.open(method, addr, false);
	client.setRequestHeader("Content-Type", "application/json");
	client.setRequestHeader("Pragma", "no-cache");
	client.setRequestHeader("Cache-Control", "no-cache");
	client.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");
	client.send(str);
};
//------------------------------------------------------------------------------
// セッション ID の取得
//------------------------------------------------------------------------------
send("POST", "http://localhost:" + port_num + "/session", JSON_stringify({"capabilities":{}}));
var	e = JSON_parse(client.responseText);
var	id = e.value.sessionId;
```

## 2.2 IE6 では JSON が使えない
IE6 では JSON.stringify も JSON.parse も使えないのでサブセット（代替品）を自作した。

```javascript:JSON.parse の代替品
var	JSON_parse = function(str) {
	var e = null; e = eval("e = " + str + ";");
	return e;
};
```

```javascript:JSON.stringify の代替品
var	JSON_stringify = function(val) {
	if(val == null) {
		return "null";
	} else if(typeof(val) == "number" || typeof(val) == "boolean") {
		return val.toString();
	} else if(typeof(val) == "string") {
		return "\"" + val + "\"";
	} else if(typeof(val) == "object") {
		if(val.length == undefined) {
			var	a = [];
			for(var i in val)
				a.push("\"" + i + "\":" + JSON_stringify(val[i]));
			return "{" + a.join(",") + "}";
		} else {
			var	a = [];
			for(var i = 0; i < val.length; i++)
				a.push(JSON_stringify(val[i]));
			return "[" + a.join(",") + "]";
		}
	}
};
```
## 2.3 BASE64 デコードとバイナリ形式での保存

スクリーンショットは PNG 形式で格納され，それを BASE64 エンコードしたテキストとして受け取るようだ。BASE64 デコードは `MSXML2.DOMDocument` を使う。またバイナリ形式でのファイル保存には `ADODB.Stream` を使う。

```javascript:初期化部分
//------------------------------------------------------------------------------
// ADODB.Stream オブジェクトの生成
//------------------------------------------------------------------------------
var	stream = WScript.CreateObject("ADODB.Stream")
//------------------------------------------------------------------------------
// MSXML2.DOMDocument オブジェクトの生成
//------------------------------------------------------------------------------
var	doc = WScript.CreateObject("MSXML2.DOMDocument");
var	element = doc.createElement("base64");
```

```javascript:スクリーンショットの保存部分
send("GET", "http://localhost:" + port_num + "/session/" + id + "/screenshot", ""); 
var	obj = JSON_parse(client.responseText);
element.dataType = "bin.base64";
element.text = obj.value;
stream.Type = 1;					// adTypeBinary = 1
stream.Open();
stream.Write(element.nodeTypedValue)
stream.SaveToFile(filename, 2);		// adSaveCreateOverWrite = 2
stream.Close();
```

# 3. 実装コード

ささっと作ったつもりだったが，エラー処理を入れたら結構長くなってしまった・・・

```javascript:msEdgeCap.js
//------------------------------------------------------------------------------
// メイン関数
//------------------------------------------------------------------------------
function main() {
	//--------------------------------------------------------------------------
	// JSON 文字列の解析
	//--------------------------------------------------------------------------
	var	JSON_parse = function(str) {
		var e = null; e = eval("e = " + str + ";");
		return e;
	};
	//--------------------------------------------------------------------------
	// JSON オブジェクトの文字列化
	//--------------------------------------------------------------------------
	var	JSON_stringify = function(val) {
		if(val == null) {
			return "null";
		} else if(typeof(val) == "number" || typeof(val) == "boolean") {
			return val.toString();
		} else if(typeof(val) == "string") {
			return "\"" + val + "\"";
		} else if(typeof(val) == "object") {
			if(val.length == undefined) {
				var	a = [];
				for(var i in val)
					a.push("\"" + i + "\":" + JSON_stringify(val[i]));
				return "{" + a.join(",") + "}";
			} else {
				var	a = [];
				for(var i = 0; i < val.length; i++)
					a.push( JSON_stringify(val[i]));
				return "[" + a.join(",") + "]";
			}
		}
	};
	//--------------------------------------------------------------------------
	// ポート番号
	//--------------------------------------------------------------------------
	var	port_num = 9515;
	//--------------------------------------------------------------------------
	// ウェブドライバ名
	//--------------------------------------------------------------------------
	var	driver_name = "msedgedriver.exe";
	//--------------------------------------------------------------------------
	// WScript.Shell オブジェクトの生成
	//--------------------------------------------------------------------------
	var	shell = WScript.CreateObject("WScript.Shell");
	//--------------------------------------------------------------------------
	// 既にウェブ・ドライバが起動されている場合はそのまま利用する
	// そうでない場合、新しいウィンドウを開いてウェブ・ドライバを起動する
	//--------------------------------------------------------------------------
	(function(){
		var	locator = WScript.CreateObject("WbemScripting.SWbemLocator");
		var	service = locator.ConnectServer();
		var	list = service.ExecQuery("Select * From Win32_Process Where Name = '" + driver_name + "'");
		if(list.Count == 0) shell.Run(driver_name + " --port=" + port_num);
	})();
	//--------------------------------------------------------------------------
	// ADODB.Stream オブジェクトの生成
	//--------------------------------------------------------------------------
	var	stream = WScript.CreateObject("ADODB.Stream")
	//--------------------------------------------------------------------------
	// MSXML2.DOMDocument オブジェクトの生成
	//--------------------------------------------------------------------------
	var	element = (function(){
		var	doc = WScript.CreateObject("MSXML2.DOMDocument");
		return doc.createElement("base64");
	})();
	//--------------------------------------------------------------------------
	// HTTP クライアントの生成
	//--------------------------------------------------------------------------
	var	client = WScript.CreateObject("MSXML2.XMLHTTP");
	var	send = function(method, addr, str) {
		client.open(method, addr, false);
		client.setRequestHeader("Content-Type", "application/json");
		client.setRequestHeader("Pragma", "no-cache");
		client.setRequestHeader("Cache-Control", "no-cache");
		client.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");
		client.send(str);
	};
	//--------------------------------------------------------------------------
	// セッションIDの取得
	//--------------------------------------------------------------------------
	send("POST", "http://localhost:" + port_num + "/session", JSON_stringify({
		"capabilities":{
			"alwaysMatch":{
				"ms:edgeOptions":{
					"excludeSwitches":["enable-automation"],
					"args":["--enable-features=msEdgeTowerAutoHide"]
				}
			}
		}
	}));
	if(client.Status != 200) {
		WScript.StdErr.WriteLine("セッションIDの取得に失敗しました!!");
		return -1;
	}
	var	e = JSON_parse(client.responseText);
	if(!e.value || !e.value.sessionId) {
		WScript.StdErr.WriteLine("セッションIDの取得に失敗しました!!");
		return -1;
	}
	var	id = e.value.sessionId;
	if(e.value && e.value.capabilities) {
		var	f = e.value.capabilities;
		if(f.browserName && f.browserVersion)
			WScript.StdErr.WriteLine(f.browserName + ":" + f.browserVersion);
		if(f.msedge && f.msedge.msedgedriverVersion)
			WScript.StdErr.WriteLine(driver_name + ":" + f.msedge.msedgedriverVersion);
	}
	//--------------------------------------------------------------------------
	// スクリーンショットをとる
	//--------------------------------------------------------------------------
	var	count = 0;
	for(;;) {
		//----------------------------------------------------------------------
		// キー入力
		//----------------------------------------------------------------------
		WScript.StdErr.Write("SCREEN SHOT(ENTER)/WINDOW LIST(W)/WINDOW SELECT(0-9)/QUIT(Q):");
		var	key = WScript.StdIn.ReadLine();
		if(key == "Q" || key == "q") {
			//------------------------------------------------------------------
			// 終了
			//------------------------------------------------------------------
			WScript.StdErr.WriteLine("終了しました。");
			break;
		} else if(key == "W" || key == "w") {
			//------------------------------------------------------------------
			// ウィンドウリストを表示する
			//------------------------------------------------------------------
			if(!(function(){
				send("GET", "http://localhost:" + port_num + "/session/" + id + "/window/handles", "");
				if(client.Status != 200) {
					WScript.StdErr.WriteLine("ウィンドウハンドルの取得に失敗しました!!");
					return false;
				}
				var	obj = JSON_parse(client.responseText);
				if(!obj.value || !obj.value.length) {
					WScript.StdErr.WriteLine("ウィンドウハンドルの取得に失敗しました!!");
					return false;
				}
				for(var i = 0; i < obj.value.length; i++)
					WScript.StdErr.WriteLine("[" + i + "] " + obj.value[i]);
				return true;
			})() ) break;
		} else if(/^[0-9]+$/.test(key)) {
			//------------------------------------------------------------------
			// ウィンドウを選択する
			//------------------------------------------------------------------
			if(!(function(){
				send("GET", "http://localhost:" + port_num + "/session/" + id + "/window/handles", "");
				if(client.Status != 200) {
					WScript.StdErr.WriteLine("ウィンドウハンドルの取得に失敗しました!!");
					return false;
				}
				var	obj = JSON_parse(client.responseText);
				if(!obj.value || !obj.value.length) {
					WScript.StdErr.WriteLine("ウィンドウハンドルの取得に失敗しました!!");
					return false;
				}
				var	n = parseInt(key);
				if(n < 0 || n >= obj.value.length) {
					WScript.StdErr.WriteLine("不正なウィンドウを指定しました!!");
					return true;
				}
				send("POST", "http://localhost:" + port_num + "/session/" + id + "/window",
					JSON_stringify({ "handle": obj.value[n] }));
				if(client.Status != 200) {
					WScript.StdErr.WriteLine("ウィンドウの選択に失敗しました!!");
					return false;
				}
				WScript.StdErr.WriteLine("ウィンドウ [" + n + "] を選択しました。");
				return true;
			})()) break;
		} else if(key == "") {
			//------------------------------------------------------------------
			// スクリーンショットを取得する
			//------------------------------------------------------------------
			if(!(function(){
				send("GET", "http://localhost:" + port_num + "/session/" + id + "/screenshot", ""); 
				if(client.Status != 200) {
					WScript.StdErr.WriteLine("スクリーンショットの取得に失敗しました!!");
					return false;
				}
				var	obj = JSON_parse(client.responseText);
				if(!obj.value || obj.value.length == 0 || obj.value.length % 4 != 0) {
					WScript.StdErr.WriteLine("スクリーンショットの取得に失敗しました!!");
					return false;
				}
				//--------------------------------------------------------------
				// BASE64 デコードして保存する
				//--------------------------------------------------------------
				var	filename = ("0000" + count).slice(-4) + ".PNG";
				element.dataType = "bin.base64";
				element.text = obj.value;
				stream.Type = 1;					// adTypeBinary = 1
				stream.Open();
				stream.Write(element.nodeTypedValue)
				stream.SaveToFile(filename, 2);		// adSaveCreateOverWrite = 2
				stream.Close();
				WScript.StdErr.WriteLine(filename + " ... 保存しました。")
				count++;
				return true;
			})()) break;
		} else {
			WScript.StdErr.WriteLine(key + " は不正なコマンドです!!");
		}
	}
	//--------------------------------------------------------------------------
	// ウェブ・ブラウザを閉じる
	//--------------------------------------------------------------------------
	send("DELETE", "http://localhost:" + port_num + "/session/" + id, "");
	return 0;
}
//------------------------------------------------------------------------------
// メイン関数の呼び出し
//------------------------------------------------------------------------------
var	ret = main(WScript.Arguments.Unnamed);
WScript.Quit(ret);
```

# 4. 実行画面
コマンドラインから起動すると Edge と WebDriver のバージョンを表示する。ENTER キーを押すとスクリーンショットを取る。ファイル名は `0000.PNG`, `0001.PNG`, `0002.PNG` ... と四桁の連番とする。ウィンドウ（タブ）が複数ある場合のため，ウィンドウ（タブ）の一覧機能と選択機能を追加した。

```
MicrosoftEdge:129.0.2792.52
msedgedriver.exe:129.0.2792.52 (6462e8fc16ec8b62bef06801adff3cbeccd6a7d4)
SCREEN SHOT(ENTER)/WINDOW LIST(W)/WINDOW SELECT(0-9)/QUIT(Q):
```

# 5. 余談
ちなみに職場の同僚に尋ねたら，ブラウザを二つ開いてスライド見ながらテストを解いているとのこと。

# 6. 参考資料

- [Excel VBAでSeleniumBasicを使わずにスクレイピングする - Qiita](https://qiita.com/uezo/items/66e20b064ffd5f239b9a)

- [WebDriver からのレスポンスを、curl コマンドで確認する - Qiita](https://qiita.com/tamarinn_x/items/f771c28a841e0536996f)

- [ChromeDriver(EdgeDriver)のVer.128からデフォルトポート9515がランダムに変更されました。- Qiita](https://qiita.com/yaju/items/3c4e516746c8db830c9c)

- [Chromeの｢自動テストソフトソフトウェアによって制御されています｣を消す - Qiita](https://qiita.com/fugafug92573304/items/6f5b68b4a1a7971a05f8)

- [ChromeDriver でよく使うオプションのチートシート - Qiita](https://qiita.com/kawagoe6884/items/cea239681bdcffe31828)

- [Python & Selenium で Edge をオプション付きで実行(WebDriver もオプション付きで) - Qiita](https://qiita.com/tamarinn_x/items/af61d68ff622a2423114
)
- [Edge & WebDriver を、Selenium を使わずにオプション付きで起動する - Qiita](
https://qiita.com/tamarinn_x/items/b5000ede6a355ae9609a)