1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

httpアーカイブ(har)から画像データを抽出するプログラムをJScript.NETでささっと作る

Posted at

はじめに

会社の e-learning が面倒です。動画や音声を使った凝った作りのものは少なく,たいていは数十枚のスライド紙芝居形式です。最後にテストがあるので適当に飛ばして見ると後悔します。ということでかつて筆者はブラウザ画面をキャプチャするツールを作ったことがあります。

このツール自体は今でも有用なのですが,いくつか課題があります。

  • ページをめくるごとにキャプチャ,上記のツールでは Enter キーを押す必要があるのが面倒。
  • Web ブラウザが頻繁にバージョンアップするのに併せて WebDriver を更新するのも面倒。

ということで,せめて自動キャプチャできないものか検討していたところ,先日 http アーカイブ(har)ファイルの存在を知りました。要はブラウズを開始する前に通信ログを取り始め,最後に通信ログから画像データをまとめて抽出すれば良いのではないか?ということです。

何で作るか問題(プログラミング言語選定)

タイトルでネタバレしていますが,今回 JScript.NET という言語自体はメジャー系列でありながら処理系としては超マイナーな存在を採用したのには理由があります。

まず Windows にタダで付いてくる処理系であることが大前提です。http アーカイブ (har) ファイルは JSON 形式なので JavaScript 系の相性が良いはずです。今回は画像データ(バイナリデータ)を取り扱うことから,Windows Script Host ではなく,.NET Framework の機能が使える JScript.NET にしました。

JScript.NET とは?

参考文献1がとても良くまとまっています。

  • コンパイルして実行します。コンパイラは Windows に付いてきます。
  • .NET Framework の機能を使えます。
  • 変数に boolean, byte, int, String など .NET Framework のデータ型を使えます。
  • 言語仕様としては ECMA Script 3 をほぼ準拠し,幻の ECMA Script 4 仕様の多くを先取りしました。代表例を挙げると定数 const が使えたり,クラス class が作れる等です。

個人的には今でも非常に有用な処理系だと思っていますが,如何せん化石過ぎて開発資料の少なさが泣き所です。まともな資料は文献23くらいでしょうか。

http アーカイブ(har)の保存方法

Web ブラウザが Google Chrome(もしくは Microsoft Edge)の人は Ctrl + Shift + I キーを押して「開発者ツール」のモードに入ります。ちなみに「開発者ツール」モードを終了するときのキーも同じです。デフォルトでは Web ブラウザの右側に下記のサイドパネルが表示されます。表示位置を左側・下側に変更することも可能です。

まず ✅ Disable cache にチェックを入れましょう。次に ⊘ ボタン(もしくは Ctrl + L キー)を押して通信ログをクリアした後,「Reload page」ボタンを押して再読み込みを行います。

一通りブラウジングした後,⭳ ボタンを押して HAR ファイルをエクスポートします。

Firefox も同様に Ctrl + Shift + I キーで「ウェブ開発ツール」モードに入りますが,Ctrl + Shift + E キーでもいけるようです。デフォルトでは Web ブラウザの右側に下記のサイドバーが表示されます。表示位置は右側・左側にも変更可能です。

まず ✅ キャッシュを無効化にチェックを入れましょう。次に 🗑 ボタン(ゴミ箱)を押してバッファを消去した後「再読み込み」ボタンを押します。

一通りブラウジングした後,右クリックして「HAR形式ですべて保存」を選びます。

http アーカイブ (har) ファイルフォーマット

そもそも標準仕様が決まっていない件・・・一応,ドラフト版4はありますが,

と公式から使用禁止令が出ている没規格(案)というのも珍しいように思います。とはいえ事実上 Google Chrome に対応すれば十分であり,幸いなことに FireFox も準拠しているようです。いちおう本ツールで参照している JSON 要素をハイライトで示しました。

お品書き(仕様案)

  • 二つのモードを持ちます。
    • リストモード:mimetype と url の一覧を表示するだけでデータを抽出しません。
    • データモード:指定した mimetype のデータを抽出します。
  • データモードの仕様
    • url アドレスの末尾のパスをファイル名としてカレントディレクトリに保存します。
    • 出力ファイルが存在する場合,重複を避けてファイル名に番号を付加します。
    • 同じ url から受信したデータが複数存在するとき,最大長のデータのみ保存します。

