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

はてな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に与えるキー、データベースファイル名などを設定する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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を使用しているが、アップデートが頻繁なので異なるバージョンは不具合が出る可能性もある。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
=begin
 
hatenawebapp1.rb
 
Sample Web Application 1 with Hatena Web Service API : File Transfer between Hatena Users
 
by RyuK (klassphere[at.mark]gmail.com)
 
[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データベースのテーブルを無ければ新規作成する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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からの複数リクエストによって同時に参照される可能性があるため、同じようにマルチスレッド対応にしなければならない。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$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の初期化関数を呼び出し、次いで表示するページ内のヘッダをそのままヒアドキュメントを使ってスクリプト内に書いている。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$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を返す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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ユーザとして認識しており、まともなセッション管理を行っていないので、実際に使用するには厳密なセッション管理が必要である。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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にするというテクニックが使用されている。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
  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問い合わせを行い、 ダウンロード済みのファイルサイズを更新しつつ進捗バーを伸ばす。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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で問い合わせを行う。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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">
 
 
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のハンドラを定義する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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の仕様に従って、アップロードされてきたファイルのサイズに応じ、一時バッファもしくは一時ファイルから、実際のファイル保存先へとデータを移す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
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をここで上書き修正している。この辺は動的言語の面目躍如と言うべきだが、濫用すると収拾が付かなくなるので個人的にはやむをえない場合以外やるべきではないと思っている。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 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によるスクレイピングを行って強引に確かめることで代用する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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 += 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を返すハンドラを定義する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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


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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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)がどのハンドラクラスによって定義されているか、ここで指定する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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形式を用いてサーバのポートなどを設定している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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という名称のディレクトリを作ってその中へ全て展開する必要がある。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
=pod
 
hatenawebapp2.pl
 
Sample Web Application 2 with Hatena Web Service API : Hatena Keyword Quiz &amp; Visualization
 
by RyuK (klassphere[at.mark]gmail.com)
 
[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内で問題がある箇所の関数を動的に上書き修正している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 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サーバ上の各パス毎にハンドラ関数を登録している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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で形態素解析を行って、見つかった名詞の部分を隠すことによって穴埋め問題にするという至極単純な仕組みである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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のハンドラ。ユーザが任意の単語を入力すると「はてなキーワード」を検索し、キーワード間の連想グラフをロードする。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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'));">
 
 
<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データから説明文を抜き出してクイズを作成表示する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
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によって「はてなキーワード」の連想グラフを視覚化した物を表示する画面のハンドラ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
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はこれを呼び出して見つかった関連単語を次々と検索し、単語のグラフに新しいノードを付け加えていく。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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モ ジュールを使って検索するが、私が試した限りでは「ディスク」など一部単語で問題が起こるようで、いささか実用性に欠ける。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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のハンドラ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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そのもののパフォーマンスが向上すれば鬼に金棒といえるだろう。

コメント