初めてのGreasemonkey の作り方(Slimtimer Report to Ticket)

初めてGreasemonkey を作りました!javascript は全然できないので(初めてのjavascript 4章まで読んだくらい)、その辺のソースを組み合わせて作りました。

どんな機能?

Slimtimer というサービスがあります。簡単に言うと、タスクを登録して、タスクを行う前にボタンを押し、終わった後にまたボタンを押すと、そのタスクにかかった時間が計測できるというツール。(詳しくはSlimTimer (オンライン仕事タイマー) : ワークスタイル・メモ


いつも、チケットに着手しているときはそのチケットをSlimtimer に登録しています。すると、「今週何やったの?」って言われた際に、Slimtimer のレポート画面を開くと一発で分かるので便利。


そこで、そのレポート画面から自分のTrac のチケットへリンクできると便利だなと思い作りました。

適応後

チケットタスク(#1 ほげる, #22 ふがる)の部分がリンクになります。



作り方

戦略としては次のような感じ。

  • 1. レポート画面からタスクの名前の部分を抽出
    • レポート画面からのタクスを抽出するのはXPath でできそうなイメージ
      • getElementByID はid がふられている場合にしかつかえない(class がふられていてもダメ)ので、XPath の勉強のためにもこっちを使う
      • レポートはtable で表現されて、タスク名はセルに格納されている
      • 要素のXPathfirebug で調べられるので簡単
      • タスク名が入っているtd を一括でとってきてループで回す?
  • 2. タスクの名前に#xxxx(xは1桁以上の数字)が含まれているかのチェック
  • 3. もし含まれていれば、タスク名の部分を以下のように置換
    • 多分xxxx は正規表現でマッチさせた時の情報からとれそう
<a href="Trac のプロジェクトへのURL/ticket/xxxx" target="_blank">タスク名</a>

1. レポート画面からタスクの名前の部分を抽出

とりあえず、テーブルのセルのデータを得る方法を求めて、「javascript table」でぐぐる。以下のページを発見!

特定のセルの値を取り出すには,次のようにする。

<table id="hoge">
  <tr><td>cell 1<td>cell 2
</table>

function cell(id, x, y) {
  table = document.getElementById(id);
  row = table.rows.item(y);
  cell = row.cells.item(x);
  document.write("cell(" + x + ", " + y + ") is " + cell.firstChild.data);
}
cell('hoge', 1, 0);


ここからセルの値を得る方法がわかる。

  • 1. table オブジェクトを得る
    • getElementById でやっているけど、XPath で得ることもできるはず
  • 2. table.rows で列のコレクションを得られる
  • 3. row.cells でその列のセルを得られる
  • 4. cell.firstChild.data でセルの値を得られる


ってことで、多分以下のようにやると値を得られる。デバッグは基本alert を使う。

var xpath ='xpath_to_table';
var table = xpath からオブジェクトを得る
alert(table.rows[0].cells[0].firstChild.data)
ここまでをGreasemonkey にしてみる

どうせなら今回みたいにリンクに置換する系のGreasemonkey があると良いなと思い、「greasemonkey リンク」でぐぐる。どんぴしゃというのはなかったけど、以下のスクリプトが参考になりました。

ここから、次のことが分かった。

  • XPaht からオブジェクトを取り出す方法
// evaluate の第一引数がxpath
var target = document.evaluate("//b[@class='sans']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  • グリモンのメタデータ部分
    • @name: グリモンの名前
    • @namespace: なんか自分の名前とか書いておく(namespace という名前から、多分同じ名前のスクリプトがあった場合の衝突をさけるための名前空間だと思う)
    • @description: グリモンの説明
    • @include: そのグリモンを機能させるページのURL(* はワイルドカード
  • グリモンのロジック部分
    • 以下の無名関数の中にロジックを書けばいい
(function(){
}
)();


で、書いてみた!今回の対象ページのテーブルにはid がふられてなかったけど、tbody にid がふられていたのでそっちを使いました。table の代わりにtbody を使っても大丈夫だった。

// ==UserScript==
// @name         Slimtimer Report to Ticket 
// @namespace    http://d.hatena.ne.jp/LukeSilvia/
// @description  Create a link to ticket on the report by slimtimer.
// @include      http://slimtimer.com/tasks*
// ==/UserScript==

(function() {
    var xpath ='//*[@id="task-tbody"]';
    var tbody = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    alert(tbody.rows[0].cells[0].firstChild.data)
)();


おぉぉ〜!インストールして、「http://slimtimer.com/tasks」に行ったら、「ルーティン」っていうalert がでましたよ!(ルーティンは一番上タスクのタスク名
以降は、インストールしたグリモンを編集して行きます。「ユーザースクリプトの管理」から編集できる。こうすると、編集した結果をすぐにブラウザで確かめられて便利。

2. タスクの名前に#xxxx(xは1桁以上の数字)が含まれているかのチェック

やることは以下のような感じ。

  • 1. ループを使って、タスク名を得る
  • 2. ループ内で正規表現を使って、タスク名に#xxxx が含まれるかチェック


js でもfor ループが使えるのは知っている。上記のグリモンの最後で「alert(rows.length)」 ってやったら列数が表示されたので、それを使えばよさそう。
後は、正規表現について調べる。「javascript 正規表現」でぐぐる。みっけた。

    xx = "12:34:56".match(/(\d+):(\d+):(\d+)/);
    document.write(RegExp.$1 + "<BR>") // → 12
    document.write(RegExp.$2 + "<BR>") // → 34
    document.write(RegExp.$3 + "<BR>") // → 56


分かったこと。

  • match メソッドで正規表現と比較できる
  • 正規表現での数字はおなじみ「\d」
  • ()とRegExp.$n でマッチした部分の中で特定の部分を取り出せる。
ここまでをグリモンにしてみる
// ==UserScript==
// @name         Slimtimer Report to Ticket 
// @namespace    http://d.hatena.ne.jp/LukeSilvia/
// @description  Create a link to ticket on the report by slimtimer.
// @include      http://slimtimer.com/tasks*
// ==/UserScript==

(function() {
    var xpath ='//*[@id="task-tbody"]';
    var tbody = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    var rows = tbody.rows; 

    for(var i = 0; rows.length; i++) {
	task = rows[i].cells[0];

	if(task.firstChild.data.match(/#(\d+)/)) {	    
	  alert(RegExp.$1);
	}
    }
}
)();


ktkr!「1, 22」が順番にalert されましたー( ´艸`)

3. もし含まれていれば、タスク名の部分を以下のように置換

最後に、置換部分。ここでやることは1つ。

  • 1. チケットのタスク名をa タグで囲む

このヒントはさっきも参考にしたグリモンgreasemonkeyでamazonにG-Tools個別商品ページへのリンクの中にありました。

var atg_link = document.createElement('a');
      atg_link.setAttribute('href', 'http://www.umizoi.com/g-tools/foward_ecs4.php?view=detail&region=jp&asin=' + asin);
      atg_link.setAttribute('title', 'Information on this commodity is acquired with G-Tool.'); 
      atg_link.innerHTML = '<span style=\"margin-left: 1em; font-size:90%;\">G-Tools</span>';
      header.parentNode.insertBefore(atg_link, header.nextSibling);


これでa タグの生成方法がわかったけど、多分insertBefore でリンクを追加している部分を変えないとだめそう。insertBefore は追加なので、今回の置換の目的には使えない。「javascript 置換 ノード」でぐぐる

上記のHTMLでh1の要素をpにinnerHTMLを使わないで書き換えろという問題。

そう言えばdel.icio.usで「innerHTML遅いからW3C DOMを使おうよ」みたいなエントリ
を見た覚えがあります。というわけで回答は以下。

var div = document.getElementById("elem1");
var children = div.childNodes;
var h1 = children.getElementsByTagName("h1")[0];
var p = document.createElement("p");
var txt = document.createTextNode("This is replaced text.");
child.appendChild(txt);
div.replaceChild(p, h1);

置き換えるノードとその親ノードを習得して、
parentNode.replaceChild(置換するノード, 置換されるノード)で置換する。
中のテキストはテキストノードを作って、子ノードとして追加することで追加できる。


ここから分かること。

  • replaceChild で要素の置換ができる
  • アンカーテキストの部分はinnerHTML 使うより、テキストノードを作ってappendChild する方が今の仕様にあっている(XHTML 的な考え方か?)
できあがったグリモン
// ==UserScript==
// @name         Slimtimer Report to Ticket 
// @namespace    http://d.hatena.ne.jp/LukeSilvia/
// @description  Create a link to ticket on the report by slimtimer.
// @include      http://slimtimer.com/tasks*
// ==/UserScript==

(function() {
    var xpath ='//*[@id="task-tbody"]';
    var tbody = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    var rows = tbody.rows;

    for(var i = 0; rows.length; i++) {
	task = rows[i].cells[0];

	if(task.firstChild.data.match(/#(\d+)/)) {	    
	    var task_link = document.createElement('a');
	    task_link.setAttribute("target", "_blank");
	    task_link.setAttribute(
		"href", 
		"Trac のプロジェクトへのURL/ticket/" + RegExp.$1);
	    task_link.appendChild(document.createTextNode(task.firstChild.data));
	    task.replaceChild(task_link, task.firstChild);
	}
    }
}
)();


動きましたよ〜(⊃д⊂)

改善

会社のレポジトリにコミットしたら、先輩から速攻のツッコミ!ありがたく受け取り修正しました!

つっこまれた部分
  • xpath からオブジェクトを得る部分
  • for 文の部分
    • Firefox のjs では、Array.forEach(rows, function(row) { ... }) と書けるよ
      • rows はArray ではないのでrows.each とは書けないよ


うぉ〜!勉強になります。ツッコミ頂いたら修正するのが礼儀だわ!ってことで修正。

修正版
// ==UserScript==
// @name         Slimtimer Report to Ticket 
// @namespace    http://d.hatena.ne.jp/LukeSilvia/
// @description  Create a link to ticket on the report by slimtimer.
// @include      http://slimtimer.com/tasks*
// @require      http://gist.github.com/raw/3242/1f990d36ad5cb261a481826411c4d7d6497ad5f3
// ==/UserScript==

const BaseURL = "Trac のプロジェクトへのURL";

function toTicketAnchor(task, number) {
    var task_link = document.createElement('a');
    task_link.setAttribute("target", "_blank");
    task_link.setAttribute("href", BaseURL + number);
    task_link.appendChild(document.createTextNode(task.firstChild.data));
    task.replaceChild(task_link, task.firstChild);	
}

(function() {
    var data = $X('id("task-tbody")');    
    if(!data)
	return;
    
    var tbody = data[0];
    var rows = tbody.rows

    Array.forEach(rows, function(row) {
	var task = row.cells[0];
    
	if(task.firstChild.data.match(/#([0-9]+)/)) {	    	    
	    toTicketAnchor(task, RegExp.$1);
	}
    });
}
)();
感想

Greasemonkey は超楽しい!要は、コンテンスが自分専用にカスタマイズできるってわけですよね!不便だと思ったら、コンテンツの管理者に言うのではなく、自分でカスタマイズすればいいっていう話だ。
いや〜、グリモンだけでもjs を学ぶ価値は十分にあるな〜。js かっこいい!

次に作りたいグリモン

今度は、「http://slimtimer.com/report」の画面で同じことをやりたい。問題は、レポート結果がAjax レスポンスを使って描画されること。
フォームのonsubmit イベントでAjax リクエストを送るようになっている。「addEventListener」を使ってonsubmit にさっきのグリモンの処理を追加しようと思ったけどだめだった。Ajax のレスポンスが返ってくる前に処理が作動してしまう。なかなか難しい。