特記事項

後で全ソースコードを示しますが,特記事項を先に述べておきます。

条件付コンパイルステートメント

ソースコードを誤ってそのまま実行させた場合,警告を発するようにしました。これは WSH(Windows Script Host)のコードとしてそのままインタプリタ実行されるのを防ぐためです。

条件付コンパイルステートメント
@cc_on
@if(@_jscript_version < 6)
WScript.Echo("JScript.NET のソースコードです。コンパイルして実行して下さい。");
@else
/*
この中にコンパイルするコードを記述する
*/
@end

JSON 形式のデシリアライズ

言語仕様が古いため JSON.parseJSON.stringify も使えません。このため危険とされている eval を使って模擬します。下記は筆者が愛用しているもので,昔 stackoverflow で見かけたコードなのですが,今探しても出典が見つかりませんでした。

JSON 形式のデシリアライズ
function JSON_parse(str) {
	var e = null; e = eval("e = " + str + ";");
	return e;
};

プロトタイプ拡張

もしくはプロトタイプ継承とも。さすがに言語仕様が古いせいか,Array.includes などの便利なメソッドに対応していません。しかし,JavaScript はメソッドを拡張できるという素晴らしい言語なので,これを利用しない手はありません。

プロトタイプ拡張
Array.prototype.includes = function(val) {
	for(var i = 0; i < this.length; i++)
		if(this[i] == val) return true;
	return false;
};

本物の等号5とは違いますが,今回は文字列同士の比較なので気にしないことにします。

副作用として配列で for ... in 構文を使うとプロトタイプ継承されたメソッドも列挙されてしまうので注意する必要があります。hasOwnProperty メソッドを使えばプロトタイプ継承されたメソッドを区別できます。

プロトタイプ拡張メンバはスキップ
var	data = [];
/*
この中で配列に要素を追加する
*/
for(var url in data) {
	if(!data.hasOwnProperty(url)) continue;
	/*
	この中で列挙された配列要素を処理する
	*/
}

なお,参考文献2の 121/794 頁に

高度なオブジェクト作成

この方法を使って、既存の (プロトタイプ オブジェクトを持つ) コンストラクタ関数に対して、追加のプロパティを定義できます。この操作は高速モードがオフの場合にだけ有効です。詳細については、「/fast」を参照してください。

と記載されており,プロトタイプ拡張を使ってしまうとコンパイル時にオプション /fast- を追加する必要があります。

ファイル名の生成

ここはちょっと苦労して何度も作り直しました。

  • url にはクエスチョンマーク ? から始まるクエリパラメータが付随することが多いので,まずそれを取り除きます。
  • url をスラッシュ / で区切った末尾の要素をファイル名とします。
  • ファイル名に拡張子がない場合はデフォルトの拡張子を追加します。
  • url は(重複しないようにリストアップするので)重複しませんが,url から生成されたファイル名が重複することは有り得ます。この場合,ファイル名をベース名+[番号]+拡張子の形式で作り直します。番号は 1-origin です。それでも重複する場合は番号を一つずつ増やしていきます。
ファイル名は url の末尾とし,重複する場合は番号 [n] をインクリメント
var	list = [];
for(var url in data) {
	/*
	プロトタイプ拡張メンバはスキップする
	*/
	if(!data.hasOwnProperty(url)) continue;
	/*
	ファイル名は url の末尾とし,重複する場合は番号 [n] をインクリメント
	*/
	var	a = url.replace(/(\?.+)$/, "").split("/");
	var	filename = a[a.length - 1];
	if(!filename.match(/(\.\w+)$/)) filename += extension;
	while(list.includes(filename)) {
		var	m = filename.match(/^(\w+?)(?:\[(\d+)\])?(\.\w+)$/);
		var	n = m[2] == "" ? 1 : 1 + parseInt(m[2]);
		filename = m[1] + "[" + n + "]" + m[3];
	}
	list.push(filename);
	/*
    この中でファイルを保存
    */
}

ファイル名をベース名+[番号]+拡張子に分割するときの正規表現マッチングに失敗した場合の例外処理を忘れました。

実装コード

