承認これくしょん

my black histories

問題ステップ記録ツールが出力するMHTMLからスクリーンショットを抽出する

問題ステップツールとは

Windows 7からは標準で「問題ステップ記録ツール」ことpsr.exeがインストールされている。
ユーザーのマウス操作などに応じて自動でスクリーンショットを撮ってくれるツールだ。
出力されるzipに入ってるMHTMLを展開してブラウザで開くとスクリーンショット付きのレポートが見られる。
以下のコマンドラインオプションを指定するとGUIを出さずに使えて便利。

記録開始
psr.exe /start /output (出力するzipのフルパス) /gui 0 /sc 1 /maxsc (撮影するスクリーンショットの上限)
記録終了
psr.exe /stop

7で試したらスクリーンショットの上限は100だったけど、8は999までいけるらしい。
上限に達した時点で撮影終了するのではなく、古いものから順に消えるので最大値を指定するのが無難。

スクリーンショットの抽出

開発環境が入れられない人でも安心のJScript
下記のスクリプトと同じフォルダに、ツールが出力した「psr.zip」が置いてある前提。
zipを自動で解凍してmhtを読み込み、記録日時を名前とするサブフォルダにスクリーンショットを抽出する。
処理実行後にzipとmhtを削除するので注意。

// 設定
var zipName = 'psr.zip'; // 問題ステップ記録ツールが出力するzip

// zip解凍
var fso = new ActiveXObject('Scripting.FileSystemObject');
var explorer = new ActiveXObject('shell.application');
var currentPath = String(WScript.ScriptFullName).replace(WScript.ScriptName,'');
var inputZip = explorer.NameSpace(currentPath + zipName);
var mhtName = inputZip.Items().Item(0).Name; // mhtファイル
var dirName = mhtName.replace(/Problem_|\.mht/g, ''); // 出力先フォルダ
var outPath = currentPath + dirName + '\\';
fso.CreateFolder(outPath);
var outputDir = explorer.NameSpace(outPath);
outputDir.CopyHere(inputZip.Items(), 0);
explorer = null;

// mht読み込み
var sr = new ActiveXObject('ADODB.Stream');
sr.Type = 2; //adTypeText
sr.charset = 'utf-8';
sr.Open();
sr.LoadFromFile(outPath + mhtName);
var xml = new ActiveXObject('Microsoft.XMLDOM');
var boundary; // boundary文字列
var fileName; // ファイル名
var fileContent = ''; // ファイル内容(Base64)

