スキップしてメイン コンテンツに移動

はてなWebサービスAPIを用いたPerl/Ruby Webアプリケーション2題

Webサービスというものが新しい技術として流行ったのは2001年頃だった。そこで語られていたビジョンというのは、WSDLで定義したWebサービスAPIをUDDIに登録し、それらがSOAPで通信しながらWeb上に広がるアプリケーションを構成するというようなものである。MS Windows的に言うと、レジストリに登録されたCOMコンポーネントのインターフェイス発見/呼び出しメカニズムのインターネット版ということにな る。ただし、Windowsの場合は、DCOMやCOM+といった、Windowsシステム同士のネットワークやWindowsシステム内部を一貫した分散オブジェクトRPCの文脈でとらえる仕組みを経て、一度レガシーを整理し、.NETに至る。このシステムをビルディングブロックとして利用することが宣伝されたHailstormというマイクロソフト提供のプラットフォームは、個人向けWebサービスをUDDI経由で提供するという触れ込みだった。その後Hailstormは、シングルサインオン認証をめぐる覇権争いに巻き込まれた挙げ句、開放された世界での商業的キーワードとしては消滅してしまった。他方、同時期にローンチして以来、コントロールされた閉鎖環境で運営されてきているシングルサインオンの理想型が、有料サービスXbox Liveとして存続している。


その時点まで一旦遡ってから改めて俯瞰すると、最近のWebサービスを巡る再評価の流れもそれがどうしたと斜に構えやすい。XMLも5年くらい前に流行り、当時はXMLスキーマが今後重要になると言われていたが、そのあたりの知識が役に立つ局面が以後拡大したとは思えない。そういうわけで、この辺りの物事は個人的な関心からは長いこと外れていた。2007年の現在それらはつまらない玩具のようなものに留まるのか、それとも個人ユーザにとって真に使える道具なのか。近頃の各種Webサービスは、APIを開放し、プレスリリースを出して客寄せを行い、利用者が増えたら閉じるという、提供企業による手軽な宣伝活動の産物であり、個人ユーザ囲い込みの道具である。それを利用する個人の動機はWeb広告業界に投じられている金で、その量が5年前とは相当異なる以上、以前とは位相が異なってはいるわけだ。Webサービス同士の競争が起こり良質なサービスが数多く提供されるまでになれば面白くなるかもしれない。しかし、Webサービスを介して提供される元ネタが数少ない企業に独占されている現状では、そんなシナリオが実現する蓋然性は低く、バリエーションの少ない似通ったWebサービスを「マッシュアップ」と称してデプロイする個人は、IT企業の走狗として活動しながら細々と広告収入を稼ぐことになる。

今回の試みでは、「はてな」 の認証WebサービスAPIと、「はてな」のキーワードWebサービスAPIを利用している。1つ目のWebアプリケーションは、「はてな」認証APIを 利用した、「はてな」ユーザが特定の他ユーザに外部サーバを介して任意のデータファイルを渡すためのファイルアップローダである。言語はRubyで、RubyのWebサーバMongrelのモジュールとして書かれている。もう1つのWebアプリケーションは、はてなキーワードAPIを利用した穴埋めクイズ作成と、はてなキーワード連想グラフの視覚化を行う。言語はPerlで、PerlのWebサーバPOE::Component::Server::HTTPのモジュールとして書かれている。

これらは、双方ともスクリプト内でWebサーバモジュールをインクルードするので、コンソールからrubyなりperlなりを通してスクリプトを実行すればそのまま動作し、ホスト用のWebサーバを別途必要としない。起動時に設定ファイルの内容を読んで、以降はWebサーバの一部としてリクエストに応じるWebアプリケーションである。前回のPerlとRubyの比較ではマルチスレッドと組み込みがポイントだったのに対し、今回は、Webアプリケーションの作成と簡易Webサーバという、Webアプリケーションをめぐる 環境の比較を行うという趣向だ。マルチスレッドに関しては、Mongrelが当然のようにマルチスレッドで、モジュールも対応が必要なのに対し、POEはマルチスレッドに対応していないようである。

双方のWebサーバともプロダクション環境に適した性能を有するサーバではなく、またここで解説するWebアプリケーションはXSS対策やSQLインジェクション対策などのセキュリティ上のケアを欠いているので、あくまで各WebサービスAPIの動作サンプルとしてのみ御覧いただきたい。ソースコードの文字コードは双方ともUTF-8で、Windows XP SP2上にて作成し、Microsoft Internet Explorer 7ならびにFirefox 3.0a1 trunk build 20061218で動作確認している。「はてな」のWeb APIの仕様は本記事を書いた2006年末の時点のものに依っているので、仕様変更によって任意の時点でこれらのアプリケーションが動かなくなっている可能性もある。

では一つ目の、Rubyアプリケーションの方から見ていこう。hatenawebapp1.confは設定ファイルで、YAMLフォーマットである。Webサーバのポートや、認証APIに与えるキー、データベースファイル名などを設定する。

# hatenawebapp1.conf
#
# configuration file for hatenawebapp1.rb

# Bound address
bound_address: "127.0.0.1"

# Bound port
bound_port: 80

# API key for Hatena Auth
api_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# Private key for Hatena Auth
private_key: "xxxxxxxxxxxxxxxx"

# File name for the SQLite database
db_filename: "hatenawebapp1.db"

# Path for uploaded files
file_store_path: "./store"


以下はスクリプト本体hatenawebapp1.rbで、ruby 1.8.5 (2006-12-04 patchlevel 2) [i386-mswin32]で動作確認した。必要ライブラリは、
RubyGems-0.9.0
mongrel-0.3.13.3-mswin32
sqlite3-ruby-1.1.0-mswin32
hatenaapiauth-0.1.0
uuidtools-1.0.0
scrapi-1.2.0

と、rubygemsツールで取得可能な依存ライブラリである。尚、sqlite3のライブラリのバイナリが別途必要で、Windowsの場合はスクリプトのディレクトリにsqlite3.dllを、Unixの場合もSQLite公式サイトで入手できるライブラリのバイナリをパスの通った場所へ置く必要がある。また、Mongrelは0.3.13.3を使用しているが、アップデートが頻繁なので異なるバージョンは不具合が出る可能性もある。


=begin

hatenawebapp1.rb

Sample Web Application 1 with Hatena Web Service API : File Transfer between Hatena Users

by RyuK (klassphere[at.mark]gmail.com)
http://zzz.zggg.com/
http://aiueo.da.ru/

[Requirements (tested on Microsoft Windows XP)]

ruby 1.8.5 (2006-12-04 patchlevel 2) [i386-mswin32]

mongrel-0.3.13.3-mswin32
sqlite3-ruby-1.1.0-mswin32
hatenaapiauth-0.1.0
uuidtools-1.0.0
scrapi-1.2.0

... and other dependent Ruby gems

=end

require 'rubygems'

require 'mongrel'
require 'yaml'
require 'sync'
require 'fileutils'
require 'pathname'
require 'sqlite3'
require 'hatena/api/auth'
require 'uuidtools'
require 'scrapi'



YAMLの設定ファイルをロードし、SQLiteデータベースのテーブルを無ければ新規作成する。



conf_filename = $PROGRAM_NAME.clone
begin
$conf = YAML.load_file conf_filename.sub!(/\.rb/, '.conf')

pn = Pathname.new($conf["file_store_path"])
begin
$conf["file_store_path"] = pn.realpath
rescue SystemCallError
Dir.mkdir(pn.to_s, 0701)
$conf["file_store_path"] = pn.realpath
end

$db = SQLite3::Database.new($conf["db_filename"])

rescue Exception => e
STDERR.puts e.to_s
exit(1)
end

# ofn = original file name, rfn = real file name

begin
$db.execute(<<SQL
create table files(
sender TEXT,
receiver TEXT,
ofn TEXT,
rfn TEXT,
date INTEGER,
size INTEGER
);
SQL
)
rescue SQLite3::SQLException => e
if e.to_s != "table files already exists"
puts e.to_s
exit(1)
end
end

$db.extend(Sync_m)



Webサーバのモジュールなので、データベースアクセスのためのグローバルオブジェクト$dbはSync_mを使用してマルチスレッド対応にしておく。アップロードされてくるファイルのファイルサイズと、受け取り済みのデータのサイズとを保存するために、Structクラスを使ってDownloadProgressという構造体を作っておく。さらに、$download_progressというHashのオブジェクトを生成し、このオブジェクトがQUERY_STRINGリクエストパラメータとDownloadProgressオブジェクトとの対応表を保存する。$download_progressは、デザインパターンで言うところのObserverパターンで、ダウンロード状況のview(MVCの'V')としてWebからの複数リクエストによって同時に参照される可能性があるため、同じようにマルチスレッド対応にしなければならない。