実装コードを以下に示します。

harutil.js
@cc_on
@if(@_jscript_version < 6)
WScript.Echo("JScript.NET のソースコードです。コンパイルして実行して下さい。");
@else
import System;
import System.IO;
import System.Text;
//------------------------------------------------------------------------------
// プロトタイプ継承:Array.prototype.includes
//------------------------------------------------------------------------------
Array.prototype.includes = function(val) {
	for(var i = 0; i < this.length; i++)
		if(this[i] == val) return true;
	return false;
};
//------------------------------------------------------------------------------
// JSON 文字列の解析
//------------------------------------------------------------------------------
function JSON_parse(str) {
	var e = null; e = eval("e = " + str + ";");
	return e;
};
//------------------------------------------------------------------------------
// ヘルプメッセージ
//------------------------------------------------------------------------------
function usage() {
	Console.Error.WriteLine("http アーカイブからデータを抽出します。");
	Console.Error.WriteLine("");
	Console.Error.WriteLine("harutil(.exe) [コマンド] [http アーカイブ名(.har)]");
	Console.Error.WriteLine("");
	Console.Error.WriteLine("<リストコマンド>");
	Console.Error.WriteLine("  list:       url と mimetype の一覧を表示します。");
	Console.Error.WriteLine("");
	Console.Error.WriteLine("<データ抽出コマンド>");
	Console.Error.WriteLine("  plain:      text/plain");
	Console.Error.WriteLine("  html:       text/html");
	Console.Error.WriteLine("  xml:        text/xml");
	Console.Error.WriteLine("              application/xml");
	Console.Error.WriteLine("  csv:        text/csv");
	Console.Error.WriteLine("  css:        text/css");
	Console.Error.WriteLine("  javascript: text/javascript");
	Console.Error.WriteLine("              application/javascript");
	Console.Error.WriteLine("              application/x-javascript");
	Console.Error.WriteLine("  json:       application/json");
	Console.Error.WriteLine("  png:        image/png");
	Console.Error.WriteLine("  gif:        image/gif");
	Console.Error.WriteLine("  jpeg:       image/jpeg");
	Console.Error.WriteLine("  webp:       image/webp");
	Console.Error.WriteLine("  octet:      application/octet-stream");
	Console.Error.WriteLine("              binary/octet-stream");
	return -1;
}
//------------------------------------------------------------------------------
// url と mimetype の一覧を表示します。
//------------------------------------------------------------------------------
function show_list(e) {
	for(var i = 0; i < e.log.entries.length; i++) {
		var	url = e.log.entries[i].request.url;
		var	content = e.log.entries[i].response.content;
		var	a = [content.mimeType, content.size, content.encoding || "-", url];
		Console.WriteLine(a.join("\t"));
	}
	return 0;
}
//------------------------------------------------------------------------------
// 指定した mimetype のデータを出力します。
//------------------------------------------------------------------------------
function output_file(e, extension, binary, mime) {
	//--------------------------------------------------------------------------
	// mimetype が一致するデータの抽出 ※url が重複する場合,最大長データを残す
	//--------------------------------------------------------------------------
	var	data = [];
	for(var i = 0; i < e.log.entries.length; i++) {
		var	url = e.log.entries[i].request.url;
		var	content = e.log.entries[i].response.content;
		/*--*/ if(!content.mimeType || !content.text) {
			continue;
		} else if(content.mimeType.indexOf(mime) >= 0) {
			if(data[url] == null || data[url].length < content.text.length)
				data[url] = content.text;
		}
	}
	//--------------------------------------------------------------------------
	// データの保存
	//--------------------------------------------------------------------------
	var	list = [];
	for(var url in data) {
		//----------------------------------------------------------------------
		// プロトタイプ拡張メンバはスキップする
		//----------------------------------------------------------------------
		if(!data.hasOwnProperty(url)) continue;
		//----------------------------------------------------------------------
		// ファイル名は url の末尾とし,重複する場合は番号 [n] をインクリメント
		//----------------------------------------------------------------------
		var	a = url.replace(/(\?.+)$/, "").split("/");
		var	filename = a[a.length - 1];
		if(!filename.match(/(\.\w+)$/)) filename += extension;
		while(list.includes(filename)) {
			var	m = filename.match(/^(\w+?)(?:\[(\d+)\])?(\.\w+)$/);
			var	n = m[2] == "" ? 1 : 1 + parseInt(m[2]);
			filename = m[1] + "[" + n + "]" + m[3];
		}
		list.push(filename);
		//----------------------------------------------------------------------
		// バイナリは base64 デコード,テキストは utf8 で保存する
		//----------------------------------------------------------------------
		Console.WriteLine("{0}\t{1}", filename, url);
		if(binary)
			File.WriteAllBytes(filename, Convert.FromBase64String(data[url]));
		else
			File.WriteAllText(filename, data[url], Encoding.UTF8);
	}
	if(list.length == 0)
		Console.WriteLine("データがありません!!");
	else
		Console.WriteLine("データを {0} 個抽出しました。", list.length);
	return  0;
}
//------------------------------------------------------------------------------
// メイン関数
//------------------------------------------------------------------------------
function main(args) {
	//--------------------------------------------------------------------------
	// ヘルプメッセージ
	//--------------------------------------------------------------------------
	if(args.length < 3) return usage();
	var	command = args[1];
	var	archive = args[2];
	//--------------------------------------------------------------------------
	// アーカイブファイルの解析
	//--------------------------------------------------------------------------
	if(!File.Exists(archive)) {
		Console.Error.WriteLine("アーカイブファイル {0} が存在しません!!", archive);
		return -1;
	}
	var	buf = File.ReadAllText(archive, Encoding.UTF8);
	var	e = JSON_parse(buf);
	if(!e.log) {
		Console.Error.WriteLine( "ファイル {0} はアーカイブ形式ではありません!!", archive);
		return -1;
	}
	//--------------------------------------------------------------------------
	// コマンド実行
	//--------------------------------------------------------------------------
	switch(command) {
	  case "list":		return show_list(e);
	  case "plain":		return output_file(e, ".txt",   false, "plain");
	  case "html":		return output_file(e, ".html",  false, "html");
	  case "xml":		return output_file(e, ".xml",   false, "xml");
	  case "csv":		return output_file(e, ".csv",   false, "csv");
	  case "css":		return output_file(e, ".css",   false, "css");
	  case "javascript":return output_file(e, ".js",    false, "javascript");
	  case "json":		return output_file(e, ".json",  false, "json");
	  case "png":		return output_file(e, ".png",   true,  "png");
	  case "gif":		return output_file(e, ".gif",   true,  "gif");
	  case "jpeg":		return output_file(e, ".jpeg",  true,  "jpeg");
	  case "webp":		return output_file(e, ".webp",  true,  "webp");
	  case "octet":		return output_file(e, ".octet", true,  "octet-stream");
	  default:
		Console.Error.WriteLine("コマンド {0} には対応していません!!", command);
		return -1;
	}
}
Environment.ExitCode = main(Environment.GetCommandLineArgs());
@end

