2013年2月18日月曜日

WhooshとMongoDBを使った全文検索Webアプリの構築 - その1

ひょんなことから全文検索システムを作ることになり、いろいろ調べたことを備忘録的に記録しておく。
自分はいつもPython+Tornado+Nginx+MongoDBの構成でWebアプリを書いていて、この環境で手軽に全文検索ができたらいいなぁと思って試行錯誤した結果、本稿のようなことができることが分かったので公開しました。


【やりたいこと】
・全文検索(基本的にN-gram。できれば記事内の単語を元に関連記事の表示や単語の編集など)
・主に.txtのファイル内のテキスト本文を対象とする
・アカウントを発行して外部にも公開
(ここは本記事の趣旨から外れるので除外する。)

 【環境】
・さくらVPS 2Core 1GBメモリ
・CentOS 6.2
・Python 2.7.2
・Tornado 2.4.1(フレームワーク)
・MongoDB 2.2.3(データベース)
・Nginx 1.2.6(Webサーバ)
・Whoosh 2.4.1(全文検索エンジン)
・mecab-python(分かち書き処理を行うライブラリ)


【1/3 Whooshのインストール】

CentOS6.2環境にTornado、MongoDB、Nginxなどがインストールされている前提として話を進める。
まずはじめに全文検索エンジンであるWhooshをインストールする。

$ easy_install Whoosh

次にWhooshの概要と、簡単な使い方を記載しておく。
Whooshはpythonで実装された全文検索ライブラリとなっており、当然ながらPythonとの親和性が非常に高く、導入の敷居も低いので手軽に全文検索を始めることができる。

Whooshは、Apache Lucene(ルシーン)というJavaで記述された全文検索エンジンの仕組みを踏襲してPythonで構築されているようなので、Luceneを使ったことがある人にはするっと理解できると思う。

<<Whooshの考え方>>
・「Schema(スキーマ)」という「データを格納するための構造」を
 作成し、そのスキーマを元にインデックスの作成/検索を行う。