$verified_users_ipaddress = Hash.new # ip - user
$verified_users_ipaddress.extend(Sync_m)

$existent_users = Hash.new # user - dummy
$existent_users.extend(Sync_m)

DownloadProgress = Struct.new("DownloadProgress", :total_size, :current_size)

$download_progress = Hash.new
$download_progress.extend(Sync_m) # user - DP



「はてな」の認証APIの初期化関数を呼び出し、次いで表示するページ内のヘッダをそのままヒアドキュメントを使ってスクリプト内に書いている。



$hatena_auth = Hatena::API::Auth.new(:api_key => $conf["api_key"], :secret => $conf["private_key"])

$page_head =<<EOS
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>#{$PROGRAM_NAME}</title>
</head>
<body>
<p>Proof of Concept: File Transfer between Hatena Users</p>
EOS

$page_end =<<EOS
</body>
</html>
EOS

def page_head_with_onload(onload)
<<EOS
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>#{$PROGRAM_NAME}</title>
</head>
<body onload="#{onload}">
<p>Proof of Concept: File Transfer between Hatena Users</p>
EOS

end



以下に、Mongrelのハンドラ関数群が続く。各種デフォルトハンドラをオーバーライドすることにより、WebアプリケーションはMongrelの動作をカスタマイズするというのがMongrelモジュールの基本コンセプトである。まずは、ユーザが最初にサーバのルートパス('/')にアクセスしたときに、「はてな」のIDとパスワードを認証APIに対して差し出すように促す。processというメソッドが ユーザ定義フィルタの役割を果たし、requestを受けてresponseを返す。



# Mongrel handlers #########################################

class RootHandler < Mongrel::HttpHandler
def process(request, response)
response.start do |head, out|
head["Content-Type"] = "text/html"

out << <<EOS
#{$page_head}

<p>If you are a Hatena user and want to transfer a file to another user,
<a href=\"#{$hatena_auth.uri_to_login}\">please follow this link</a> to the uploader.</p>

#{$page_end}
EOS
end
end
end



このスクリプトの一番最後にMongrel起動時の初期設定を行う部分があるので、そこを見てもらうとして、このUploaderHandlerは、"/uploader" というパスにアクセスした場合のハンドラである。リクエスト中のクエリ文字列(request.params["QUERY_STRING"])を解析し、認証情報 を取り出して「はてな」認証APIに与える。「はてな」ユーザとして認証されると、$verified_users_ipaddressハッシュ表にリモートIPアドレスとユーザ名の組が保存される。ちなみに、このアプリケーションはIPアドレス1つあたり1ユーザとして認識しており、まともなセッション管理を行っていないので、実際に使用するには厳密なセッション管理が必要である。



class UploaderHandler < Mongrel::HttpHandler
def show_error(response, message)
response.start do |head, out|
head["Content-Type"] = "text/html"
out << message
end
end

def process(request, response)
unknown_error_page =<<EOS
#{$page_head}

<p>Error: Unknown Error</p>
<script language="JavaScript">
<!--
history.go(-1)
//-->
</script>

#{$page_end}
EOS

auth_error_page =<<EOS
#{$page_head}

<p>Error: Authorization Failed</p>

#{$page_end}
EOS

cert_key = ""
if request.params["QUERY_STRING"] =~ /cert=([^&amp;]+)/
cert_key = $1
else
show_error(response, unknown_error_page)
return
end

user = nil
begin
user = $hatena_auth.login(cert_key)
rescue Hatena::API::AuthError
show_error(response, auth_error_page)
return
end

$verified_users_ipaddress.synchronize() do
if $verified_users_ipaddress.size > 10000
$verified_users_ipaddress.clear
end
$verified_users_ipaddress[request.params[Mongrel::Const::REMOTE_ADDR]] = user['name']
end



このページのヘッダはJavaScriptを含んでいて、AJAXの簡単なフレームワークと、「はてな」ユーザ名の実在性を確かめるメソッド、ファイルのアップロード状態をポーリングしながら進捗バーを動的に表示するメソッドなどを含む。AJAXによる画面遷移無しのファイルアップロードを実現するために、ファイルのアップロード先を隠しiframeにするというテクニックが使用されている。


response.start do |head, out|
head["Content-Type"] = "text/html"

results =<<EOS
#{page_head_with_onload("setup('#{user['name']}')")}
<script language="JavaScript">
<!--

var isMozilla = navigator.userAgent.indexOf('Gecko') != -1;
var isIE = window.ActiveXObject;

function createHttpRequest()
{
if (isIE)
{
try
{ // CLSID_XMLHTTP
// v 3.0
return new ActiveXObject("Msxml2.XMLHTTP");
}
catch (e)
{
try
{// v 2.x
return new ActiveXObject("Microsoft.XMLHTTP");
}
catch (e2)
{
return null;
}
}
}
else if (window.XMLHttpRequest) // non-IE
{
var hr = new XMLHttpRequest();
if (isMozilla)
hr.overrideMimeType('text/xml');
return hr;
}
else
{
return null;
}
}

function sendHTTP(data, method, uri, callback, async, caller)
{
var hr = createHttpRequest();

var args = new Array();
args.push(hr);
for (var i = 6; i < arguments.length; ++i)
{
args.push(arguments[i]);
}

try
{
hr.open(method, uri, async);
hr.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");

hr.onreadystatechange = function()
{
if (hr.readyState == 4)
{
callback.apply(caller, args);
}
}

hr.send(data);
delete hr;
}
catch(e)
{
alert("sendHTTP: " + e);
}
}

function setup(owner_name)
{
loadFileList(owner_name);
checkUsername();
}

var filename = "";
var query_progress = "";
var time_start = 0;

function startPolling(form)
{
if (form.filename.value == "")
{
alert("Invalid file name");
return;
}

if (form.receiver.value == "")
{
alert("Invalid user name");
return;
}

var x = document.getElementById("hidden_div");
if (x)
document.body.removeChild(x);

var d = document.createElement('div');
d.setAttribute("id", "hidden_div");
d.innerHTML='<iframe id="hidden_iframe" name="hidden_iframe" style="display: none; width: 0px; height: 0px; border: 0px"></iframe>';
document.body.appendChild(d);

form.button_upload.disabled = true;
filename = form.filename.value;

document.getElementById("form_area").innerHTML = ("<p>Uploading <b>" + filename + "</b></p>");

query_progress = (form.sender.value + '&amp;' + form.receiver.value + '&amp;' + filename);

form.action = ("/receiver?" + query_progress);

var d = new Date();
time_start = d.getTime();

form.submit();

pollDownloadProgress();
}



pollDownloadProgress関数を1秒毎にタイマー呼び出しして/query_progressへAJAX問い合わせを行い、 ダウンロード済みのファイルサイズを更新しつつ進捗バーを伸ばす。



function pollDownloadProgress()
{
sendHTTP('', 'POST', './query_progress' + '?' + query_progress, pollDownloadProgressCallback, true);

if (filename != "")
setTimeout("pollDownloadProgress();", 1000);
}

function pollDownloadProgressCallback(hr)
{
var nodelist = hr.responseXML.getElementsByTagName("f");
if (!nodelist || nodelist.length == 0)
return;

var current_size = 0;
var total_size = 0;

for (var i = 0; i < nodelist.length; ++i)
{
var n = nodelist.item(i);
if (n == null)
return;

var nn = n.firstChild;
if (nn == null)
return;

while (nn != null)
{
// nn.textContent == Mozilla only
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
if (nn != null &amp;&amp; (nn.nodeType == 3 || nn.nodeType == 4))
{
if (nn.nodeValue != "")
{
var matched = nn.nodeValue.match(/(\\d+)\\/(\\d+)/);
if (matched)
{
current_size = parseInt(matched[1], 10);
total_size = parseInt(matched[2], 10);

setProgressBar(current_size, total_size);
}
}
}

nn = nn.nextSibling;
}
}
}

function setProgressBar(received_size, total_size)
{
document.getElementById("d2").style.width = 400 * (received_size / total_size) + "px";

var d = new Date();
var elapsed_time = (d.getTime() - time_start) / 1000;
if (elapsed_time <= 0)
elapsed_time = 1;

var speed = parseInt(received_size / elapsed_time / 1024, 10);

if (400 * (received_size / total_size) >= 75)
document.getElementById("d1").innerHTML
= "<font size=-1>" + parseInt(received_size / total_size * 100) + "% (" + speed.toString() + "KB/s)</font>";
}