// 一行ずつ末尾まで読み込み
while (!sr.EOS) {
    var line = sr.ReadText(-2); // adReadLine
    // boundaryが未取得
    if (typeof boundary == 'undefined') {
        boundary = (line.match(/boundary=\"(\S+)\"/)||[])[1];
    // boundaryを含む行
    } else if (line.indexOf(boundary) != -1) {
        // ファイル名と内容が取得済み
        if (fileName && fileContent) {
            // Base64をバイナリに変換
            var node = xml.createElement(fileName);
            node.dataType = 'bin.base64';
            node.text = fileContent;
            // バイナリを保存
            var sw = new ActiveXObject('ADODB.Stream');
            sw.Type = 1; // adTypeBinary
            sw.Open();
            sw.Write(node.nodeTypedValue);
            sw.SaveToFile(outPath + fileName);
            sw.Close();
            sw = null;
        };
        // ファイル名と内容のクリア
        fileName = undefined;
        fileContent = '';
    // boundaryを含まない行
    } else {
        // ファイル名が未定義
        if (typeof fileName == 'undefined') {
            fileName = (line.match(/Content-Location: (\S+\.jpeg)/)||[])[1]; // ファイル名を取得
        // 出力ファイル名が定義済み
        } else {
            fileContent += line; // 出力ファイル内容に追記
        };
    };
};

xml = null;
sr.Close();
sr = null;

// ファイル削除
fso.DeleteFile(outPath + mhtName); // mhtファイル
fso.DeleteFile(currentPath + zipName); // zipファイル
fso = null;

これでExcelに貼り付けたりできますね!ちょっと画質悪いけど…

WSHで指定したTwitterアカウントが投稿した画像をだいたい全部ダウンロードする

公式Webクライアントの「画像/動画」タブに出てくるpic.twitter.comにアップロードされた画像をダウンロードする。
外部サービスは対象外、またRTしたツイートが含まれることがあるので「だいたい」。
bignumber.jsを使ってるので一緒にディレクトリに入れてください。
あと今回はCS/CKは不要です。理由はコード見ればわかると思う。

使い方

cscript download.wsf (アカウント名)

画像はアカウント名のサブディレクトリに保存される。

コード

download.wsf
<job>
<script language="JScript" src="bignumber.js" />
<script language="JScript">

var fso = new ActiveXObject('Scripting.FileSystemObject');
var xhr = new ActiveXObject('Msxml2.XMLHTTP');
var sw = new ActiveXObject('ADODB.Stream');

var screenName = WScript.Arguments(0);
var currentPath = String(WScript.ScriptFullName).replace(WScript.ScriptName,'');
var outPath = currentPath + screenName + '/';
if (!fso.FolderExists(outPath)) fso.CreateFolder(outPath);

var baseURL = 'https://twitter.com/i/profiles/show/' + screenName + '/media_timeline';
var imageCount = 0;

do {
    var json = getJSON(lastId);
    var document = new ActiveXObject('htmlfile');
    document.write(json.items_html);
    getImages(document);
    var lastId = getOldestTweet(document);
    document = null;
} while (json.has_more_items == true);

WScript.echo(imageCount + '個のファイルをダウンロードしました。');

fso = null;
xhr = null;
sw = null;

function getJSON(lastId) {
    var params;
    if (!lastId) {
        params = '';
    } else {
        var nextId = new BigNumber(lastId);
        nextId = nextId.subtract(1);
        params = '?contextual_tweet_id=' + lastId + '&include_available_features=1&include_entities=1&max_id=' + nextId;
    };
    xhr.Open('GET', baseURL + params, false);
    xhr.send();
    return eval('(' + xhr.responseText + ')');
}

function getImages(document) {
    var elements = document.getElementsByTagName('img');
    for (var i = 0; i < elements.length; i++) {
        if (elements[i].src.search(/https:\/\/pbs.twimg.com\/media\/\S+:large/) != -1) {
            var url = elements[i].src.replace(':large', ':orig');
            xhr.Open('GET', url, false);
            xhr.send();
            sw.Open();
            sw.Type = 1; //adTypeBinary
            sw.Write(xhr.responseBody);
            sw.Savetofile(outPath + url.replace(/https:\/\/pbs.twimg.com\/media\/|:orig/g, ''), 2);
            sw.Close();
            WScript.echo(url);
            imageCount++;
        };
    };
};

function getOldestTweet(document) {
    var tweetId;
    var elements = document.getElementsByTagName('div');
    for (var i = 0; i < elements.length; i++) {
        if (elements[i].getAttribute('data-tweet-id')) {
            tweetId = elements[i].getAttribute('data-tweet-id');
        };
    };
    return tweetId;
};

</script>
</job>

所感

  • JSONをevalするのは良くない、WSHでjson2.js使えるのかな?
  • ECMAScriptの数値はdoubleなのでツイートIDを扱うには精度が足りない

WSHでツイートする

JavaScriptのOAuthライブラリを知った。
これをJScriptで使えばツイートできるのでは?と思って試したらできた。

使い方

cscript tweet.wsf "Yo"

コード

tweet.wsf
<job>
<script language="JScript" src="oauth.js" />
<script language="JScript" src="sha1.js" />
<script language="JScript">
var accessor = {
    consumerSecret: "API secret",
    tokenSecret: "Access token secret"
}
var message = {
    method: "POST",
    action: "https://api.twitter.com/1.1/statuses/update.json",
    parameters: {
        oauth_consumer_key: "API key",
        oauth_signature_method: "HMAC-SHA1",
        oauth_token: "Access token",
        oauth_version: "1.0",
        status: WScript.arguments(0)
    }
}
OAuth.setTimestampAndNonce(message);
OAuth.SignatureMethod.sign(message, accessor);
var target = OAuth.addToURL(message.action, message.parameters);
var xhr = new ActiveXObject("Msxml2.ServerXMLHTTP");
xhr.onreadystatechange = function() {
    if (xhr.readyState == 4) {
        if (xhr.status == 200) {
            Wscript.echo("成功");
        } else {
            Wscript.echo("失敗");
        }
    }
}
xhr.open(message.method, target);
xhr.setRequestHeader("Authorization", OAuth.getAuthorizationHeader("", message.parameters));
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send();
</script>
</job>

所感

  • OAuthライブラリは全く手を加えることなく動く
  • POSTでもOAuth.addToURLが必要なのに気づかず1日無駄にした
  • XMLHTTPRequestのCOMが複数あるらしく、上手くいかないのもあったが自分の環境(Win7 SP1)ではMsxml2.ServerXMLHTTPで動いた

椅子取りゲーム用の音楽プレイヤーを作った

概要

ブラウザ上に音楽ファイルをドロップするとメニューが出る。
そこで指定した秒数の範囲内で乱数を生成して、イントロからその分だけ再生するやつ。
HTML5のDrag and Drop APIとFile APIのテスト。
デモ

コード

chairs.html
<!DOCTYPE html>
<html>
    <head>
       <meta charset="utf-8">
       <script src="chairs.js"></script>
   </head>
    <body>
        <h1>椅子取りゲーム</h1>
        <div id="description" style="display:block;">音楽ファイルをドロップ</div>
        <div id="player" style="display:none;">
            最小秒数<input id="min" type="text" size="1" value="1"><br>
            最大秒数<input id="max" type="text" size="1" value="5"><button id="play" type="button">スタート</button>
        </div>
    </body>
</html>
chairs.js
window.onload = function() {
    var bgm;
    document.addEventListener("dragover", function(e) {
        e.preventDefault();
    });
    document.addEventListener("drop", function(e) {
        e.preventDefault();
        var file = e.dataTransfer.files[0];
        var reader = new FileReader();
        reader.onload = function(e) {
            bgm = new Audio(e.target.result);
            document.getElementById("description").style.display = "none";
            document.getElementById("player").style.display = "block";
        }
        reader.readAsDataURL(file);
    });
    document.getElementById("play").addEventListener("click", function(e) {
        bgm.play();
        var sec = Math.random() * (document.getElementById("max").value - document.getElementById("min").value) + Number(document.getElementById("min").value);
        setTimeout(function() {
            bgm.pause();
            bgm.currentTime = 0;
        }, sec * 1000);
    })
}

所感

  • dragoverをキャンセルしないとページ遷移する
  • readAsDataURLはBase64文字列化して返すやつなので、でかいファイルを読み込むと固まります
  • そもそも椅子取りゲームにパソコン使わない

TwitterのApplication-only authenticationを試した

Application-only authentificationとは

詳細は公式ドキュメントを参照。
特定ユーザーの情報取得、ツイート検索といったAPIのみが使用できる。
ユーザー側のアプリ連携を要しないため、勝手にツイートしたりブロックしたりという危険がなくて良い。

トークンの取得

ここを参考にcURLでやった。

curl -u (API key):(API secret) -d grant_type=client_credentials https://api.twitter.com/oauth2/token

公式ドキュメントではBase64化してコロンでつなげてますが、それがBasic認証とのこと。
するとトークンが返ってくるので

curl -H "Authorization: Bearer (token)" https://api.twitter.com/1.1/search/tweets.json?q=Java

といった感じで使える。

PHPで使う

ツイート検索の結果をJSONPで返してみる。PHPcURLを使った。
ディレクトリ内にCA証明書が必要。

<?php
header('Content-type: text/javascript');
$ch = curl_init();
curl_setopt($ch, CURLOPT_CAINFO, getcwd().'/cacert.pem');
curl_setopt($ch, CURLOPT_URL, 'https://api.twitter.com/1.1/search/tweets.json?q='.urlencode($_GET['q']));
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Bearer (token)'));
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER,1);
$json = curl_exec($ch);
curl_close($ch);
echo $_GET['callback'].'('.$json.')';
?>

Google Apps Script + jQuery Mobileでスマホ用簡易タイムレコーダーを作った

最近ブラック企業に関する報道の影響か、自分自身の手帳等にも出社・退社時刻を記録すべきという主張を目にします。
とはいえ正直なところ面倒なので、スマホから記録できる簡易タイムレコーダーを作りました。

使い方

ボタンを押すだけ

仕様

jQuery MobileのクライアントからGETリクエストを送信すると、Google Apps ScriptのWebアプリが受け取ってスプレッドシートに記録し、JSONPで結果を返します。
クライアントはGoogle ドライブホスティング機能で公開するようにしました。そのためレンタルサーバ等は不要です。

作り方

  • 記録先スプレッドシートを準備
    • こんな感じの「template」シートを作成

      f:id:old_horizon:20140429222733p:plain

      スクリプトはこれを月初めにコピーし、「YYYY/MM」にリネームして記録します。
    • E, F, J列の書式を「時間」に
    • J1に「=SUM(F:F)」、J2に「=SUM(G:G)」
  • コードを書く(以下参照)
  • クライアント一式をGoogle ドライブで「ウェブ上で一般公開」→生成されたホスティングURLにアクセスして使う

コード

サーバー (Google Apps Script)
function doGet(e) {
  var scriptProperties = PropertiesService.getScriptProperties();
  var book = SpreadsheetApp.openById(scriptProperties.getProperty("bookId"));
  var now = new Date();
  var nowStr = Utilities.formatDate(now, "JST", "yyyy/MM/dd HH:mm:ss");
  var values = [];
  var response = {};
  
  switch(e.parameters.mode[0]) {
    case "checkin":
      var name = Utilities.formatDate(now, "JST", "yyyy/MM");
      var sheet = book.getSheetByName(name);
      // if sheet does not exist
      if (!sheet) {
        var sheet = book.getSheetByName("template").copyTo(book).setName(name);
        sheet.getRange(1, 1).setValue(name);
        book.setActiveSheet(sheet);
      }
      values[0] = Utilities.formatDate(now, "JST", "yyyy/MM/dd");
      values[1] = "=TEXT(R[0]C[-1],\"ddd\")";
      values[2] = nowStr;
      sheet.appendRow(values);
      response["status"] = "success";
      break;
    case "checkout":
      var sheet = book.getActiveSheet();
      var lastRow = sheet.getLastRow();
      values[0] = nowStr;
      values[1] = "=IF(R[0]C[-1]-R[0]C[-2]>=" + scriptProperties.getProperty("breakLimit") + "/24," + scriptProperties.getProperty("breakHours") + "/24,0)";
      values[2] = "=R[0]C[-2]-R[0]C[-3]-R[0]C[-1]";
      values[3] = "=IF(R[0]C[-1]-" + scriptProperties.getProperty("workHours") + "/24>0,R[0]C[-1]-" + scriptProperties.getProperty("workHours") + "/24,0)";
      sheet.getRange(lastRow, 4, 1, 4).setValues([values]);
      response["status"] = "success";
      break;
    default:
      response["status"] = "failed";
  }
  // return jsonp
  return ContentService.createTextOutput(e.parameters.callback + "(" + JSON.stringify(response) + ")").setMimeType(ContentService.MimeType.JAVASCRIPT);
}

スクリプトプロパティ

プロパティ
bookId 記録先スプレッドシートのキー
breakLimit 休憩時間が付与される最低労働時間(単位:時間)
breakHours 休憩時間(単位:時間)
workHours 所定労働時間(単位:時間)
クライアント (jQuery Mobile)
  • timeRecorder.js
var end = "終業時刻 (HH:mm)";

var exec = function(event) {
    if (window.confirm(event.data.name + "を記録しますか?")) {
        $.mobile.loading("show", {
            textVisible: true,
            textonly: false
        });
        $.ajax({
            url: "https://script.google.com/macros/s/(スクリプトのキー)/exec?mode=" + event.data.mode,
            type: "GET",
            dataType: "jsonp",
            timeout: 10000,
            success: function(data, status) {
                        if (data.status == "success") {
                            $.mobile.loading("hide");
                            alert("記録しました");
                        } else {
                            $.mobile.loading("hide");
                            alert("不正なリクエストです");
                        }
                    },
            error: function(data) {
                        $.mobile.loading("hide");
                        alert("通信エラーが発生しました");
            }
        });
    }
}


$(document).bind("pageinit", function() {
    var update = function() {
        var now = moment();
        var day = now.format("YYYY/MM/DD");
        $("#day").text(day);
        
        var current = now.format("HH:mm:ss");
        $("#current").text(current);
        
        var rest = moment(day + " " + end);
        rest = moment.utc(rest.diff(now)).format("HH:mm:ss");
        $("#rest").text(rest);
    }
    update();
    setInterval(update, 1000);
    $("#checkin").on("click", {mode: "checkin", name: "出勤"}, exec);
    $("#checkout").on("click", {mode: "checkout", name: "退勤"}, exec);
});
  • index.html
<!DOCTYPE html>
<html>
<head>
<title>タイムレコーダー</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="//code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.min.css" />
<script src="//code.jquery.com/jquery-1.9.1.min.js"></script>
<script src="//code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.5.1/moment.min.js"></script>
<script src="timeRecorder.js"></script>
</head>
<body>
<div data-role="page" data-theme="b" id="index">
<div data-role="header">
<h1>タイムレコーダー</h1>
</div>
<div role="main" class="ui-content">
<table data-role="table" id="data">
<thead>
<tr>
<th>日付</th>
<th>時刻</th>
<th>終業まで</th>
</tr>
</thead>
<tbody>
<tr>
<td><span id="day"></span></td>
<td><span id="current"></span></td>
<td><span id="rest"></span></td>
</tr>
</tbody>
</table>
<a class="ui-btn" href="#" id="checkin">出勤</a>
<a class="ui-btn" href="#" id="checkout">退勤</a>
</div>
</body>
</html>

メモ

Google Apps Script
  • ScriptProperties/UserPropertiesは廃止されたのでPropertiesServiceを使えとのこと
  • しかしPropertiesService.getUserProperties()で取得できるユーザープロパティはスクリプトエディタのUIからは編集できない、一方でスクリプトプロパティは編集できるという仕様 ^1
  • doGetの引数に入るparametersは配列になってるのか、[0]をつけないとswitch文でコケる?
  • Range#setNumberFormatでセルの書式を設定できるけど、24時間以上が表示できるか微妙な感じ
jQuery Mobile/JavaScript
  • $(document).ready()ではなくpageinit()を使えとのこと
  • 時刻計算にmoment.jsを使いました

Mechanizeでニコニコ動画の最新コメント500件を取得する

Mechanize使うだけですが、私の環境(RubyInstaller)でCA証明書のデフォルトパスを調べてみると

irb(main):001:0> require "openssl"
=> true
irb(main):002:0> p OpenSSL::X509::DEFAULT_CERT_FILE
"C:/Users/Luis/Code/openknapsack/knap-build/var/knapsack/software/x86-windows/op
enssl/1.0.0k/ssl/cert.pem"
=> "C:/Users/Luis/Code/openknapsack/knap-build/var/knapsack/software/x86-windows
/openssl/1.0.0k/ssl/cert.pem"

これでは困るのでカレントフォルダに置いたものを指定して使います。Mechanizeのドキュメント^1を参照。
cURLが配布しているもの^2が有名のようです。

require "mechanize"
require "uri"

mail = "your_mail_address"
password = "your_password"

auth = "https://secure.nicovideo.jp/secure/login?site=niconico"
getflv = "http://www.nicovideo.jp/api/getflv?v="
id = "video_id" # e.g. sm9

cert_store = OpenSSL::X509::Store.new
cert_store.add_file "cacert.pem"
crawler = Mechanize.new
crawler.cert_store = cert_store
crawler.post auth, mail: mail, password: password
params = crawler.get getflv + id
body = URI.decode(params.body)

params = {}
body.split("&").each do |elem|
    param = elem.split("=")
    params[param[0]] = param[1]
end

comments = crawler.post params["ms"], "<thread res_from=\"-500\" version=\"20061206\" thread=\"#{params['thread_id']}\" />"
File.write(id + ".xml", comments.body)

もしくは環境変数SSL_CERT_FILEでパスを上書きする、とのこと?