・スキーマにはFieldが定められており、Fieldの型やインデックス特性を
 考慮してテキストの保存をどのように行うかを考える必要がある
 Fieldは以下のようなものとなる。(引用 Whoosh v2.4.1 documentation

フィールド名 内容 ユニークにできるか否か インデックス有無 パラメータ
ID タイトル、ファイルのパス、URLなどに使用する stored
unique
IDLIST スペースや(または)句読点で区切られた複数の文字列をIDとして保存するフィールド 可能 stored
unique
expression
STORED インデックスしたくないものを保存するフィールド × × stored
KEYWORD スペースまたはカンマ区切りの文字列を保存し、タグのような使い方をする。区切られたそれぞれの文字(例えばabc,def,efg)はそれぞれの単語がインデックスされ、検索対象となる。ただしフレーズ検索(続いた文字列軍での検索)には対応していない。 × analyzer
phrase
chars
vactor
stored
spelling
NUMERIC 数値フィールド。Int / long / floatの値に対応。 可能 type
stored
unique
decimal_places
shift_steps
signed
DATETIME 日時(datetime型)を保存するフィールド。 可能 unique
stored
BOOLEAN ブーリアンを保存するフィールド。 × stored
NGRAM N-gramで文字列を保持するフィールド。minsizeで指定した数値(デフォルトは2)によって、最小となる文字数が決まる。 × minsize
maxsize
stored
queryor
phrase
NGRAMWORDS 連続する文字列(キーワード)をn単語単位で分割してインデックス化するフィールド。(例:we are theの場合、[we are][are the]の2つのデータとしてインデックス化する。 × minsize
maxsize
stored
tokenizer
at
queryor


今回登録したかったテキスト情報は
・タイトル(string)
・登録日時(datetime)
・テキスト本文(string)
・その他メタ情報が5種類ほど
といったものがあり、これらすべてをWhoosh側に登録するのはFieldの種類とカラム数が足らないので、

MongoDB側では
・タイトル
・その他メタ情報5種類ほど
といった補完情報を保存し、

そのレコードIDをWhoosh側で
・MongoDBのレコードID(_id)を「ID」にセット
・テキスト本文を「NGRAM」にセット
・登録日時を「DATETIME」にセット
して保存し、実際の検索はWhooshに行わせるようにした。


【2/3 Whooshの使い方 - テキストの登録】

<<テキストデータの登録>>
今回は.txt形式のテキストファイルがあり、このファイルを特定のディレクトリにアップロードしておいて
「テキストを抽出->MongoDBへ登録->Whooshへ登録」という処理を行った。
テキストファイルはあらかじめ指定のディレクトリにアップロードしておき、そのディレクトリ内にあるテキストを一括で登録・インデックス化するという流れになる。

from whoosh.fields import Schema, ID, STORED, NGRAM, DATETIME, TEXT
import whoosh.index as index
from whoosh.qparser import QueryParser

today = datetime.datetime.now()
indexdir = "/var/www/html/indexdir"
add_flg = 0
add_key = self.get_argument('add','')
if add_key=='1':
    #スキーマを定義する
    schema = Schema(
        mongo_rec_id=ID(stored=True, unique=True),
        regist_date=DATETIME(stored=True),
        body=TEXT(stored=True),
        n_body=NGRAM(stored=True)
    )
    database = con.UserData
    d_col = database.DocumentMasterData #MongoDBのコレクションを準備
    if not os.path.exists(indexdir):
        os.mkdir(indexdir)
        index.create_in(indexdir, schema)
    #/var/www/html/static/target_txt内のテキストファイル数を取得する
    target_dir = '/var/www/html/target_txt'
    check_dir = os.path.exists(target_dir)
    filecount = 0
    if check_dir:
        files = os.listdir(target_dir)
        filecount = len(files)
        for i in range(0,filecount):
            _filename = files[i];
            filename = '/var/www/html/target_txt/'+_filename
            dataarray = open(filename,"rb").read().split('\n') #テキストファイルを開く
            num = 0
            date_1 = ''
            something = ''
            flg = ''
            title = ''
            textvalue = ''
            today = datetime.datetime.now()
            line_f = '\n'
            for n in dataarray:
                if num==0:  date_1 = n.decode('cp932') #0. 日付を取得する
                if num==1:  something = n.decode('cp932')   #1. 日付(?)を取得する
                if num==2:  flg = n.decode('cp932')        #2. フラグを取得する
                if num==3:  title = n.decode('cp932')      #3. タイトルを取得する
                #4. 5行目以上のテキスト本文を最終行まですべて取得する
                if num>=4:
                    if textvalue!="":
                        if n.decode('cp932')=='':
                            textvalue = textvalue+line_f
                        else:
                            textvalue = textvalue+line_f+n.decode('cp932')
                else:
                        if n.decode('cp932')=='':
                            textvalue = line_f
                        else:
                            textvalue = n.decode('cp932')
                num+=1
    
                if title!="" and textvalue!="":
                #【STEP1/2】はじめにMongoDB側に補完情報を保存しておく
                _mongo_rec_id = d_col.insert({
                    "title":title,
                    "regist_date":today,
                    "update_date":"",
                    "page_num":0,
                    "category":""
                })
                _mongo_rec_id = str(_mongo_rec_id)
                if not isinstance(_mongo_rec_id,unicode):
                    _mongo_rec_id = unicode(_mongo_rec_id,'utf-8')
                
                #【STEP2/2】Whooshへドキュメントデータを保存する
                if _mongo_rec_id!="":
                    ix = index.open_dir(indexdir)
                    writer=ix.writer()
                    writer.add_document(
                        mongo_rec_id =_mongo_rec_id,
                        body              =textvalue,
                        n_body           =textvalue,
                        regist_date     =today
                    )
                    #optimize=Trueにすると登録処理に時間がかかる!
                    #try:    writer.commit(optimize=True) 
                    try:    writer.commit(optimize=False)
                    except: writer.cancel()
                    ix.close()


【3/3 Whooshの使い方 - 検索】

Whooshに登録したテキストデータを検索する時にも、上で説明した「Schema(スキーマ)」を使用する。

keyword = self.get_argument('keyword','') #検索したい文字
indexdir = "/var/www/html/indexdir"
if keyword!="":
    #検索する
    ix = None
    try:
        ix = index.open_dir(indexdir)
    #indexdirディレクトリにインデックスファイルがない場合に発生
    except index.EmptyIndexError:
        index.create_in(indexdir, schema)
        ix = index.open_dir(indexdir)
    searcher = ix.searcher()
    parser = QueryParser("n_body", schema = ix.schema)
    q = parser.parse(keyword)
    #検索取得件数を取得して検索実行(未指定の場合10件)
    results = searcher.search(q,limit=200)
    
    for r in results:
        self.write(r["n_body"].replace('\n','<br />'))
        self.write('<br />')
        self.write(str(r['mongo_rec_id']))
        self.write('<br />')
    ix.close()


現在、総登録件数における検索速度がどれくらいになるのか動作確認を行なっているところだが、約8万件登録した状態で上記の検索を実行した際の結果取得は平均して200msくらいで完了できる。


この後、辞書(MeCab)と絡めて検索精度を上げるための研究をしていこうと思う。

2010年10月31日日曜日

MacBook Air 11にぴったりなインナーケース

2010年10月21日 日本時間午前2時に催された「Back To The Mac」にて発表された新MacBook Air。初代MacBook Airが発表された時の感動を上回る衝撃に、やっぱりポチってしまいました。
オンラインのアップルストアにて11インチモデルをCTO(1.6GHz/4GB RAM/ 128GB)にて入手しましたが、最適なインナーケース(またはケース)がなくて結構困ってました。
(仕方ないので裸のままバッグにInしていました。。。)

基本的にいつでもマシンを持ち歩いているので、MacBook Pro 13(2.53GHz/8GB RAM/500GB-7200RPM)の代わりにうってつけなAirを早く外でも安心して使い倒したい!と思い、いろいろと探しました。

そんな中で見つけたのが今回ご紹介するEasternShape社の「E.S. InnerCase for MacBook 13"」です。
もともとMacBook Pro 13インチおよびMacBook 13インチ用のものなので、MacBook Air 11に完全対応というわけではないのですが、画面サイズの割に横幅のあるAirには(自分としては)ぴったりだと思いました。


原宿にあるアシストオンさんで購入。7,350円でした。
スペーサーとしてオレンジ色の生地で包まれたスポンジ(?)がついてます。
このケースは前・後ろ共に樹脂ボードが仕込まれているため衝撃に強そう!
MacBook Airは薄いため、たくさん荷物を入れたバッグで
心配になる「圧力で本体折れないかな?...」という不安が

お店でこの板の硬さを確認してみて完全に拭えました。
(本当に大丈夫かどうかはこれから試していきます!)


横幅は約25cm弱くらい


縦幅は36cmほどでしょうか。


MacBook Air 11と並べるとこんな感じです。


MacBook Air11をそのまま入れるとちょっとゆるい。


MacBook Air11を入れてみました。底の方に押し込まずに撮影。
右側に隙間ができるので.......


付属のスポンジ(?)素材をはめ込んでみました。なんとぴったり!


底の方に押し込んでみます。うん。これでゆるくならない。
でも底の方に押し込んでフタをすると上部に隙間ができるので
何か適当なスポンジをまた探してみようかと思います。


ちなみにMacBook Air11との厚さ比較はこんな感じです。


思いがけず素晴らしいケースに出会いました。ありがとう!アシストオンさん




ちなみに普段MacBook Pro 13を運ぶのに使用しているバッグがコレ
これに
・MacBook Pro 13 (Core2Duo 2.53GHz / 8GB RAM / 500GB-7200RPM)
・iPad(32GB/3G&Wi-Fi)
・Android HTC Desire
・E-mobile Pocket Wi-Fi
Western Digital My Passport 
(FW800/400/USB 2.5インチ外付けハードディスク)
・その他、FeliCaカードリーダ、USBケーブル、FireWire800ケーブル、など
を突っ込んでいます。
MacBook ProがMacBook Air 11になるだけでもかな〜り軽くなるので本当に助かります。


さて次はパームレストに貼るフィルムだなぁ〜。
パワーサポートさんとかMicroSolutionさんとか早く出さないかな〜。
よろしくお願いします!

2010年8月24日火曜日

IE対策 - クリックEventの取得について

JavaScriptを使用して、イベント(Event)を取得し、そのイベントの動作を
制御したい場合に以下の点でハマることがあります。

・確認画面(windows.confirm)やモーダルウィンドウ($.modal)などを使用して
ダイアログを開いた状態のまま処理を停止したい時に、停止すべき"Event"を取得することができない

実際のソースコードでは以下のような場合にInternet Explorer 7および8で上手く動きません。

【例1】

            function showDetail( event , kwds , argv ){
                event.preventDefault();    //イベントを取得してそのイベントを一時的に止めておくための処理
            }

            <button onclick="showDetail(event,message,value)">クリック</button>


上記のソースで上手く動かないポイントは「event.preventDefault()」を実行した際に、
引数で「event」が渡っているにも関わらず、IEのJavaScript判定ではこのeventを取得
出来ない、というところです。

これを正しく動作するようにするためには以下のように書き換える必要があります。


【例1改】

            function showDetail( event , kwds , argv ){
                $("#clickme").click(function(event){
                    event.preventDefault();    //イベントを取得してそのイベントを一時的に止めておくための処理
                });
            }

            <button id="clickme" onclick="showDetail(event,message,value)">クリック</button>


上記のように「あるidをクリックした」という事実を「.click」や「.submit」を使用して
その引数にeventを渡してあげることでIE7&8でもSafariでもFirefoxでもChromeでも正しく動くようになります。

2010年8月13日金曜日

Google Calender API | feedの取得

Google Calendar の予定などをApp Engine上に読み込むためには
Google Data Library(GDATA)というものを使用します。

【GDATA Python Client Library】のダウンロード
http://code.google.com/p/gdata-python-client/
2010年8月13日現在、最新版は2.0.11です。

→ファイルをダウンロードしたら展開し、「src」フォルダ内の
「gdata」「atom」の2つのフォルダを、自身のアプリケーションの
プロジェクトフォルダのルートにドラッグ&ドロップで移動してください。


このデータの取得方法において、まず始めにやらなくてはならないことが、
「ユーザの許可を得る」ということです。

このユーザの許可を得る方法としては

・アクセスするユーザのアカウント名/パスワードをあらかじめ
    ソースにハードコーディングで仕込んでおく「Client Login」

・ユーザのデータへアクセスする必要がある場合にユーザに対して
    直接得る「AuthSub」

の2つの認証方法があります。※1

アクセスするユーザが特定されている場合には「Client Login」を使用したほうが
実装は簡単です。
しかし複数のユーザが存在することが前提となるGoogle Apps環境などに合わせて開発を
行う場合には「AuthSub」を使用しなくてはなりません。

ここでは
「AuthSubによる認証トークンの取得とデータ(Feed)の取得」
について説明します。




【AuthSubによる認証トークンの取得とデータの取得】


# -*- coding: utf_8 -*-
#!/usr/bin/env python

############################# Import  Library ##############################
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
from google.appengine.ext.webapp import util
try:
  from xml.etree import ElementTree # for Python 2.5 users
except ImportError:
  from elementtree import ElementTree
import gdata.calendar.service
import gdata.service
import atom.service
import gdata.calendar
import gdata.auth
import gdata.alt.appengine
import atom
import getopt
import sys
import string
import time
import cgi
import logging
import codecs
import os
############################# Import Library ##############################


################################ Class #################################
#url:/
class MainHandler(webapp.RequestHandler):
    def get(self):
        # Googleサービス(Scope)への接続許可を求めるためのリンクを生成する。
        authSubUrl = GetAuthSubUrl();
        a_url = u'Googleサービスへアクセス' % authSubUrl
        self.response.out.write(str(a_url.encode('utf-8')))

#url:/calendar_view
class CalendarViewer(webapp.RequestHandler):
    layout_file = 'calendar.html'
    
    def __init__(self):
        # calendar_clientにGoogleカレンダーサービスをセットし、初期値の設定を行う。
        self.calendar_client = gdata.calendar.service.CalendarService()
        gdata.alt.appengine.run_on_appengine(
            self.calendar_client,store_tokens = True, single_user_mode = True
        )
        
    def get(self):
        # Googleサービス(Scope)への接続許可を求めるためのリンクを生成する。
        authSubUrl = GetAuthSubUrl();
        a_url = u'Googleサービスへアクセス' % authSubUrl
        
        token_request_url = None
        cal_list          = []
        cal_list_detail   = []
        # URLからトークンを取得する
        auth_token = gdata.auth.extract_auth_sub_token_from_url(self.request.uri)
        if auth_token:
            try:
                # 取得したトークンをセッショントークンにアップグレードして
                # AuthSubTokenとしてセットする
                self.calendar_client.SetAuthSubToken(
                    self.calendar_client.upgrade_to_session_token(auth_token)
                )
            except gdata.service.TokenUpgradeFailed:
                self.redirect('/')
                return
            
            # アップデートされたトークンがセットされたcalendar_clientで
            # カレンダーの予定を取得するGetCalendarEventFeed()を実行する
            feed = self.calendar_client.GetCalendarEventFeed()
            
            # feed.entryにカレンダーのデータの配列が入っているのでループで取得する
            # ここでは例としてキー・バリューを取り出します。
            #(予定のタイトル開始日時・終了日時が含まれた配列がcal_listにセットされます。    
            cal_list = [[i.title.text,i.when[0].start_time,i.when[0].end_time] for i in feed.entry]
     
        ValueList = {
        'a_url':a_url,
        'cal_list':cal_list        # html上でループして値を取得・表示できる
        }

        fpath = os.path.join(os.path.dirname(__file__),'layouts',self.layout_file)
        html = template.render(fpath,ValueList)
        self.response.out.write(html)

################################ Class #################################



############################# Custom Method ############################

# Googleサービス(Scope)への接続許可を求めるためのリンクを生成する。
def GetAuthSubUrl():
    next = 'http://calender-retrieve-sample.appspot.com/calendar_view'
    scope = 'http://www.google.com/calendar/feeds/default/allcalendars/full'
    secure = False
    session = True
    calendar_service = gdata.calendar.service.CalendarService()
    return calendar_service.GenerateAuthSubURL(next, scope, secure, session);

############################# Custom Method ############################


################################# main #################################
def main():
    application = webapp.WSGIApplication([
        ('/', MainHandler),
        ('/calendar_view',CalendarViewer)
    ],debug=True)
    util.run_wsgi_app(application)

if __name__ == '__main__':
    main()
################################# main #################################

2010年8月8日日曜日

iPhone/iPad向けWebアプリ開発時のローカルファイルキャッシュ

iPhone/iPad向けのWebアプリケーションを開発する際、SenchaやjQTouchなどの
ライブラリーを活用してWebアプリケーションをあたかもネイティブアプリのように
振舞わせるわけだが、その際に避けて通れないのが
「ライブラリ・CSSなどのロード時の速度低下」です。

ライブラリやCSSなどのファイルのロードがかなり時間を食ってしまい、
利用者に「このページ遅いな」と思われてしまいます。

それを防ぐためには
「iPhone/iPadのフラッシュメモリ内にJavaScriptやCSSなどのファイルを保存させる」
という設定を行ないます。

この設定には「manifest」ファイルをサーバ側で用意し、manifestファイルには、
「どのファイルをローカルキャッシュさせるか」を記述してあげる必要があります。


以下に記述例を記載します。

使用するファイルの構成は以下の通りとなります。

【プロジェクトフォルダ内】
CSSファイル
/css/js/sencha_icons/mystyle.css
(元々app.yamlに以下の
- url: /css
  static_dir: css
という静的ファイルディレクトリ指定の設定をしています。)

JavaScirptファイル
/css/js/sencha_icons/ext-touch.js
/css/js/sencha_icons/index.js

作成したmanifestファイル
/css/js/sencha_icons/sitefile.manifest


1.まずはじめに、使用するHTMLファイル内のソースに


と記述します。
(example.manifestはexampleという名前のマニフェストファイルとなります。)


2.app.yamlに以下の内容を記述します。




- url: /css/js/sencha_icons/sitefile.manifest                        →所在URL
  static_files: /css/js/sencha_icons/sitefile.manifest          →ファイルの在処
  mime_type: text/cache-manifest                                        →mime_typeの指定
(↑これがないとマニフェストファイルとして認識されません。)
  upload: /css/js/sencha_icons/sitefile.manifest                →ファイルのアップロード先



3.sitefile.manifestファイル(ファイル名はsitefileでなくてもOK)を作成し、以下の内容を記述します。

CACHE:
mystyle.css
ext-touch.js
index.js

4.デプロイしてiPhone/iPadのブラウザで再読込する。


これで上記の3つのファイルがiPhone/iPadのローカルに保存され、
ページのロードが高速になります。



参考URL: http://www.html5rocks.com/tutorials/appcache/beginner/

2010年7月2日金曜日

【Python】デコレータをやってみる

Pythonを始めてから早9ヶ月。
最近ものすごくPythonが楽しい。

そこで今日は、ちょっと前から気になっていた「デコレータ」に挑戦してみようかな。


まず「デコレータ」(decolator)について調べてみると、言葉の意味から
分かるように「装飾する」ものとして動くらしい。
数カ月前に知り合いのプログラマが「デコレータいいよ」って言ってた
ときは全然分からなかった(何に使うのか/自分にとって必要なのか)が、
やってみるとこれは凄い!


僕は今回以下のサイトで勉強させてもらいました。
ありがとうございますm(_ _)m

http://www.ianlewis.org/jp/appengine-maintenance-page
http://www.gesource.jp/weblog/?p=3498
http://satoshi.blogs.com/life/2009/11/python入門デコレータとは.html
http://jutememo.blogspot.com/2008/10/python-1_09.html



前々から自分の書くコードはなんとなく他の人のものよりも長い!
おんなじようなことをいろんなところでやってる!
もっとまとめられないのか??

そんな悩みを解決できそうなのが「デコレータ」。


というわけで早速やってみる。



今回は、
「ログイン状態をチェックし、ログインしていない場合にはログインページへ、admin権限でログインしていない場合にはログイン画面へリダイレクト」
というのをやる。

書いてみたのが以下のメソッド


#ログイン状態をチェックするデコレータ
def check_login_status(req):
 def wrapped(request, *args, **kwargs):
 #ログインしていない場合はログイン画面へリダイレクトする
 if not user_account:
  request.redirect('/login')
  return
 #管理者権限を持っていない場合はユーザ向けページへリダイレクトする
 if not users.is_current_user_admin():
  request.redirect('/user')
  return
 return req(request, *args, **kwargs)
return wrapped




実際には以下のように使用している。

class AdminMainHandler(webapp.RequestHandler):
 layout = 'admin.html'
 @check_login_status
 def get(self):


ポイントは

・クラス内のメソッドの直前に「@check_login_status」という形で
デコレータを呼び出す。

・デコレータは「def get(self)」自体を受け取り(reqとして使用)、
 直後の「def get(self)」の「self」を受けて(requestとして使用)、
 ログイン状態を判定し、リダイレクトなどの処理を行う。

という感じだと思う。




まぁここで気になったのはデコレータの中で使っているwrappedの
*args, **kwargs」だが、これについても別途書き綴ってみたいと思う。



今日は疲れたのでちょっと休む。