"/check_username"というパスに、あるユーザ名が実在の「はてな」ユーザかどうか確かめるサービス(後述のCheckUsernameHandler)が動いているので、そこに対してAJAXで問い合わせを行う。



var checked_user = "";
var check_username_requesting = false;

// onchange doesn't work until the focus is out
function checkUsername()
{
if (document.getElementById("receiver").value != ""
&amp;&amp; document.getElementById("receiver").value != checked_user &amp;&amp; !check_username_requesting)
{
check_username_requesting = true;
checked_user = document.getElementById("receiver").value;
document.getElementById("form_area").innerHTML = ("<p><blink>Checking if <b"
+ checked_user + "</b> is an existing Hatena user...</blink></p>");
sendHTTP(checked_user, 'POST', './check_username', checkUsernameCallback, true);
}

setTimeout(checkUsername, 3000);
}

function isNotUser()
{
document.getElementById("form_area").innerHTML = ("<p><b>" + checked_user + "</b> is not a Hatena user.</p>");
document.getElementById("button_upload").disabled = true;
}

function checkUsernameCallback(hr)
{
// Can't use responseXML.getElementById() -
// For responseXML.getElementById() to return an element with the matching id value, XMLHttpRequest
// implementations must be aware of the underlying schema/DTD that defines an id attribute of type ID.
// Currently browsers are not schema/DTD aware for XMLHttpRequests, although they support well-known DTDs
// like HTML and XHTML for documents.

var nodelist = hr.responseXML.getElementsByTagName("r");
if (!nodelist || nodelist.length == 0)
isNotUser();

check_username_requesting = false;
for (var i = 0; i < nodelist.length; ++i)
{
var n = nodelist.item(i);
if (n == null)
{
isNotUser();
continue;
}

var nn = n.firstChild;
if (nn == null)
{
isNotUser();
continue;
}

while (nn != null)
{
// nn.textContent == Mozilla only
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
if (nn != null &amp;&amp; (nn.nodeType == 3 || nn.nodeType == 4))
{
if (nn.nodeValue != "")
{
document.getElementById("button_upload").disabled = false;
checked_user = nn.nodeValue;
document.getElementById("receiver").value = nn.nodeValue;
document.getElementById("form_area").innerHTML = ("<p><b>" + checked_user + "</b> is an existing Hatena user.</p>");
}
}

nn = nn.nextSibling;
}
}

check_username_requesting = false;
}

function loadFileList(owner_name)
{
sendHTTP('', 'POST', './list_files' + '?' + owner_name, loadFileListCallback, true);
}

function unixtime2localdate(t)
{
var d = new Date;
d.setTime(t * 1000);
return d.toLocaleString();
}

function loadFileListCallback(hr)
{
var out = "";

var nodelist = hr.responseXML.getElementsByTagName("myfile");
if (nodelist)
{
out += "<p>Your files sent to other users:</p>";
for (var i = 0; i < nodelist.length; ++i)
{
var e = nodelist.item(i);
out += "<p><a href='./downloader/";
out += e.getAttribute("rfn");
out += "'><b>";
out += e.getAttribute("ofn");
out += "</b></a> (Size: ";
out += Math.floor(parseInt(e.getAttribute("size"), 10) / 1024).toString();
out += "KB - To: <a target='_blank' href='http://www.hatena.ne.jp/user?userid=";
out += e.getAttribute("r");
out += "'>";
out += e.getAttribute("r");
out += "</a> - Date: ";
out += unixtime2localdate(parseInt(e.getAttribute("d"), 10));
out += ")</p>";
}
if (nodelist.length == 0)
out += "<p>(no files)</p>";
}

nodelist = hr.responseXML.getElementsByTagName("sentfile");
if (nodelist)
{
out += "<p>Files sent to you from other users:</p>";
for (var i = 0; i < nodelist.length; ++i)
{
var e = nodelist.item(i);
out += "<p><a href='./downloader/";
out += e.getAttribute("rfn");
out += "'><b>";
out += e.getAttribute("ofn");
out += "</b></a> (Size: ";
out += Math.floor(parseInt(e.getAttribute("size"), 10) / 1024).toString();
out += "KB - From: <a target='_blank' href='http://www.hatena.ne.jp/user?userid=";
out += e.getAttribute("s");
out += "'>";
out += e.getAttribute("s");
out += "</a> - Date: ";
out += unixtime2localdate(parseInt(e.getAttribute("d"), 10));
out += ")</p>";
}
if (nodelist.length == 0)
out += "<p>(no files)</p>";
}

document.getElementById("files_list").innerHTML = out;
}

//-->
</script>

<p>Welcome <b>#{user['name']}</b> @ Hatena.</p>
<p>You are now authorized to upload a file to transfer to another Hatena user.</p>

<span id="form_area"><p>Fill in the name of the receiving user and upload a file.</p></span>

<p>
<!-- the colon at the end of startPolling() is required. -->
<form method="POST" id="file_form" action="" enctype="multipart/form-data"
target="hidden_iframe" onsubmit="startPolling(this); return false;">
<input type="hidden" name="sender" value="#{user['name']}">
Receiving Hatena User: <input type="text" id="receiver" name="receiver" size="16"><br><br>
File to upload: <input type="file" name="filename" size="80">
<input type="submit" id="button_upload" value="Upload this file" disabled>
</form>
</p>

<p>
<div id="empty" style="background-color: #cccccc; border: 1px solid black; height: 30px; width: 400px; padding: 0px;" align="left"/>
<div id="d2" style="position: relative; top: 0px; left: 0px; background-color: #333333;
height: 30px; width: 0px; padding-top: 5px; padding: 0px;"/>
<div id="d1" style="position: relative; top: 0px; left: 0px; color: #f0ffff; height: 30px; text-align: center; font: bold;
padding: 0px; padding-top: 5px;"/></div></div></div>
</p>

<span id="files_list"></span>

#{$page_end}
EOS
out << results
end
end
end


"/downloader"でアクセスできる、他ユーザが自分宛にアップロードしたファイルを受け取りダウンロードするためのURLのハンドラを定義する。


class DownloaderHandler < Mongrel::DirHandler
def initialize(path, listing_allowed=true, index_html="index.html")
super(path, listing_allowed, index_html)

#Mongrel::DirHandler::add_mime_type(".zip", "application/zip")
#Mongrel::DirHandler::add_mime_type(".rar", "application/x-rar-compressed")
#Mongrel::DirHandler::add_mime_type(".lzh", "application/x-lzh");
#Mongrel::DirHandler::add_mime_type(".xml", "text/xml");
end

def process(request, response)
user = nil
$verified_users_ipaddress.synchronize(Sync_m::SH) do
if $verified_users_ipaddress.include?(request.params[Mongrel::Const::REMOTE_ADDR])
user = $verified_users_ipaddress[request.params[Mongrel::Const::REMOTE_ADDR]]
end
end

unless user
response.reset
response.start do |head, out|
head["Content-Type"] = "text/html"
out << "Authorization Failed - <a href=\"#{$hatena_auth.uri_to_login}\">Verify again</a>"
end
return
end

req_method = request.params[Mongrel::Const::REQUEST_METHOD] || Mongrel::Const::GET
req_path = can_serve request.params[Mongrel::Const::PATH_INFO]

if not req_path
response.reset
response.start(404) do |head, out|
out << "File not found"
end
else
original_filename = ""
real_filename = request.params[Mongrel::Const::PATH_INFO].clone
real_filename.gsub!("/", "");

if real_filename =~ /[^a-zA-Z0-9_-\.]/ || user =~ /[^a-zA-Z0-9_-]/
response.reset
response.start(403) do |head, out|
out << "Invalid Request"
end
return
end

$db.synchronize(Sync_m::SH) do
$db.execute("select * from files where rfn = '#{real_filename}' AND receiver = '#{user}'") do |row|
original_filename = row[2]
end
end

if original_filename == ""
response.reset
response.start(403) do |head, out|
out << "Not Authorized"
end
return
end

response.header["Content-Disposition"] = "filename=\"#{original_filename}\"";
begin
if req_method == Mongrel::Const::HEAD
send_file(req_path, request, response, true)
elsif req_method == Mongrel::Const::GET
send_file(req_path, request, response, false)
else
response.start(403) {|head, out| out.write(Mongrel::ONLY_HEAD_GET) }
end
rescue => details
STDERR.puts "Error sending file #{req_path}: #{details}"
end
end
end
end


