素人の言語処理100本ノック:69


[言語処理100本ノック 2015](http://www.cl.ecei.tohoku.ac.jp/nlp100/)の挑戦記録です。環境はUbuntu 16.04 LTS ＋ Python 3.5.2 \:\: Anaconda 4.1.1 (64-bit)です。過去のノックの一覧は[こちら](http://qiita.com/segavvy/items/fb50ba8097d59475f760)からどうぞ。

## 第7章: データベース
>artist.json.gzは，オープンな音楽データベースMusicBrainzの中で，アーティストに関するものをJSON形式に変換し，gzip形式で圧縮したファイルである．このファイルには，1アーティストに関する情報が1行にJSON形式で格納されている．JSON形式の概要は以下の通りである．
>
>|フィールド|型|内容|例|
>|--------|--------|-----|----|
>|id|ユニーク識別子|整数|20660|
>|gid|グローバル識別子|文字列|"ecf9f3a3-35e9-4c58-acaa-e707fba45060"|
>|name|アーティスト名|文字列|"Oasis"|
>|sort_name|アーティスト名（辞書順整列用）|文字列|"Oasis"|
>|area|活動場所|文字列|"United Kingdom"|
>|aliases|別名|辞書オブジェクトのリスト||
>|aliases[].name|別名|文字列|"オアシス"|
>|aliases[].sort_name|別名（整列用）|文字列|"オアシス"|
>|begin|活動開始日|辞書||
>|begin.year|活動開始年|整数|1991|
>|begin.month|活動開始月|整数||
>|begin.date|活動開始日|整数||
>|end|活動終了日|辞書||
>|end.year|活動終了年|整数|2009|
>|end.month|活動終了月|整数|8|
>|end.date|活動終了日|整数|28|
>|tags|タグ|辞書オブジェクトのリスト||
>|tags[].count|タグ付けされた回数|整数|1|
>|tags[].value|タグ内容|文字列|"rock"|
>|rating|レーティング|辞書オブジェクト||
>|rating.count|レーティングの投票数|整数|13|
>|rating.value|レーティングの値（平均値）|整数|86|
>
>artist.json.gzのデータをKey-Value-Store (KVS) およびドキュメント志向型データベースに格納・検索することを考える．KVSとしては，LevelDB，Redis，KyotoCabinet等を用いよ．ドキュメント志向型データベースとして，MongoDBを採用したが，CouchDBやRethinkDB等を用いてもよい．

###69. Webアプリケーションの作成
>ユーザから入力された検索条件に合致するアーティストの情報を表示するWebアプリケーションを作成せよ．アーティスト名，アーティストの別名，タグ等で検索条件を指定し，アーティスト情報のリストをレーティングの高い順などで整列して表示せよ．

####出来上がったコード：

メインのプログラムです。「cgi-bin」というディレクトリを作って、その中に配置します。

```python:cgi-bin/main.py
#!/usr/bin/env python
# coding: utf-8
from string import Template
import pymongo
from pymongo import MongoClient
import cgi
import cgitb
from html import escape

# 詳細なエラー情報をブラウザーで表示
cgitb.enable()

max_view_count = 20		# 最大結果表示数

# HTML全体のテンプレート
template_html = Template('''
<html>
	<head>
		<title>言語処理100本ノック2015 問題69</title>
		<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
	</head>
	<body>
		<form method="GET" action="/cgi-bin/main.py">
			名前、別名：<input type="text" name="name" value="$clue_name" size="20"/><br />
			タグ：<input type="text" name="tag" value="$clue_tag" size="20"/><br />
			<input type="submit" value="検索"/>
		</form>
		$message
		$contents
	</body>
</html>
''')

# 結果表示部分(template_htmlの$contents部分)のテンプレート
template_result = Template('''
		<hr />
		($index件目/全$total件)<br />
		〔名前〕$name<br />
		〔別名〕$aliases<br />
		〔活動場所〕$area<br />
		〔タグ〕$tags<br />
		〔レーティング〕$rating<br />
''')

# MongoDBのデータベースtestdbにコレクションartistにアクセス
client = MongoClient()
db = client.testdb
collection = db.artist

# 条件を作成
form = cgi.FieldStorage()
clue = {}
clue_name = ''		# 名前の入力欄の内容
clue_tag = ''		# タグの入力欄の内容

if 'name' in form:
	clue_name = form['name'].value
	clue = {'$or': [{'name': clue_name}, {'aliases.name': clue_name}]}

if 'tag' in form:
	clue_tag = form['tag'].value
	if len(clue) > 0:
		clue = {'$and': [clue, {'tags.value': clue_tag}]}	# 名前とタグの組み合わせ
	else:
		clue = {'tags.value': clue_tag}						# タグのみ

# 検索、ソート
contents = ''		# 検索結果部分の出力内容
total = -1			# 結果件数、未検索時は-1
if len(clue) > 0:

	results = collection.find(clue)
	results.sort('rating.count', pymongo.DESCENDING)
	total = results.count()

	# 結果を整形
	dict_template = {}
	for i, doc in enumerate(results[0:max_view_count], start=1):

		# 結果表示部分のテンプレート用辞書に内容をセット
		dict_template['index'] = i
		dict_template['total'] = total
		dict_template['name'] = escape(doc['name'])

		if 'aliases' in doc:
			dict_template['aliases'] = \
				','.join(escape(alias['name']) for alias in doc['aliases'])
		else:
			dict_template['aliases'] = '(データなし)'

		if 'area' in doc:
			dict_template['area'] = escape(doc['area'])
		else:
			dict_template['area'] = '(データなし)'

		if 'tags' in doc:
			dict_template['tags'] = \
				','.join(escape(tag['value']) for tag in doc['tags'])
		else:
			dict_template['tags'] = '(データなし)'

		if 'rating' in doc:
			dict_template['rating'] = doc['rating']['count']
		else:
			dict_template['rating'] = '(データなし)'

		# 結果表示部分のテンプレート適用
		contents += template_result.substitute(dict_template)

# HTML全体のテンプレート用辞書に内容をセット
dict_template = {}
dict_template['clue_name'] = escape(clue_name)
dict_template['clue_tag'] = escape(clue_tag)
dict_template['contents'] = contents

if total > max_view_count:
	dict_template['message'] = '結果が多いため先頭{}件を表示しています'.format(max_view_count)
elif total == -1:
	dict_template['message'] = '検索条件を入力してください'
elif total == 0:
	dict_template['message'] = '該当するアーティストは見つかりませんでした'
else:
	dict_template['message'] = ''

# HTML全体のテンプレート適用、出力
print(template_html.substitute(dict_template))
```

python3のWebサーバー機能を開始するためのシェルスクリプトです。

```bash:start_serv.sh
#!/bin/sh

# CGIHTTPRequestHandlerをハンドラとしてWebサーバー起動
python -m http.server --cgi 8000
```

####実行結果：

MongoDBのサービスを開始して、start_serv.shを実行すれば準備は完了です。
ブラウザーで「localhost:8000/cgi-bin/main.py」にアクセスすれば動作します。

![Kobito.TmdU2L.png](https://qiita-image-store.s3.amazonaws.com/0/139624/e242b13b-c097-f7a7-8beb-f7bd34f93ee0.png "Kobito.TmdU2L.png")

以下、名前での検索例です。入力された条件で、アーティスト名と別名の両方を検索し、どちらかに該当すればヒットします。ちなみにジョン・ウィリアムズはStar Warsなどの映画音楽で有名な作曲家です。

![Kobito.np47ub.png](https://qiita-image-store.s3.amazonaws.com/0/139624/7f897d1a-4e61-2bad-d922-0c152bb53acd.png "Kobito.np47ub.png")

タグの検索例です。複数ヒットする場合は、レーティングの多い順で表示します。件数が多い場合は最初の20件だけを表示するようにしています。

![Kobito.YJN3HJ.png](https://qiita-image-store.s3.amazonaws.com/0/139624/9e6f3b7a-0355-35c9-bd5a-18cf03371abe.png "Kobito.YJN3HJ.png")

名前とタグの両方が入力された場合は、両方を満たすものを検索します。

![Kobito.xIJcKq.png](https://qiita-image-store.s3.amazonaws.com/0/139624/d2a8a85e-f76e-1f5f-01f3-e7ee1a54df42.png "Kobito.xIJcKq.png")

###Webサーバーの準備

今回はPython3に標準で備わっている簡易HTTPサーバーの機能を利用しました。「start_serv.sh」に書いたようにコマンド一発で起動できるので簡単です。なお、Pythonのプログラムを動かすためには「CGIHTTPRequestHandler」を使う必要があるため、「--cgi」オプションを指定しています。終了する機能はないみたいなので「CTRL」+「C」で終了させてください。

Python3のHTTPサーバーに関する詳細は[こちら](http://docs.python.jp/3/library/http.server.html)です。また、ネットにもたくさん情報があるので、詳しくは「Python3 CGI」などでググってみてください。

###Webサーバー？HTTPサーバー？CGIサーバー？

Webアプリケーションの場合、利用者はWebブラウザーを使います。WebブラウザーがWebサーバーに処理依頼を投げて、Webサーバーがそれを受け取ってPythonのプログラムを実行します。そして、そのPythonのプログラムの出力結果をWebサーバーが受け取り、それをWebブラウザーに返す流れです。

WebブラウザーとWebサーバーの間で使われるプロトコル（通信のルール）が「HTTP」です。そのため、WebサーバーはHTTPサーバーとか呼ばれたりします。また、Webサーバーが他のプログラムを実行して結果を受け取るための仕組みがCGIです。そのため、WebサーバーはCGIサーバーとか呼ばれたりもします。

###PythonでCGIのプログラムを作る

今回のPythonのプログラムはWebサーバーから実行されるため、シェルスクリプト同様、実行権限が必要です。そのため「cgi-bin」ディレクトリの中に置く「main.py」には`chmod +x main.py`などで実行権限を付与しましょう。ちなみにこの「cgi-bin」というディレクトリ名は、CGIの実行ファイルを置く場所で、ディレクトリ名は慣習です。変えない方が混乱がなくて良さそうです。

Pythonのプログラム内では、Webサーバーからの処理要求をcgiモジュールを使って取り出せます。
今回のプログラムでは、Webブラウザーからの処理要求がフォームデータとして送られてくるので、これを`cgi.FieldStorage()`で取り出します。取り出した結果は辞書のようにアクセスできます。詳しくは[cgi — CGI (ゲートウェイインタフェース規格) のサポート](http://docs.python.jp/3/library/cgi.html#using-the-cgi-module)を参照してください。

PythonのプログラムからWebサーバーに結果を返す方法は簡単で、単純に標準出力へ出力するだけです。今回の問題では、利用者が入力した検索の条件を取り出してMongoDBに検索をかけて、その結果を`print()`で出力してWebサーバーに返しています。なお、Webサーバーに返した内容はほぼそのままWebブラウザーに送られるため、Webブラウザーが理解できるHTML形式のデータを出力する必要があります。

Webブラウザー、Webサーバー、Pythonのプログラムの3者の関係をざっくり図にすると、こんな感じになります。
![Kobito.kiQigI.png](https://qiita-image-store.s3.amazonaws.com/0/139624/0a2bac50-fb95-5c8a-2b36-34fc2da912c0.png "Kobito.kiQigI.png")


###詳細なエラー情報の表示

Pythonの簡易HTTPサーバーでは、Pythonのプログラムに間違いがあってもエラーにならず、成功を示すコード（HTTPコード200）をブラウザーへ返してしまうようです。[http.server.CGIHTTPRequestHandler](http://docs.python.jp/3/library/http.server.html#http.server.CGIHTTPRequestHandler)の説明の「注釈」にも、それらしいことが書いてありました。この辺は簡易サーバーなので仕方がないところかも知れません。
この場合、ブラウザーは処理に成功して0バイトのファイルが送られてきたと思ってしまうため、それを開くか保存するかを聞いてきます。0バイトなので開いても保存しても中身はまっしろです。

![Kobito.uUX3ha.png](https://qiita-image-store.s3.amazonaws.com/0/139624/7bf787bc-c754-0c89-659b-918807edef38.png "Kobito.uUX3ha.png")

これではどこにエラーがあるのか分からずデバッグが大変なので、今回のプログラムでは[`cgitb.enable()`](http://docs.python.jp/3/library/cgitb.html#cgitb.enable)を指定して、エラー時は詳細なエラー情報をブラウザーに送るように設定しました。これならエラー箇所がブラウザーで確認できてデバッグしやすくなります。

![Kobito.cuyvGe.png](https://qiita-image-store.s3.amazonaws.com/0/139624/93539a8f-4d2d-5e7b-4702-614f279500e4.png "Kobito.cuyvGe.png")

ただし、ソースの中身やサーバー上の場所がブラウザーに表示されてしまうので注意が必要です。これらの情報は悪意ある人から見ると攻撃の糸口になってしまうためです。なお、詳細なエラー情報をブラウザーに送らずログに出力する機能もあります。詳しくは[cgitb — CGI スクリプトのトレースバック管理機構](http://docs.python.jp/3/library/cgitb.html?highlight=cgitb#module-cgitb)を参照してください。

###MongoDBに対する複数条件の検索

今回の問題では、アーティスト名が入力されると、アーティスト名と別名の2つのフィールドを検索します。これはMongoDBのOR演算子（どちらかを満たす）である`$or`を使っています。また、アーティスト名とタグ名が入力された場合は、アーティスト名の条件とタグの条件をAND演算子（両方を満たす）`$and`で連結して検索しています。

演算子はインタラクティブシェルと同様なので、[問題64](http://qiita.com/segavvy/items/8f93187ec89f4831d863#_reference-91be3d233556a0033a62)でもご紹介したsvjunicさんがまとめられている[「MongoDB コマンドメモとか書き」の複数条件](http://qiita.com/svjunic/items/285e9cf20169d70aa1fa#複数条件)の解説が分かりやすいです。オフィシャルサイトでは[Query and Projection Operators](https://docs.mongodb.com/manual/reference/operator/query/)に解説があります。

###HTML形式への整形

HTML形式への整形は[問題07](http://qiita.com/segavvy/items/6967ab5e8d41c7a879db)で使った[`string.Template`クラス](http://docs.python.jp/3/library/string.html#template-strings)を使っています。実行時に変動する部分は`$hogehoge`とプレースホルダーを埋めておいて、実際に整形する際にその中身を伝えています。

###HTMLのエスケープ

HTML形式では、表示したい内容に`&`、`<`、`>`、`'`、`"`を含む場合は、HTMLのタグなどと勘違いされないように、特殊な表記へエスケープする必要があります。これは[`html.escape()`](http://docs.python.jp/3/library/html.html#html.escape)でやってくれますので、今回のプログラムでは入力値やMongoDBから取得した文字列を表示する部分で使っています。（`'`と`"`のエスケープは、厳密にはHTMLの属性値として`'`や`"`でくくられている中でのみ必要らしいのですが、それ以外の場所でエスケープしてもブラウザは理解してくれるので今回のプログラムは一律で`'`や`"`も変換しています。厳密にやりたい場合は[`html.escape()`](http://docs.python.jp/3/library/html.html#html.escape)の`quote`で指定できます。）
なお、今回は使っていませんが、[`html.unescape()`](http://docs.python.jp/3/library/html.html#html.unescape)でエスケープされたものを元に戻せます。

　
70本目のノックは以上です。誤りなどありましたら、ご指摘いただけますと幸いです。
<hr>

*実行結果には、[100本ノックで用いるコーパス・データ](http://www.cl.ecei.tohoku.ac.jp/nlp100/#data)で配布されているデータの一部が含まれます。この第7章で用いているデータのライセンスは[クリエイティブ・コモンズ 表示 - 非営利 - 継承 3.0 非移植](https://creativecommons.org/licenses/by-nc-sa/3.0/)（[日本語訳](https://creativecommons.org/licenses/by-nc-sa/3.0/deed.ja)）です。*