CakePHP から レコメンデーションエンジン Cicindela にアクセスするためのライブラリを公開しました。
http://github.com/tkyk/cakephp-cicindela
使い方はREADMEを読んでください。Cicindela本体のWeb APIとほとんど同じ構造なので、特に迷うことはないだろうと思います。
ちなみに Configure::read(‘debug’) > 1 で実行すると、DboSourceと同じようにリクエスト情報が一覧表示されます。デバッグに便利です。こんな感じ:
(cicindela) 2 request(s)
| Nr | URL | Status | Took (ms) |
| 1 | http://host/cicindela/recommend?set=pick&op=for_item&item_id=Ruby | HTTP/1.1 200 OK | 2887 |
| 2 | http://host/cicindela/recommend?set=pick&op=for_item&item_id=PHP | HTTP/1.1 200 OK | 1346 |
….
以下、このライブラリを作りながら考えたこと。
このライブラリはDataSource + Behaviorという2クラス構成になっている。最初はDataSourceだけでできるかと思っていたのだが、無理だった。現在のCakeのModelクラスは、DataSourceとDboSourceの関係で言うなら”DboModel”であって、非DboのDataSourceを扱うには適していない。
現在のModelが”DboModel”であるように、XXXSourceに対してはXXXModelが存在することが望ましい。CakePHP 1.3からはDataSourceがプラグイン化できるようなので、同時にModelのベースクラスを提供するようにすれば、ある程度は改善されると思う。ただそれでも結局(プラグインの)ユーザがいちいちクラスの継承関係に気を使わなければいけない点が煩わしい。理想を言うなら、1.非常に簡素なBaseModelクラスを用意する、2.DataSourceにモデル初期化用コールバックを設ける、3.初期化用コールバックにおいてBehaviorをattachする、DataSource固有の機能はBehaviorに実装する……という構成にできれば良いのではないか。
先日使い方を学んだ MessageEvent と、location.href+javascript:ハックとJSDeferred userscript版とを組み合わせ、XPCNativeWrapper と unsafeWindow の間で同期処理を行うコードを書いてみた。
まずは MessageEvent を使って前回 evalInPage 相当の処理を作る。キャンセル処理も入れたかったので UnsafeWrapper というオブジェクトにまとめることにした。前回より長くなったように見えるが、実際には MessageEvent の lastEventId が使えるようになった分、簡潔になっている。
var UnsafeWrapper = new (function() {
var seqId = 0, waiting = {}, self = this, noop = function(){};
function dispatch(data, id) {
var e = document.createEvent("MessageEvent");
e.initMessageEvent('GM_UnsafeWrapper_returned', true, false,
data, location.protocol + "//" + location.host,
id, window);
document.dispatchEvent(e);
}
function listen(func) {
document.addEventListener('GM_UnsafeWrapper_returned', func, false);
}
listen(function(e) {
if(waiting[e.lastEventId]) {
waiting[e.lastEventId].call(self, JSON.parse(e.data));
delete waiting[e.lastEventId];
}
});
this.exec = function(func, args, callback) {
var userFunc = "("+ func +").apply(null,"+ JSON.stringify(args || []) +")";
waiting[seqId] = callback || noop;
location.href = "javascript:void "+
(function(dispatch, ret, id) { dispatch(JSON.stringify(ret), id) }) +
"("+ dispatch +","+ userFunc +"," + JSON.stringify(seqId) +")";
return seqId++;
};
this.cancel = function(id) {
delete waiting[id];
};
});
そして Deferred に対するアダプタを書く。とても簡単。
with(D()) {
function unsafeExec(func, args) {
var d = new Deferred();
var id = UnsafeWrapper.exec(func, args, function(ret) {
d.call(ret);
});
d.canceller = function(){ UnsafeWrapper.cancel(id) };
return d;
};
}
これで例えば、GM_xmlhttpRequest で外部ドメインからデータを取得して、そのデータを整形して、unsafeWindow 側で表示処理を行って、そしてその結果をuserscript側で受け取って…という処理は次のように書くことができる。
with(D()) {
xhttp.get("http://example.com/")
.next(function(res) {
var data = res.responseText;
//....
return unsafeExec(function(data) {
$.each(data, function(k,v) {
//...
});
//...
return result;
},
[data]);
})
.next(function(result) {
//...
});
}
より実用性を高めるのであれば、location.href で実行するコード全体を try {} で囲み、catch したエラーをJSONに変換して _failed イベントを dispatch して、Deferred の fail に転送すれば、unsafeWindow 側で起こったエラーをuserscript側のDeferred chainで捕捉できるようになる。
参考:
DB内のMEMORYテーブルのサイズを、テーブルごとにグラフ化するための munin プラグインを作ってみました。
mysql_memory_tables_
インストール
munin-node 本体のプラグインディレクトリ(/usr/share/munin/plugins など)にコピーしてください。
設定
ワイルドカードプラグインなので、mysql_memory_tables_{チェック対象となるDB名} という名前でシンボリックリンクを張ってください。
ln -s /usr/share/munin/plugins/mysql_memory_tables_ /etc/munin/plugins/mysql_memory_tables_db1
データを取得するには対象DBの INFORMATION_SCHEMA にアクセスする権限が必要です。プラグイン設定ファイル(/etc/munin/plugin-conf.d/munin-node など)で mysql コマンドに渡すユーザ名・パスワードを指定してください。
[mysql_memory_tables_*]
env.mysqlopts -u cicindela --password=hogehoge
動作確認
munin-run コマンドでテストができます。
# 値の表示
munin-run mysql_memory_tables_db1
# グラフ設定情報の表示
munin-run mysql_memory_tables_db1 config
動作が確認できたら munin-node を再起動してください。
XPCNativeWrapperの外側でスクリプトを評価する関数。Firebugも同じことをしている。unsafeWindowを触らないので安全。
function evalInPage(fun) {
location.href = "javascript:void (" + fun + ")()";
}
SmartLDR更新 – 素人がプログラミングを勉強するブログ
これは面白い!Function.prototype.toString が関数のソースコードを返すことを利用した、巧妙にして簡潔なハック。
次のように引数を渡せるように改良すればさらに強力になる(引数に渡せるのはJSON化可能な値のみ)。
function evalInPage(func, args) {
var argStr = JSON.stringify(args || []);
location.href = "javascript:void "+ func +".apply(null,"+ argStr +")";
}
GM_xmlhttpRequest を使って別ドメインから得たデータを、unsafeWindow 上のライブラリを使って表示する、といったコードが安全かつ自然に書けるようになる。
// データの取得は Greasemonkey で行う
GM_xmlhttpRequest({ method:"GET",
url: "http://example.com/api",
onload: function(response) {
var data = response.responseText;
//...
evalInPage(render, [data]);
}
});
// データの表示は unsafeWindow 上で行う
// jQueryなど unsafeWindow 上のライブラリが使える
function render(data) {
$.each(data, function(k,v){
//...
})
}
これだけでも十分便利なのだが、「返り値が利用できない」「Firefoxのlocation.hrefは非同期で実行される」といった難点があるらしい。それを回避するためにunsafeExec on JSDeferred – 枕を欹てて聴くにおいてはJSDeferredというライブラリを使う方法が紹介されているのだが、コードを見る限り unsafeWindow に対するアクセスが必要になるようだ。
unsafeWindow にアクセスせずにGreasemonkeyに制御を戻すには、DOM Eventを使うのが良いと思う。以下、DOMNodeInserted イベントを使って実装してみた(返り値として利用できるのはJSON化可能な値のみ)。
追記: Constellationさんからコメントをいただきました。DOMNodeInsertedなどのイベントを使わなくても、直接createEvent/dispatchEventでイベントを生成・データを送受信する方法があるようです。 詳細はこのページの Constellationさんのコメントや、『Greasemonkey スクリプトとイベントで通信: Days on the Moon』など参照してください。
function getUniqId() {
do {
var id = "_tmp" + String(Math.random()).slice(2);
} while(document.getElementById(id));
return id;
}
function evalInPage(func, args, callback) {
var argStr = JSON.stringify(args || []),
userFunc = "("+ func +").apply(null,"+ argStr +")";
if(!callback) {
location.href = "javascript:void "+ userFunc;
return;
}
var div = document.createElement('div'),
id = getUniqId();
div.id = id;
div.style.display = 'none';
document.body.appendChild(div);
div.addEventListener('DOMNodeInserted', function(e){
callback(JSON.parse(e.target.nodeValue));
div.parentNode.removeChild(div);
}, false);
location.href = "javascript:void "+ (function(ret, id) {
document.getElementById(id)
.appendChild(document.createTextNode(JSON.stringify(ret)));
}).toString() + ".call(null,"+ userFunc +"," + JSON.stringify(id) +")";
}
JSDreferredを使う方法と同じく、戻り値を受け取るコールバック関数を指定する。
evalInPage(function(obj, num) {
//この中のコードは unsafeWindow で実行される
return { answer: obj.a + num };
},
[{a:99}, 1],
function(ret) {
//この中のコードは Greasemonkey で実行される
alret(ret.answer);
});
多分まじめに作り込めば一種のRPCシステムみたいになると思う。
確実にグローバル関数/変数にアクセスしたいとき。あるいはglobalオブジェクトの名前が分からないときに。
var global = (function(){ return this })();
ブラウザ上では window がglobalオブジェクトを指すことは暗黙の前提になっているが、そうでない環境もあり得る。
ちなみに Greasemonkey と Chrome user-script ではともに window でglobalオブジェクトにアクセスできるようだ。
(CakePHP 1.2.5)
ビヘイビアに定義されたメソッドは、モデル自身のメソッドであるかのように呼び出すことができます。
class FooBehavior extends ModelBehavior {
function foo($model){ /* ... */ }
}
class Bar extends AppModel {
var $actsAs = array('Foo');
}
// in Controllers
$this->Bar->foo();
しかしこの方法では Dynamic Finder のように動的に名前が変化するメソッドは実装できません。
ModelBehavior クラスの $mapMethods プロパティを使用すると、この制約を乗り越えることができます。$mapMethods は配列で、次のような構造をしています:
array( '/メソッド名に対する正規表現/' => '実際に呼び出されるメソッド' )
例えば '/^list/' => '_genericList' という設定を行った場合、listから始まる全てのメソッド呼び出しが、ビヘイビアの _genericList メソッドの呼び出しに変換されます。このとき _genericList メソッドの引数には元のメソッド名と引数が渡されます。ちょうど __call によるメソッドのオーバーロードと同じです。
この機能を活用して、あるカラムの値を配列として取得するための listColumn メソッドを作ってみます(Column はカラム名)。このメソッドは ListingBehavior に実装することにしましょう。
class ListingBehavior extends ModelBehavior {
var $mapMethods = array('/^list[a-zA-z0-9]+$/' => '_listColumn');
function _listColumn($model, $methodName, $query=array()) {
$column = substr($methodName, 4);
$query = am($query, array('fields' => array($model->alias.".".$column)));
$arr = $model->find('all', $query);
if(is_array($arr)) {
return Set::extract("/{$model->alias}/{$column}", $arr);
}
return $arr;
}
}
モデルにこのビヘイビアを組み込むと、任意のカラムに対して listXXX メソッドを呼び出すことができるようになります。
class User extends AppModel {
var $actsAs = array('Listing');
}
// in Controllers
$this->User->listId();
$this->User->listUsername(array('conditions' => 'active = 1'));
$this->User->listGroup_id();
メソッド名は小文字に変換されて渡されるので(これが仕様なのかバグなのか微妙なところです)、残念ながら Inflector::underscore を使うことはできません。
ow.lyやoneclip.jpなど、元コンテンツをiframe内に表示するタイプのURL短縮サービスから、自動的に元URLにリダイレクトするuser scriptを作りました。
Redirect to the iframed URL
同様の機能を持つものとしてiframe redirectorがあり、今まで私も使用していたのですが、対象サービスのURLやHTML構造に依存するコードが含まれているため、対象サービスの構成が変化すると動かなくなってしまいます(少し前に am6.jp の仕様が変わって動かなくなりました。現在は修正されています)。
そのため拙作のスクリプトではURLやHTMLに依存するコードは使わず、「面積比でbodyの4分の1を占めるiframe」を元コンテンツと判断するようにしました。おそらくほとんどのケースで問題なく動作するものと思われます。
デフォルトでは以下のURLで動作します。
- http://ow.ly/*
- http://oneclip.jp/*
- http://am6.jp/*
今後新たに同種のサイトが登場した場合は(登場してほしくないのですが)、Greasemonkeyの管理ダイアログから「ユーザスクリプトを実行するページ」にURLを追加してください。スクリプトの修正は不要です。
Firefox 3.5.7 + Greasemonkey 0.8.2 および Mac版 Google Chrome 4.0.295.9 で動作確認しています。
参考
URL短縮サービス ow.ly は実URLが表示されずに危険 → グリモン書いた – まちゅダイアリー(2009-11-05)
ライブドア製レコメンデーション・エンジン Cicindela の動作を確かめるために、簡単なAjaxアプリケーションを作ってみました。
前回の続きです。
JavaScript: Logger
Cicindela との連携部分を作る前に、簡単な Logger を作っておきます。ログの出力先には TEXTAREA 要素を想定しています。
var Logger = {
clear: function() {
$('#log').val("");
},
append: function(text) {
var dom = $('#log').get(0);
dom.value+= text + "\n";
dom.scrollTop = dom.scrollHeight;
}
};
Cicindela への入力
まずは入力部分から作っていきます。このアプリケーションにおいて必要な操作は「あるユーザがアイテムを選択した」、「あるアイテムをあるカテゴリに所属させる」の2つです。これらの操作を抽象化するために、Cicindela Web APIに対する Proxy となるオブジェクトを作ります。
var Cicindela = {
base: '/cicindela/',
set: 'sample',
_insert_op_url: function(op, item) {
return this.base +"record?set="+ this.set +"&item_id="+ item + "&op="+ op;
},
_send_req: function(url, cb) {
Logger.append("send Request to " + url);
$.ajax({ type: "GET",
url: url,
success: cb,
complete: function(xhr, status) {
Logger.append("receive Response["+ xhr.status +"] from "+ url);
}
});
},
set_category: function(item, category) {
this._send_req(this._insert_op_url("set_category", item) + "&category_id="+ category);
},
insert: function(user, item) {
this._send_req(this._insert_op_url("insert_pick", item) + "&user_id="+ user);
}
};
insert がアイテム選択、set_category がカテゴリ登録のためのメソッドです。実際にリクエスト送信を行うのは _send_req メソッドで、送信先URLと成功時に呼び出されるコールバック関数とを引数にとります。set プロパティには集計セットの名前を書いておいてください。
あとは「insert」ボタンが押されたときに、これらの API を呼び出します。
// $(document).ready において
$('button#insert').click(
function(e) {
var user_id = $('#username').val();
if(user_id == "") {
return false;
}
$.each(data, function(category_name, category_data) {
var category_id = category_data.id;
var container = $("#"+ category_name);
var values = container.val();
$.each(values,
function(i, v) {
Cicindela.set_category(v, category_id);
Cicindela.insert(user_id, v);
$('#username').val("");
});
});
return false;
});
このアプリケーションの場合、カテゴリ登録はデータの初期化時にまとめて行った方が効率的なのですが、「同じアイテムを、何度同じカテゴリに登録しても問題ない」ことを確かめるために、あえてアイテム選択時に毎回登録を実行しています。
これで Cicindela への入力準備が整いました。ユーザ名を入力して、適当にアイテムを選択して「insert」ボタンを押してみます。成功すれば次のようなログが出力されるはずです。
send Request to /cicindela/record?set=sample&item_id=Ruby&op=set_category&category_id=1
send Request to /cicindela/record?set=sample&item_id=Ruby&op=insert_pick&user_id=cicindela
receive Response[204] from /cicindela/record?set=sample&item_id=Ruby&op=insert_pick&user_id=cicindela
receive Response[204] from /cicindela/record?set=sample&item_id=Ruby&op=set_category&category_id=1
またMySQLサーバにログインして、categories テーブルや picks テーブルにデータが入っていることを確認します。
Cicindela からの出力
続いて Cicindela からレコメンデーション・データを取り出してみます。必要なのは「特定アイテムに対するレコメンデーション(関連アイテム)取得 (=item to item)」なので、次のような get_for_item メソッドを Cicindela オブジェクトに追加します。
var Cicindela = {
/* ...略... */
get_for_item: function(item, category, cb) {
var url = this.base +'recommend?set='+ this.set
+'&op=for_item&item_id='+ item +"&category_id="+ category;
this._send_req(url, cb);
}
};
そして「get」ボタンが押されたときにこのメソッドを呼び出します。第3引数として渡すコールバック関数は、正常にレスポンスが受信できたときに受信データを引数として呼び出されます。Cicindela からのレスポンスは改行区切りの文字列データです。ここでは単に出力用の要素に html メソッドで書き込んでおきます。
// $(document).ready において
$('#get').click(
function(){
var item_id = $('#items').val();
$.each(data, function(category_name, category_data) {
Cicindela.get_for_item(item_id,
category_data.id,
function(data) {
$("#output-"+ category_name).html(data)
});
});
});
以上で一通りの機能が完成しました。ユーザ名と選択アイテムを変えて何度か入力を行い、レコメンデーションを取得してみましょう。その際には自分なりに”シナリオ”を決めて、そのシナリオに沿った結果になるか試してみると面白いです。例えば:「Perl好きはEmacsやVimを使う」、「WindowsユーザにもMacユーザにもLinuxユーザにもJava好きはいる」、「EmacsとVimを両方ともよく使うユーザは少ない」などなど。
完全なソースを gist に置いておきます。
ライブドア製レコメンデーション・エンジン Cicindela の動作を確かめるために、簡単なAjaxアプリケーションを作ってみました。スクリーンショット:
「好きなプログラミング言語」と「よく使うソフトウェア」についての情報を収集し、その結果に基づいておすすめの言語・ソフトを算出する、一種のアンケートです。
現実にはAjaxアプリケーションから直接 Cicindela のWeb APIを叩くのは難しいと思いますが(いたずらされ放題になってしまうので)、機能をテストするのには手軽です。以下に作り方を紹介しておきます。
- Cicindela はセットアップ済みとします(CentOS 5にセットアップする手順はこちら)
- カテゴリー機能を使います
- アイテムid・ユーザidは文字列です
- jQuery 1.3.2 を使います
- Firefox 3で動作確認します(ほかのブラウザでも動くと思いますが)
データベースの初期化
create_init_sql.pl を実行して使用するデータベースを作成します。DB名は sample としました。
perl create_init_sql.pl --db_name=sample | mysql -uroot
cicindelaの設定
lib/Cicindela/Config/_common.pm に集計セットを定義します。
'sample' => {
datasource => [ 'dbi:mysql:sample;host=localhost', 'cicindela', 'cicindela' ],
filters => [
'PicksExtractor',
'InverseUserFrequency',
'ItemSimilarities',
],
recommender => 'ItemSimilarities::LimitCategory',
refresh_interval => 1,
use_user_char_id => 1,
use_item_char_id => 1,
discard_user_id_char2int => '6 month',
},
カテゴリ機能を使いたいので、フィルタとして ItemSimilarities を、レコメンダとして ItemSimilarities::LimitCategory を指定します。またユーザidとアイテムidに文字列を使用したいので、 use_*_char_id を設定します。結果を確認しやすくするために refresh_interval を 1 にしていますが、実運用時にはもっと大きな値を指定してください。
HTML
HTMLのbody内を抜粋すると次のようになります。SELECT 要素は後からJavaScriptで初期化するので、この段階では空っぽです。
<h2>追加</h2>
<div class="insert">
<p>username: <input type="text" id="username" /></p>
<fieldset><legend>好きな言語</legend>
<select multiple="multiple" id="langs"></select>
</fieldset>
<fieldset><legend>よく使うソフト</legend>
<select multiple="multiple" id="softs"></select>
</fieldset>
<p class="submit-space"><button id="insert">insert</button></p>
</div>
<h2>取得</h2>
<div class="recommend">
<p><select id="items"></select></p>
<fieldset><legend>おすすめ言語</legend>
<div id="output-langs"></div>
</fieldset>
<fieldset><legend>おすすめソフト</legend>
<div id="output-softs"></div>
</fieldset>
<p class="submit-space"><button id="get">get</button></p>
</div>
<h2>ログ</h2>
<textarea id="log"></textarea>
JavaScript: データの定義
使用するデータは JavaScript のオブジェクトとして、次のような構造で定義します。langs と softs という2つのカテゴリがあり、それぞれに数値のid(1,2)が割り当てられています。
var data = {
langs: { id: 1,
list: ['Perl', 'PHP', 'Ruby', 'Python', 'Java',
'JavaScript', 'CSharp', 'Erlang', 'CommonLisp'] },
softs: { id: 2,
list: ['Windows', 'Linux', 'Mac', 'Emacs', 'Vim',
'Eclipse', 'NetBeans', 'VisualStudio', 'Dreamweaver'] }
};
JavaScript: SELECTの初期化
document の ready イベントにてSELECT要素を初期化します。Cicindela に渡す item_id としてアイテムの名前をそのまま使うので、OPTION要素の value 値にはアイテム名を設定しておきます。
$(function() {
$.each(data, function(category_name, category_data) {
var all_items = $('#items');
var category_items = $("#"+ category_name);
$.each(category_data.list,
function(i,l) {
$('<option />').attr('value', l).text(l)
.clone().appendTo(category_items)
.clone().appendTo(all_items);
});
});
});
続く。