"/receiver"のパスが、ユーザがファイルをアップロードする対象である。 request_progressメソッドはリクエストのデータを一定量受け取る度にMongrelが呼び出すイベントコールバックで、これをオーバーライドすることによって、アップロードされてくるファイルのアップロード済みデータ量の数値を逐次更新する。Mongrel::CGIWrapperを 使用してHTMLフォームから送信されてくるデータを解析しているが、使用したMongrelのバージョンではファイルをアップロードしている場合に正常 にそれぞれのクエリ要素を受け取れないというバグがあるので、迂回策として生のデータを正規表現で検索している。ファイルのダウンロードが済むと、Mongrel::CGIWrapperの仕様に従って、アップロードされてきたファイルのサイズに応じ、一時バッファもしくは一時ファイルから、実際のファイル保存先へとデータを移す。


class ReceiverHandler < Mongrel::HttpHandler
def initialize
@request_notify = true
end

def request_progress(params, clen, total)
$download_progress.synchronize() do
if $download_progress.size > 1000
$download_progress.clear
end
$download_progress[params["QUERY_STRING"]] = Struct::DownloadProgress.new(total, total - clen)
end
end

def gen_stored_filename()
return UUID.timestamp_create.to_s
end

def process(request, response)
cgi = Mongrel::CGIWrapper.new(request, response)

# can't use cgi["sender"] and cgi["receiver"] for multipart/form-data due to a possible bug of Mongrel 0.3.13.3

sender = nil
receiver = nil
original_filename = nil

if request.params["QUERY_STRING"] =~ /^([^&amp;]+)&amp;([^&amp;]+)&amp;(.+)/
sender = $1
receiver = $2
original_filename = $3
original_filename.gsub!("'", "")
original_filename.gsub!(/.*\\/, "")
original_filename.gsub!(".*/", "")
end

if sender =~ /[^a-zA-Z0-9_-]/ || receiver =~ /[^a-zA-Z0-9_-]/ || original_filename =~ /[^a-zA-Z0-9_-\.\\]/
response.reset
response.start(403) do |head, out|
out << "Invalid Request"
end
return
end

$verified_users_ipaddress.synchronize(Sync_m::SH) do
if $verified_users_ipaddress.include?(request.params[Mongrel::Const::REMOTE_ADDR])
if sender != $verified_users_ipaddress[request.params[Mongrel::Const::REMOTE_ADDR]]
sender = nil
end
else
sender = nil
end
end

$existent_users.synchronize(Sync_m::SH) do
unless $existent_users.include?(receiver)
receiver = nil
end
end

unless sender &amp;&amp; receiver &amp;&amp; original_filename
response.start do |head, out|
head["Content-Type"] = "text/html"

out << <<EOS
#{$page_head}

<script language="JavaScript">
<!--

