はじめに
会社の 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.parse
も JSON.stringify
も使えません。このため危険とされている eval
を使って模擬します。下記は筆者が愛用しているもので,昔 stackoverflow で見かけたコードなのですが,今探しても出典が見つかりませんでした。
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 です。それでも重複する場合は番号を一つずつ増やしていきます。
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);
/*
この中でファイルを保存
*/
}
ファイル名をベース名+[番号]+拡張子に分割するときの正規表現マッチングに失敗した場合の例外処理を忘れました。
実装コード
実装コードを以下に示します。
@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
コンパイル
下記バッチファイルを実行します。
@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
の記事を書きましょう。