コンパイル

下記バッチファイルを実行します。

build.cmd
@echo off
setlocal
set DOTNETPATH=
for /F "usebackq tokens=1,2,*" %%I in (
  `reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v InstallPath`
) do (
  if "%%I"=="InstallPath" set "DOTNETPATH=%%K"
)
if defined DOTNETPATH goto ENDIF
  echo .NET Framework 4 is not found! 
  exit /b 1
:ENDIF
if "%DOTNETPATH:~-1%"=="\" set "DOTNETPATH=%DOTNETPATH:~0,-1%"
set "PATH=%PATH%;%DOTNETPATH%"
jsc /fast- harutil.js
endlocal
exit /b 0

使い方

引数なしで実行するとヘルプメッセージを表示します。

引数なしで実行した場合
c:\Qiita>harutil
http アーカイブからデータを抽出します。

harutil(.exe) [コマンド] [http アーカイブ名(.har)]

<リストコマンド>
  list:       url と mimetype の一覧を表示します。

<データ抽出コマンド>
  plain:      text/plain
  html:       text/html
  xml:        text/xml
              application/xml
  csv:        text/csv
  css:        text/css
  javascript: text/javascript
              application/javascript
              application/x-javascript
  json:       application/json
  png:        image/png
  gif:        image/gif
  jpeg:       image/jpeg
  webp:       image/webp
  octet:      application/octet-stream
              binary/octet-stream