parent.document.getElementById("form_area").innerHTML
= ("<p>An authorization error happened in uploading <b>" + parent.filename
+ "</b>. <a href=\\"#{$hatena_auth.uri_to_login}\\">Please retry</a></p>");
parent.filename = "";
parent.setProgressBar(0, 0);

//-->
</script>

#{$page_end}
EOS
end
return
end

$download_progress.synchronize() do
$download_progress.delete(request.params["QUERY_STRING"])
end

file = cgi['filename']

real_filename = gen_stored_filename()

begin
if file.size >= 10240 then
FileUtils.cp(file.path, $conf["file_store_path"] + real_filename)
else
open($conf["file_store_path"] + real_filename, "wb") do |fh|
fh.write(file.read)
end
end

$db.synchronize() do
$db.transaction do |d|
d.execute("insert into files values('#{sender}', '#{receiver}',
'#{original_filename}', '#{real_filename}', #{Time.now.tv_sec}, #{file.size})")
end
end
rescue Exception => e
p e
puts e.backtrace
end

response.start do |head, out|
head["Content-Type"] = "text/html"

out << <<EOS
#{$page_head}

<script language="JavaScript">
<!--

parent.document.getElementById("form_area").innerHTML = ("<p><b>"
+ parent.filename + "</b> has been successfully uploaded.</p>");
parent.filename = "";
parent.setProgressBar(#{file.size}, #{file.size});
parent.loadFileList('#{sender}');
parent.document.getElementById("button_upload").disabled = false;

//-->
</script>

#{$page_end}
EOS
end
end
end



scrapiのWindows版で1モジュールのdllの読み込みに不具合があるので、問題になるメソッドfind_tidyをここで上書き修正している。この辺は動的言語の面目躍如と言うべきだが、濫用すると収拾が付かなくなるので個人的にはやむをえない場合以外やるべきではないと思っている。



# Redefine the find_tidy method in scrapi for Windows to load the correct dll first
module Scraper
module Reader
module_function

def find_tidy()
return if Tidy.path

begin
$LOAD_PATH.each do |path|
if path =~ /scrapi/ &amp;&amp; path =~ /lib$/
if Config::CONFIG['arch'] =~ /mswin/
Tidy.path = File.join(path, "/tidy", "libtidy.dll")
else
Tidy.path = File.join(path, "/tidy", "libtidy.so")
end
break
end
end
rescue LoadError => e
puts e.to_s
end
end
end
end



あるユーザ名が「はてな」の実在のユーザかどうか確かめるためのwebサービスAPIを「はてな」では提供していないので、「はてな」上に該当ユーザのメンバーページが存在するかどうか、scrapiによるスクレイピングを行って強引に確かめることで代用する。



class CheckUsernameHandler < Mongrel::HttpHandler

@@scraper = Scraper.define do
process "td[align='center'] a[href='/q']", :ret => :text
result :ret
end

def process(request, response)
verified = false
$verified_users_ipaddress.synchronize(Sync_m::SH) do
if $verified_users_ipaddress.include?(request.params[Mongrel::Const::REMOTE_ADDR])
verified = true
end
end

unless verified
response.start(403) do |head, out|
out.write("Not Authorized")
end
return
end

found = false
$existent_users.synchronize(Sync_m::SH) do
found = $existent_users.include?(request.body.string)
end

unless found
uri = "http://www.hatena.ne.jp/user?userid="
uri += request.body.string # StringIO

found = (@@scraper.scrape(URI.parse(uri)) == nil)

$existent_users.synchronize() do
if $existent_users.size > 1000
$existent_users.clear()
end
$existent_users[request.body.string] = 1
end
end

response.start do |head, out|
head["Content-Type"] = "text/xml"
out.write(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><r>#{found ? request.body.string : ""}</r>")
end
end
end



自分がアップロード中のファイルの進捗状況を示すXMLを返すハンドラを定義する。


class QueryProgressHandler < Mongrel::HttpHandler
def process(request, response)
$verified_users_ipaddress.synchronize(Sync_m::SH) do
unless $verified_users_ipaddress.include?(request.params[Mongrel::Const::REMOTE_ADDR])
response.start(403) do |head, out|
out.write("Not Authorized")
end
return
end
end

found = false
$download_progress.synchronize(Sync_m::SH) do
if $download_progress.include?(request.params["QUERY_STRING"])
found = $download_progress[request.params["QUERY_STRING"]]
end
end

response.start do |head, out|
head["Content-Type"] = "text/xml"
if found
out.write(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><f>#{found.current_size}/#{found.total_size}</f>")
else
out.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><f></f>")
end
end
end
end


自分宛に他ユーザがアップロードしたファイルの一覧を表示するハンドラを定義する。


class ListFilesHandler < Mongrel::HttpHandler
def process(request, response)
$verified_users_ipaddress.synchronize(Sync_m::SH) do
if $verified_users_ipaddress.include?(request.params[Mongrel::Const::REMOTE_ADDR])
if $verified_users_ipaddress[request.params[Mongrel::Const::REMOTE_ADDR]] != request.params["QUERY_STRING"]
response.start(403) do |head, out|
out.write("Not Authorized")
end
return
end
else
response.start(403) do |head, out|
out.write("Not Authorized")
end
return
end
end

if request.params["QUERY_STRING"] =~ /[^a-zA-Z0-9_-\.\\]/
response.reset
response.start(403) do |head, out|
out << "Invalid Request"
end
return
end

xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><files>'

$db.synchronize(Sync_m::SH) do
$db.execute("select * from files where sender = '#{request.params["QUERY_STRING"]}'") do |row|
xml += "<myfile r=\"#{row[1]}\" ofn=\"#{row[2]}\" rfn=\"#{row[3]}\" d=\"#{row[4]}\" size=\"#{row[5]}\"/>"
end
$db.execute("select * from files where receiver = '#{request.params["QUERY_STRING"]}'") do |row|
xml += "<sentfile s=\"#{row[0]}\" ofn=\"#{row[2]}\" rfn=\"#{row[3]}\" d=\"#{row[4]}\" size=\"#{row[5]}\"/>"
end
end

xml += "</files>"

response.start do |head, out|
head["Content-Type"] = "text/xml"
out.write(xml)
end
end
end



Mongrelの起動設定と起動、終了処理。どのパス(URI)がどのハンドラクラスによって定義されているか、ここで指定する。



#stats = Mongrel::StatisticsFilter.new(:sample_rate => 1)

# new(defaults={}, &amp;blk)
# You pass in initial defaults and then a block to continue configuring.
config = Mongrel::Configurator.new :host => $conf["bound_address"], :port => $conf["bound_port"] do
listener do
uri "/", :handler => RootHandler.new
#uri "/", :handler => Mongrel::DeflateFilter.new # This messes up IE
#uri "/", :handler => stats

uri "/uploader", :handler => UploaderHandler.new

uri "/downloader", :handler => DownloaderHandler.new($conf["file_store_path"], false)

uri "/receiver", :handler => ReceiverHandler.new

uri "/check_username", :handler => CheckUsernameHandler.new

uri "/query_progress", :handler => QueryProgressHandler.new

uri "/list_files", :handler => ListFilesHandler.new

#uri "/status", :handler => Mongrel::StatusHandler.new(:stats_filter => stats)
end

trap("INT") { stop }
run
end

puts "Mongrel running on #{$conf["bound_address"]}:#{$conf["bound_port"]}"

config.join



hatenawebapp1.rbは以上である。 スクリプトを動作させると、Mongrelが設定ファイル内のポートで起動するので、Webブラウザでアクセスすると、「はてな」認証を促すリンクが表示される。それをクリックし、認証を通過すると、コールバックURLのhttp://127.0.0.1/uploaderに転送され、そこでファイルのアップロードが可能となる。自分の送信済みファイルと、自分宛に他ユーザが送信したファイルのリストもそこに表示されている。ファイルをアップロードする と、AJAXを利用して画面遷移無しで進捗表示とアップロード完了後のリスト更新が行われる。尚、ごく稀に特定のファイルでアップロードが失敗することがあるようだが、MongrelのCGIWrapperのバグに起因する問題でありMongrel側の修正を待つしかない。

つぎに、Perl webアプリの方を見ていくことにする。まずは、設定ファイルのhatenawebapp2.confである。Rubyアプリの方と同様に、YAML形式を用いてサーバのポートなどを設定している。


# hatenawebapp2.conf
#
# configuration file for hatenawebapp2.pl

# Bound port
bound_port: 80

# JavaScript directory name (not path)
javascript_directory: jsdir

# Mask for a hidden text in a quiz question
quiz_mask: "<font color=red>******</font>"


スクリプト本体はhatenawebapp2.plである。perl, v5.8.8 built for MSWin32-x86-multi-threadで動作を確認している。

必要ライブラリは、Perl 5.8の他に、
YAML::Syck
POE::Component::Server::HTTP
HTTP::Status
XML::RSS
XMLRPC::Lite
LWP::Simple
MeCab
URI::Escape
threads::shared
Thread::Semaphore

のそれぞれCPANシェルを使って入手できる最新バージョンと、各々が依存するライブラリである。オープンソース形態素解析エンジンMeCabは、ナマズのブログで入手可能な0.92のWindows用バイナリと辞書を使用させていただいた。尚、Windows下ではMeCabがShiftJISでビルドされている ため辞書もShiftJIS版を使用し、スクリプト内で必要な変換を行ったが、他プラットフォームでテストする場合はMeCab、辞書ともUTF-8版が必要である。また、JavaScriptのグラフ視覚化ライブラリであるJSVizと、ツールチップライブラリboxoverを利用しており、これらはスクリプト下にjsdirという名称のディレクトリを作ってその中へ全て展開する必要がある。



=pod

hatenawebapp2.pl

Sample Web Application 2 with Hatena Web Service API : Hatena Keyword Quiz &amp; Visualization

by RyuK (klassphere[at.mark]gmail.com)
http://zzz.zggg.com/
http://aiueo.da.ru/

[Requirements (tested on Microsoft Windows XP)]

Mecab is compiled with ShiftJIS with the ShiftJIS dictionary.
For platforms other than Windows, use UTF-8 for Mecab and its dic.

=cut

use 5.8.0;

use strict;
use warnings;

use utf8;
use Encode;

use YAML::Syck;
use POE::Component::Server::HTTP;
use HTTP::Status;
use XML::RSS;
use XMLRPC::Lite;
use LWP::Simple;
use MeCab;
use URI::Escape;

$YAML::Syck::ImplicitTyping = 1;

my %quiz_answer = (); # IP address - answer

my $conf = YAML::Syck::LoadFile("hatenawebapp2.conf");



ここでは、日本語UTF-8文字列を使うときにUTF-8に対応していないXMLRPC::LiteSOAP::Lite内で問題がある箇所の関数を動的に上書き修正している。



# Patches some functions in XMLRPC::Lite and SOAP::Lite to pass a UTF-8 Japanese string
# in its HTTP transport that is a subclass of LWP::UserAgent
$SOAP::Constants::DO_NOT_USE_LWP_LENGTH_HACK = 1;

{
my $s =<<'SUBDOC';
package XMLRPC::Serializer;

sub new
{
my $self = shift;

unless (ref $self)
{
my $class = ref($self) || $self;
$self = $class->SUPER::new(
typelookup =>
{
base64 => [10, sub {1}, 'as_string'],
int => [20, sub {$_[0] =~ /^[+-]?\d+$/}, 'as_int'],
double => [30, sub {$_[0] =~ /^(-?(?:\d+(?:\.\d*)?|\.\d+)|([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?)$/}, 'as_double'],
dateTime => [35, sub {$_[0] =~ /^\d{8}T\d\d:\d\d:\d\d$/}, 'as_dateTime'],
string => [40, sub {1}, 'as_string'],
},
attr => {},
namespaces => {},
@_,
);
}

return $self;
}

1;

package SOAP::Utils;

sub bytelength
{
return length($_[0]);
}

1;

SUBDOC

no warnings;
local $^W = 0;
eval "$s";
use warnings;
}



Webサーバの設定を行うとともに、Webサーバ上の各パス毎にハンドラ関数を登録している。



my $aliases = POE::Component::Server::HTTP->new(
Port => $conf->{bound_port},
ContentHandler =>
{
'/' => \&amp;handlerRoot,
'/viz' => \&amp;handlerViz,
'/quiz' => \&amp;handlerQuiz,
'/answer' => \&amp;handlerAnswer,
'/assoc' => \&amp;handlerAssoc,
'/search' => \&amp;handlerSearch
},
Headers => { Server => 'My Server' },
);



これは穴埋めクイズの問題を作る関数で、要は、「はてなキーワード」内の日本語文に対しMeCabで形態素解析を行って、見つかった名詞の部分を隠すことによって穴埋め問題にするという至極単純な仕組みである。



sub makeQuiz
{
my $s = shift;
my $ip = shift;

my $sjis = ($^O =~ /mswin/i);
if ($sjis)
{
utf8::encode($s);
Encode::from_to($s, 'utf8', 'shiftjis');
}

my $m = new MeCab::Tagger("");
my $n = $m->parseToNode($s);

my @fragments = ();

my $count = 0;
while ($n = $n->{next})
{
if (defined($n->{surface}))
{
my $w = $n->{surface};
my $f = $n->{feature};

if ($sjis)
{
Encode::from_to($f, 'shiftjis', 'utf8');
utf8::decode($f);

Encode::from_to($w, 'shiftjis', 'utf8');
utf8::decode($w);
}

if ($f =~ /^名詞/ &amp;&amp; length($w) >= 2)
{
push @fragments, [$w, 1];
++$count;
}
else
{
push @fragments, [$w, 0];
}
}
}

if ($count == 0)
{
return "";
}

my $masked_index = int(rand $count);

my $final = "";
my $current_noun_index = 0;
foreach my $f (@fragments)
{
if ($f->[1] == 0)
{
$final .= $f->[0];
}
elsif ($current_noun_index++ == $masked_index)
{
$final .= $conf->{quiz_mask};

if (keys(%quiz_answer) > 1000)
{
%quiz_answer = ();
}

$quiz_answer{$ip} = $f->[0];
}
else
{
$final .= $f->[0];
}
}

return $final;
}


webサーバのルートURLのハンドラ。ユーザが任意の単語を入力すると「はてなキーワード」を検索し、キーワード間の連想グラフをロードする。


sub handlerRoot
{
my ($request, $response) = @_;
$response->code(RC_OK);

my $out = "";
my $jsdir = $conf->{javascript_directory};

if ($request->uri =~ /$jsdir\/([^\/]+)$/)
{
my $f = $1;
if (!open(FILE, "./$jsdir/$f"))
{
return RC_DENY;
}

while (<FILE>)
{
$out .= $_;
}

$response->content($out);
$response->header('Content-type' => "application/x-javascript");

close(FILE);

return RC_OK;
}

$out =<<'HEREDOC';
<html>
<head>
<title>Hatena web app 2</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

<script language="JavaScript">
<!--

function setup()
{

}

function resizeIframe(f)
{
if (document.body.clientWidth)
{
f.style.width = document.body.clientWidth + "px";
f.style.height = (document.body.clientHeight - 100) + "px";
}
}

function resizeIframe2(f)
{
if (document.body.clientWidth)
{
f.style.width = document.body.clientWidth + "px";
}
}

//-->

</script>

<style type="text/css">

body
{
margin: 0;
padding: 0;
overflow: hidden;
}

p
{
text-decoration: none;
font-size: 13px;
font-weight: normal;
font-family: Verdana, Geneva, san-serif;
line-height: 150%;
margin-top: 0px;
margin-bottom: 1em;
padding-left: 8px;
padding-right: 8px;
}

form
{
margin: 0;
padding: 0;
}

</style>
</head>
<body onload="setup();" onresize="resizeIframe(document.getElementById('ifr'));resizeIframe2(document.getElementById('quiz'));">
<br>
<p>Proof of Concept: Hatena Keyword Quiz &amp; Visualization</p>

<form onsubmit="frames.ifr.hatenaKeywords.getKeywords(this.word.value); return false;">
<p>はてなキーワード内から検索したい単語を <input type="text" id="word" size="40"> に入力し
<input type="submit" value="検索"> して、見つかった単語をクリックして下さい。
</p>
</form>
<p>(キーワードのノードはマウスでドラッグ可能です)</p>
<iframe src="/quiz" id="quiz" name="quiz" width="1000" height="160" scrolling="yes" valign="top"
onload="resizeIframe2(this);"></iframe>
<iframe src="/viz" id="ifr" name="ifr" width="1000" height="600" scrolling="yes" valign="top"
onload="resizeIframe(this);"></iframe>

</body>
</html>

HEREDOC

$response->content($out);

return RC_OK;
}



GETリクエストで呼び出されると必要なJavaScriptを表示し、POSTリクエストの場合は入力された単語を「はてなキーワード」で検索した後、キーワードのRSSデータから説明文を抜き出してクイズを作成表示する。



sub handlerQuiz
{
my ($request, $response) = @_;
$response->code(RC_OK);

my $out = "";

if ($request->method =~ /get/i)
{
$out =<<'HEREDOC';
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

<script language="JavaScript">
<!--

var isIE = window.ActiveXObject;
var isMozilla = navigator.userAgent.indexOf('Gecko') != -1;

function createHttpRequest()
{
if (isIE)
{
try
{ // CLSID_XMLHTTP
// v 3.0
return new ActiveXObject("Msxml2.XMLHTTP");
}
catch (e)
{
try
{// v 2.x
return new ActiveXObject("Microsoft.XMLHTTP");
}
catch (e2)
{
return null;
}
}
}
else if (window.XMLHttpRequest) // non-IE
{
var hr = new XMLHttpRequest();
if (isMozilla)
hr.overrideMimeType('text/xml');

return hr;
}
else
{
return null;
}
}

function sendHTTP(data, method, uri, callback, async, caller)
{
var hr = createHttpRequest();

var args = new Array();
args.push(hr);
for (var i = 6; i < arguments.length; ++i)
{
args.push(arguments[i]);
}

try
{
hr.open(method, uri, async);
hr.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");

hr.onreadystatechange = function()
{
if (hr.readyState == 4)
{
callback.apply(caller, args);
}
}

hr.send(data);
delete hr;
}
catch(e)
{
alert("sendHTTP: " + e);
}
}

function getTextContent(xml)
{
if (xml)
{
if (xml.textContent)
return xml.textContent; // Mozilla
// IE
if (xml.innerText)
return xml.innerText;
if (xml.text)
return xml.text;
}
}

function setup()
{

}

function checkAnswer(answer)
{
sendHTTP(answer, "POST", "/answer", checkAnswerCallback, true, this, answer);
}

function checkAnswerCallback(request)
{
var nodelist = request.responseXML.getElementsByTagName("a");
if (nodelist &amp;&amp; nodelist.length != 0)
{
for (var i = 0; i < nodelist.length &amp;&amp; i < 10; ++i)
{
var e = nodelist.item(i);
if (e.getAttribute("r") == "true")
{
alert("正解");
}
else
{
var answer = "";
var n = e.firstChild;
while (n != null)
{
// n.textContent == Mozilla only
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
if (n != null &amp;&amp; (n.nodeType == 3 || n.nodeType == 4))
{
answer = n.nodeValue;
}

n = n.nextSibling;
}

alert("不正解 - 解答: " + answer);
}
}
}
}

//-->

</script>

<style type="text/css">

body
{
margin: 0;
padding: 0;
overflow: hidden;
}

p
{
text-decoration: none;
font-size: 13px;
font-weight: normal;
font-family: Verdana, Geneva, san-serif;
line-height: 150%;
margin-top: 0px;
margin-bottom: 1em;
padding-left: 8px;
padding-right: 8px;
}

form
{
margin: 0;
padding: 0;
}

</style>
</head>
<body onload="setup();">
<span id="quiz"></span>
</body>
</html>

HEREDOC

}
else
{
$response->header('Content-Type' => 'text/xml');
$out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n";

my $w = $request->content;
utf8::decode($w);
$w =~ tr/A-Za-z0-9/A-Za-z0-9/;
$w = URI::Escape::uri_escape_utf8($w);

my $content = get("http://d.hatena.ne.jp/keyword?word=$w&amp;mode=rss&amp;ie=utf8");
if ($content)
{
my $rss = new XML::RSS;

utf8::decode($content);

$rss->parse($content);

my $item = ${$rss->{items}}[0];

for my $item (@{$rss->{items}})
{
$out .= "<i a='";
$out .= $item->{link};
$out .= "'>";

if (defined($item->{title}))
{
$out .= "<t><![CDATA[";

my $t = $item->{title};
utf8::decode($t);
$t =~ s/]]>/]]>/g;
$out .= $t;

$out .= "]]></t>\n";
}

if (defined($item->{description}))
{
$out .= "<d><![CDATA[";

my $d = $item->{description};
utf8::decode($d);
$d =~ s/]]>/]]>/g;
$d =~ s|<a[^>]+>||g;

$out .= makeQuiz($d, $request->{connection}->{remote_ip});

$out .= "]]></d>";
}

$out .= "</i>\n";
}
}

$out .= "</r>";
}

