Contents

Monologue

is not a blog.

DICE

IRC/Webサーバ for MS Windows®

Essays

プログラミングに関する文書他

English Side

英語コンテンツ

 

Favorites

Beyond3D

The forum runs @ 120fps.

Slashdot

News for nerds, stuff that doesn't matter - powered by Slash

The Code Project

Windows Free Source Code and Tutorials

SceneCritique

Powered by DICE

The Old New Thing

For all Windows programmers

Links

Home

Home (alt.)

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

文・RyuK ( )

2007年01月11日

 

webサービスというものが新しい技術として流行ったのは2001年頃だった。そこで語られていたビジョンというのは、WSDLで定義したサービスの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サービスは、webサービスのAPIを開放し、プレスリリースを出して客寄せを行い、利用者が増えたら閉じるという、提供企業による手軽な宣伝活動の産物であり、個人ユーザ囲い込みの道具である。それを利用する個人の動機はweb広告業界に投じられている金で、その量が5年前とは相当異なる以上、以前とは位相が異なってはいるわけだ。webサービス同士の競争が起こり良質なサービスが数多く提供されるまでになれば面白くなるかもしれない。しかし、webサービスを介して提供される元ネタが数少ない企業に独占されている現状では、そんなシナリオの蓋然性は低く、バリエーションの少ない似通ったwebサービスを「マッシュアップ」などと称してデプロイする個人は、いわば企業の走狗として活動しながら細々と広告収入を稼ぐことになる。

今回の試みでは、「はてな」の認証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=([^&]+)/
			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 + '&' + form.receiver.value + '&' + 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 && (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 != ""
    	&& document.getElementById("receiver").value != checked_user && !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 && (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"] =~ /^([^&]+)&([^&]+)&(.+)/
			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 && receiver && 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/ && 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={}, &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 & 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 =>
	{
		'/' => \&handlerRoot,
		'/viz' => \&handlerViz,
		'/quiz' => \&handlerQuiz,
		'/answer' => \&handlerAnswer,
		'/assoc' => \&handlerAssoc,
		'/search' => \&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 =~ /^名詞/ && 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 & 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 && nodelist.length != 0)
	{
		for (var i = 0; i < nodelist.length && 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 && (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&mode=rss&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 && nodelist.length != 0)
		{
			for (var i = 0; i < nodelist.length && 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 && 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 && 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 && i < max && 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 class="keyword" 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&mode=rss&ie=utf8&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そのもののパフォーマンスが向上すれば鬼に金棒といえるだろう。