まずはリストモードで画像データの種別を確認します。おまけにデータサイズとエンコーディングも表示するようにしました。

リストモード
c:\Qiita>harutil list e-learning.com.har
text/html       4950    -       https://e-learning.com/scorm/xxx/index.html
image/x-icon    32038   base64  https://e-learning.com/favicon.ico

~中略~

text/html       709     -       https://e-learning.com/scorm/xxx/contents/00/jp/001.html
text/css        360     -       https://e-learning.com/scorm/xxx/contents/00/jp/style.css
image/png       41741   base64  https://e-learning.com/scorm/xxx/contents/00/jp/001.png
text/html       2083    -       https://e-learning.com/scorm/xxx/contents/00/jp/002.html
text/css        360     -       https://e-learning.com/scorm/xxx/contents/00/jp/style.css
image/png       110983  base64  https://e-learning.com/scorm/xxx/contents/00/jp/002.png
text/html       503     -       https://e-learning.com/scorm/xxx/contents/00/jp/003.html
text/css        360     -       https://e-learning.com/scorm/xxx/contents/00/jp/style.css
image/png       198627  base64  https://e-learning.com/scorm/xxx/contents/00/jp/003.png

~中略~

text/html       3950    -       https://e-learning.com/scorm/xxx/contents/00/jp/045.html
text/css        360     -       https://e-learning.com/scorm/xxx/contents/00/jp/style.css
image/png       122110  base64  https://e-learning.com/scorm/xxx/contents/00/jp/045.png
text/html       1086    -       https://e-learning.com/scorm/xxx/contents/00/jp/end_j.html
image/png       7895    base64  https://e-learning.com/scorm/xxx/contents/00/jp/end.png

次にデータモードで PNG ファイルを抽出します。

データモード
c:\Qiita>harutil png e-learning.com.har
001.png https://e-learning.com/scorm/xxx/contents/00/jp/001.png
002.png https://e-learning.com/scorm/xxx/contents/00/jp/002.png
003.png https://e-learning.com/scorm/xxx/contents/00/jp/003.png

~中略~

045.png https://e-learning.com/scorm/xxx/contents/00/jp/045.png
end.png https://e-learning.com/scorm/xxx/contents/00/jp/end.png
ファイルを 46 個抽出しました。

TO DO リスト(やり残したこと)

  • mime タイプの判別に String.indexOf メソッドを使っています。というのも,例えばプレーンテキスト形式は text/plain だけではなく,エンコード情報が付加されて text/plain; charset=UTF-8 となる場合もあり,部分一致で確認するのが楽だからです。とくに JavaScript の mime タイプは歴史的な経緯もあってバリエーションが多過ぎて困ります6。このため短いキーワードで検索するようにしたのですが,逆に短くし過ぎたことにより誤判定するかもしれません。

  • Base64 デコードするか否かを mime タイプ別にコードの中で作り込んでいますが,http レスポンスの中に含まれているエンコード情報を利用すれば自動判別できそうです。

Firefox のユーザーへ

Firefox の場合,通信不良でもないのに抽出したデータが欠けている場合があります。これはネットワークモニターで記録した情報をファイルへ保存する際,Response Body の大きさに制限が設けられているからです。アドレスバーから about:config と入力し,下記の設定値を変更してみてください。

devtools.netmonitor.responseBodyLimit

デフォルトは 1,048,576 bytes,すなわち 1Mbytes となっています。不足するようであれば,この値を大きくしてみて下さい。0 にすれば無制限になります。

おまけ

タグを新規作成すると某所から通知が来るようになりました。本記事の JScriptt.NET が該当します。引用記事が一つしかないと目立つので,皆さん JScript.NET の記事を書きましょう。

  1. 君よ知るや JScript.NET - slideshare.net

  2. VS2003_Jscrip_ja-jp.pdf - download.microsoft.com 2

  3. Introducing JScript .NET - learn.microsoft.com

  4. HTTP Archive (HAR) format Historical Draft August 14, 2012 - w3c

  5. Array.prototype.includes() - developer.mozilla.org

  6. MIME タイプ(IANA メディア種別)- developer.mozilla.org

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?