$response->content($out);

return RC_OK;
}



JSVizによって「はてなキーワード」の連想グラフを視覚化した物を表示する画面のハンドラ。



sub handlerViz
{
my ($request, $response) = @_;
$response->code(RC_OK);

my $out = "";
my $jsdir = $conf->{javascript_directory};

if ($request->uri =~ /$jsdir\/([^\/]+)$/)
{
my $f = $1;
if (!open(FILE, "./$jsdir/$f"))
{
return RC_DENY;
}

while (<FILE>)
{
$out .= $_;
}

$response->content($out);
$response->header('Content-type' => "application/x-javascript");

close(FILE);

return RC_OK;
}

$out =<<'HEREDOC';
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

<script language="JavaScript" src="/jsdir/DataGraph.js"></script>
<script language="JavaScript" src="/jsdir/Magnet.js"></script>
<script language="JavaScript" src="/jsdir/Spring.js"></script>
<script language="JavaScript" src="/jsdir/Particle.js"></script>
<script language="JavaScript" src="/jsdir/ParticleModel.js"></script>
<script language="JavaScript" src="/jsdir/Timer.js"></script>
<script language="JavaScript" src="/jsdir/EventHandler.js"></script>
<script language="JavaScript" src="/jsdir/HTMLGraphView.js"></script>
<script language="JavaScript" src="/jsdir/SVGGraphView.js"></script>
<script language="JavaScript" src="/jsdir/RungeKuttaIntegrator.js"></script>
<script language="JavaScript" src="/jsdir/Control.js"></script>
<script language="JavaScript" src="/jsdir/boxover.js"></script>

<script language="JavaScript">
<!--

var isIE = window.ActiveXObject;
var isMozilla = navigator.userAgent.indexOf('Gecko') != -1;

// Suppress IE6 SP1 flicker
if (isIE)
{
try
{
document.execCommand("BackgroundImageCache", false, true);
}
catch (e)
{

}
}

function createHttpRequest()
{
if (isIE)
{
try
{ // CLSID_XMLHTTP
// v 3.0
return new ActiveXObject("Msxml2.XMLHTTP");
}
catch (e)
{
try
{// v 2.x
return new ActiveXObject("Microsoft.XMLHTTP");
}
catch (e2)
{
return null;
}
}
}
else if (window.XMLHttpRequest) // non-IE
{
var hr = new XMLHttpRequest();
if (isMozilla)
hr.overrideMimeType('text/xml');

return hr;
}
else
{
return null;
}
}

function sendHTTP(data, method, uri, callback, async, caller)
{
var hr = createHttpRequest();

var args = new Array();
args.push(hr);
for (var i = 6; i < arguments.length; ++i)
{
args.push(arguments[i]);
}

try
{
hr.open(method, uri, async);
hr.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");

hr.onreadystatechange = function()
{
if (hr.readyState == 4)
{
callback.apply(caller, args);
}
}

hr.send(data);
delete hr;
}
catch(e)
{
alert("sendHTTP: " + e);
}
}

function getTextContent(xml)
{
if (xml)
{
if (xml.textContent)
return xml.textContent; // Mozilla
// IE
if (xml.innerText)
return xml.innerText;
if (xml.text)
return xml.text;
}
}

var HatenaKeywords = function(dataGraph, particleModel)
{
this.init(dataGraph, particleModel);
}

HatenaKeywords.prototype =
{
init: function(dataGraph, particleModel)
{
this.dataGraph = dataGraph;
this.particleModel = particleModel;

this.TRAVERSE_DEPTH = 1;
this.MAX_PRODUCTS_ORIGIN = 8;
this.MAX_PRODUCTS_PER_SIMILARITY = 8;
this.MAX_NODES = 20;

this.nodesByName = {};
this.nodesCount = 0;
},

search: function(keyword)
{
document.getElementById('searchResults').innerHTML = "";
document.getElementById('keywordResults').style.display = "none";

parent.document.getElementById('word').value = keyword;

this.particleModel.clear();
this.nodesByName = {};
this.nodesCount = 0;

if (this.particleModel.timer.interupt)
this.particleModel.timer.start();

var node = new DataGraphNode(true, 2);
node.keyword = keyword;

this.dataGraph.addNode(node);
this.nodesByName[keyword] = node;
this.getSimilarKeywords(keyword, 0);

sendHTTP(keyword, "POST", "/quiz", this.getQuizCallback, true, this, keyword);
},

getQuizCallback : function(request)
{
var nodelist = request.responseXML.getElementsByTagName("i");
if (nodelist &amp;&amp; nodelist.length != 0)
{
for (var i = 0; i < nodelist.length &amp;&amp; i < 10; ++i)
{
var e = nodelist.item(i);
var keyword = getTextContent(e.getElementsByTagName("t")[0]);
if (!keyword)
keyword = "";

var desc = getTextContent(e.getElementsByTagName("d")[0]);
if (!desc)
desc = "";

parent.frames.quiz.document.getElementById('quiz').innerHTML
= "<p>クイズ: 隠された単語は何でしょう?</p><p>"
+ desc
+ "</p><form onsubmit=\"checkAnswer(this.answer.value);return false;\"><p>"
+ "あなたの答え: <input id=\"answer\" type=\"text\" size=\"20\"><input type=\"submit\""
+ " value=\"解答をチェック\"></p></form>";
}
}
},

getKeywords : function(keyword)
{
document.getElementById('searchResults').innerHTML = "<p><blink>Searching...</blink></p>";

this.particleModel.clear();
this.nodesByName = {};
this.nodesCount = 0;

if (this.particleModel.timer.interupt)
this.particleModel.timer.start();

sendHTTP(keyword, "POST", "/search", this.getKeywordsCallback, true, this, keyword);
},

getKeywordsCallback : function(request)
{
document.getElementById('searchResults').innerHTML = "";

var nodelist = request.responseXML.getElementsByTagName("i");
if (nodelist &amp;&amp; nodelist.length != 0)
{
var h = document.createElement('p');
h.innerHTML = (nodelist.length.toString() + "個のキーワードが見つかりました。クリックすると関係する単語群を探せます。");
document.getElementById('searchResults').appendChild(h);

for (var i = 0; i < nodelist.length &amp;&amp; i < 10; ++i)
{
var e = nodelist.item(i);
var keyword = getTextContent(e.getElementsByTagName("t")[0]);
if (!keyword)
keyword = "";

var desc = getTextContent(e.getElementsByTagName("d")[0]);
if (!desc)
desc = "";

var r = document.createElement('div');
r.className = "keyword";
var title = ("fade=[on] header=[" + keyword.replace(/[/g, "").replace(/]/g, "")
+ "] body=[" + desc.replace(/[/g, "").replace(/]/g, "") + "]");
r.setAttribute("title", title);
r.setAttribute("style", "padding-left: 50px;");
r.innerHTML = '<p onclick="' +
"hatenaKeywords.search('" + keyword.replace(/"/g, '"') + "')" + '">'
+ "<b><a onmouseover=\"this.style.textDecoration = 'underline'\" onmouseout=\"this.style.textDecoration = 'none'\">"
+ keyword + '</b></a></p>';

document.getElementById('searchResults').appendChild(r);

}
}
},

getSimilarKeywords: function(word, ordinal)
{
sendHTTP(word, "POST", "/assoc", this.getSimilarKeywordsCallback, true, this, word, ordinal);
},

getSimilarKeywordsCallback: function(request, parentWord, ordinal)
{
var max = this.MAX_PRODUCTS_PER_SIMILARITY;
if (ordinal == 0)
max = this.MAX_PRODUCTS_ORIGIN;

var nodelist = request.responseXML.getElementsByTagName("related");
for (var i = 0; i < nodelist.length &amp;&amp; i < max &amp;&amp; this.nodesCount < this.MAX_NODES; i++)
{
var word = nodelist[i].getAttribute("w");

if (this.nodesByName[word])
{
var node = this.nodesByName[word];
this.dataGraph.addEdge(node, this.nodesByName[parentWord]);
}
else
{
var node = new DataGraphNode(false, 1);
node.keyword = word;

node.addEdge(this.nodesByName[parentWord], 1);
this.dataGraph.addNode(node);
this.nodesCount++;

this.nodesByName[word] = node;

if (ordinal < this.TRAVERSE_DEPTH)
this.getSimilarKeywords(word, ordinal + 1);
}
}
}
}

var hatenaKeywords;

function setup()
{
var FRAME_WIDTH;
var FRAME_HEIGHT;

if (document.all)
{
FRAME_WIDTH = document.body.offsetWidth - 10;
FRAME_HEIGHT = document.documentElement.offsetHeight - 10 - 28;
}
else
{
FRAME_WIDTH = window.innerWidth - 10;
FRAME_HEIGHT = window.innerHeight - 10 - 28;
}

var view = document.implementation.hasFeature("org.w3c.dom.svg", '1.1') ?
new SVGGraphView(0, 26, FRAME_WIDTH, FRAME_HEIGHT, true)
: new HTMLGraphView(0, 26, FRAME_WIDTH, FRAME_HEIGHT, true);

var particleModel = new ParticleModel(view);
particleModel.start();

var control = new Control(particleModel, view);
var dataGraph = new DataGraph();

hatenaKeywords = new HatenaKeywords(dataGraph, particleModel);

var nodeHandler = new NodeHandler(dataGraph, particleModel, view, control);
dataGraph.subscribe(nodeHandler);

var buildTimer = new Timer(150);
buildTimer.subscribe(nodeHandler);
buildTimer.start();
}

var NodeHandler = function( dataGraph, particleModel, view, control )
{
this.dataGraph = dataGraph;
this.particleModel = particleModel;
this.view = view;

this.nodeQueue = new Array();
this.relationshipQueue = new Array();

this['newDataGraphNode'] = function(dataGraphNode)
{
this.enqueueNode(dataGraphNode);
}

this['newDataGraphEdge'] = function(nodeA, nodeB)
{
this.enqueueRelationship(nodeA, nodeB);
}

this['enqueueNode'] = function(dataGraphNode)
{
this.nodeQueue.push(dataGraphNode);
}

this['enqueueRelationship'] = function(nodeA, nodeB)
{
this.relationshipQueue.push({'nodeA': nodeA, 'nodeB': nodeB});
}

this['dequeueNode'] = function()
{
var node = this.nodeQueue.shift();

if (node)
{
this.addParticle(node);
return true;
}

return false;
}

this['dequeueRelationship'] = function()
{
var edge = this.relationshipQueue.shift();
if (edge)
this.addSimilarity(.05, edge.nodeA, edge.nodeB);
}

this.update = function()
{
var nodes = this.dequeueNode();
if (!nodes)
this.dequeueRelationship();
}

this['addParticle'] = function(dataGraphNode)
{
particle = this.particleModel.makeParticle(dataGraphNode.mass, 0, 0);
dataGraphNode.particle = particle;

if (dataGraphNode.fixed)
particle.fixed = true;

var rx = Math.random() * 2 - 1;
var ry = Math.random() * 2 - 1;
particle.positionX = rx - 50 / this.view.skew;
particle.positionY = ry;

for (var j = 0, l = this.particleModel.particles.length; j < l; j++)
{
if (this.particleModel.particles[j] != particle)
this.particleModel.makeMagnet(particle, this.particleModel.particles[j], -30000, 64);
}

var particleParent = false;

for (var c in dataGraphNode.edges)
{
if (!particleParent)
{
particleParent = true;
particle.positionX = dataGraphNode.edges[c].particle.positionX + rx;
particle.positionY = dataGraphNode.edges[c].particle.positionY + ry;
}

this.addSimilarity(.2, dataGraphNode, dataGraphNode.edges[c]);
}

var keyword = dataGraphNode.keyword;
if (!keyword) {keyword = "";}

var n = document.createElement('div');
n.style.position = "absolute";
n.className = "keyword";
n.innerHTML = '<div onclick="'
+ "hatenaKeywords.search('" + keyword.replace(/"/g,'"') + "')"
+ "\"><a onmouseover=\"this.style.textDecoration = 'underline'\" onmouseout=\"this.style.textDecoration = 'none'\">"
+ keyword + '</a></div>';
n.onmousedown = new EventHandler(control, control.handleMouseDownEvent, particle.id)
dataGraphNode.viewNode = this.view.addNode(particle, n, 25, isIE ? 10 : 30);

//particle.width = dataGraphNode.viewNode.offsetWidth;
//particle.height = dataGraphNode.viewNode.offsetHeight;

return dataGraphNode;
},

this['addSimilarity'] = function(springConstant, nodeA, nodeB)
{
particleModel.makeSpring(nodeA.particle, nodeB.particle, springConstant, .2, 80);

var props = document.implementation.hasFeature("org.w3c.dom.svg", '1.1') ?
{
'stroke': "#bbbbbb",
'stroke-width': '2px',
'stroke-dasharray': '2, 8'
}
:
{
'pixelColor': "#aaaaaa",
'pixelWidth': '2px',
'pixelHeight': '2px',
'pixels': 15
};

this.view.addEdge(nodeA.particle, nodeB.particle, props);
}
}

//-->

</script>

<style type="text/css">

body
{
margin: 0;
padding: 0;
overflow: hidden;
}

p
{
text-decoration: none;
font-size: 13px;
font-weight: normal;
font-family: Verdana, Geneva, san-serif;
line-height: 150%;
margin-top: 0px;
margin-bottom: 1em;
padding-left: 8px;
padding-right: 8px;
}

form
{
margin: 0;
padding: 0;
}

div.keyword
{
font-family: Verdana, Geneva, san-serif;
font-weight: bold;
font-size: 14px;
text-align: left;
background-repeat: no-repeat;
cursor: hand;
}

#keywordResults
{
display: none;
color: #000000;
border-top: 0px;
}

</style>
</head>

<body onload="setup();">
<div id="searchResults"></div>
<div id="keywordResults"></div>
</body>
</html>

HEREDOC

$response->content($out);

return RC_OK;
}



「はてなキーワード」で検索を行うためのAJAXコールバックを定義する。JSVizはこれを呼び出して見つかった関連単語を次々と検索し、単語のグラフに新しいノードを付け加えていく。



sub handlerSearch
{
my ($request, $response) = @_;
$response->code(RC_OK);

if ($request->method =~ /get/i)
{
return RC_DENY;
}

$response->header('Content-Type' => 'text/xml');
my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n";

my $w = $request->content;
utf8::decode($w);
$w =~ tr/A-Za-z0-9/A-Za-z0-9/;
$w = URI::Escape::uri_escape_utf8($w);

my $content = get("http://search.hatena.ne.jp/keyword?word=$w&amp;mode=rss&amp;ie=utf8&amp;page=1");
if ($content)
{
my $rss = new XML::RSS;

utf8::decode($content);

$rss->parse($content);

for my $item (@{$rss->{items}})
{
$out .= "<i a='";
$out .= $item->{link};
$out .= "'>";

if (defined($item->{title}))
{
$out .= "<t><![CDATA[";

my $t = $item->{title};
utf8::decode($t);
$t =~ s/]]>/]]>/g;
$out .= $t;

$out .= "]]></t>\n";
}

if (defined($item->{description}))
{
$out .= "<d><![CDATA[";

my $d = $item->{description};
utf8::decode($d);
$d =~ s/]]>/]]>/g;
$out .= $d;

$out .= "]]></d>";
}

$out .= "</i>\n";
}
}

$out .= "</r>";

$response->content($out);

return RC_OK;
}



関連単語を「はてな」の関連キーワードAPIを用いて探すためのハンドラ。「はてな」のコードサンプルで推奨されているようにXMLRPC::Liteモ ジュールを使って検索するが、私が試した限りでは「ディスク」など一部単語で問題が起こるようで、いささか実用性に欠ける。



sub handlerAssoc
{
my ($request, $response) = @_;
$response->code(RC_OK);

if ($request->method =~ /get/i)
{
return RC_DENY;
}

$response->header('Content-Type' => 'text/xml');
my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n";

my $w = $request->content;
utf8::decode($w);
$w =~ tr/A-Za-z0-9/A-Za-z0-9/;
utf8::encode($w);

# XML::Parser employed by XMLRPC::Lite has troubles for some words such as "ディスク".
# Probably should avoid XMLRPC::Lite and use another module instead in future.
eval
{
my $res = XMLRPC::Lite->new->proxy('http://d.hatena.ne.jp/xmlrpc')->call(
'hatena.getSimilarWord', {wordlist => [$w]}
);

unless ($res->fault)
{
foreach (@{$res->result->{wordlist}})
{
if (defined($_->{word}))
{
$out .= "<related w='";
$out .= $_->{word};
$out .= "'/>\n";
}
}
}
};
warn $@ if $@;

$out .= "</r>";

$response->content($out);

return RC_OK;
}



クイズの答えが正しいかどうか判定し、正解/不正解を返すAJAXコールバックURLのハンドラ。



sub handlerAnswer
{
my ($request, $response) = @_;
$response->code(RC_OK);

if ($request->method =~ /get/i)
{
return RC_DENY;
}

$response->header('Content-Type' => 'text/xml');
my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n";

if (exists($quiz_answer{$request->{connection}->{remote_ip}}))
{
my $user_input = $request->content;
utf8::decode($user_input);

if ($quiz_answer{$request->{connection}->{remote_ip}} eq $user_input)
{
$out .= "<a r=\"true\"/>";
}
else
{
my $answer = $quiz_answer{$request->{connection}->{remote_ip}};
$answer =~ s/]]//g;
$out .= "<a r=\"false\"><![CDATA[" . $answer . "]]></a>";
}
}

$out .= "</r>";

$response->content($out);

return RC_OK;
}

POE::Kernel->run;
exit;


最後にPOE::Kernelを呼び出し、サーバを起動する。

2つを書いてみての感想は、モジュール周りの扱いがRubyの方が簡潔で、完成された印象を持った。Perlの方はかなり入り組んでいてモジュールのインストールだけで小一時間かかってしまう(ほとんどがPOEに起因しているが、XML関連モジュールの層も相当ファットである印象を受ける)。Perlの方だけUTF-8を扱う必要がある点も、各モジュールの問題が噴出し、Perlにとって不利な結果となった。もちろんRubyも Mongrelの完成度が低いといった問題はあるものの、Webアプリケーションそのものの問題ではないし、それを言えばPOEは完全にMongrelに劣るので、Webアプリケーションテスト環境を含めた評価としては、やはりRubyの方が洗練されている。今回は順当にRubyに軍配が上がる結果となった。これでRubyそのもののパフォーマンスが向上すれば鬼に金棒といえるだろう。

コメント