読者です 読者をやめる 読者になる 読者になる

俺の備忘録

個人的な備忘録です。

Flaskとwhooshで簡単全文検索Webアプリケーション

python java whoosh Flask

サマリ

Pythonの軽量WebフレームワークであるFlask全文検索ライブラリであるwhooshを使用して全文検索Webアプリケーションを作ってみた。

f:id:magayengineer:20160920000022p:plain

ただ単にテキストファイルを全文検索しただけでは実用性に欠ける。 そのため、MSオフィスのファイルとPDFファイルをApache POI, Apache PDFBoxでテキストファイルに変換し、全文検索で検索できるようにした。

全体構成

まず、全文検索の対象にしたいファイルをApache POIApache PDFBoxなどを利用し、テキストファイルに変換する。変換の必要のないファイルはそのままコピーすればよい。 次にwhooshでテキストファイルを解析し、検索用のインデックスファイルを作る。そしてFlaskを使って作成したWebアプリからインデックスを使用して全文検索を行い、検索結果と検索にヒットしたファイルのURLを返すという作りになっている。

f:id:magayengineer:20160920000039p:plain

MSオフィスファイルからのテキスト抽出

Apache POIから必要なライブラリ(jarファイル)を入手すれば終わったようなもの。

以下のJavaコードで簡単にテキストファイルが抽出できる。

/**
 * MSオフィスのファイルからテキストを抽出する
 * @param file MSオフィスのファイル
 * @return MSオフィスのファイルのテキスト
 */
private static String getOfficeText(File file){
  POITextExtractor textExtractor = null;
  try {
    textExtractor = ExtractorFactory.createExtractor(file);
    return textExtractor.getText();
  } catch (Exception e) {
    e.printStackTrace();
  } finally {
    if (textExtractor != null) {
      try {
        textExtractor.close();
      } catch (IOException e) {
        e.printStackTrace();
      };
    }
  }
  return null;
}

PDFファイルからのテキストを抽出

こちらも同じくApache PDFBoxから必要なライブラリ(jarファイル)を入手すれば終わったようなもの。

以下のJavaコードで簡単にテキストファイルが抽出できる。

/**
 * PDFからテキストを抽出して返す
 * @param file PDFファイル
 * @return PDFから抽出したテキスト
 */
private static String getPdfText(File file) {
  PDDocument document = null;
  try {
    document = PDDocument.load(file);
    return new PDFTextStripper().getText(document);
  } catch (IOException e) {
    e.printStackTrace();
  } finally {
    if (document != null) {
      try {
        document.close();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
  return null;
}

最終的にできたテキスト変換Javaコード

最終的にできたコードがこれ。 MSオフィスやPDFファイルはテキストファイルに変換しコピーし、その他のファイルはそのままコピーする。指定したディレクトリに含まれる全てのファイルに対して実行する。 これを実行するだけで、全文検索に用いるテキストファイルが用意できる。

CopyAndConvert.java

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.POITextExtractor;
import org.apache.poi.extractor.ExtractorFactory;

/**
 * 全文検索用にファイルをテキストファイルに変換するクラス
 * ディレクトリ構造のコピー、ファイルのコピーを行う。
 * MSオフィスのファイルはテキストを抽出しにファイル作成する。
 */
public class CopyAndConvert {
    public static void main(String[] args) {
        // コピー元ディレクトリのパス
        String fromDirPath = args[0];
        // コピー先ディレクトリのパス
        String toDirPath = args[1];
        // ファイルコピー
        CopyAndConvert.copyAndConvert(new File(fromDirPath), new File(toDirPath));
    }

    /**
    * MSオフィスの拡張子
    */
    private static final List<String> MS_OFFICE_SUFFIX_LIST = new ArrayList<String>();
    static {
        MS_OFFICE_SUFFIX_LIST.add("xls");
        MS_OFFICE_SUFFIX_LIST.add("doc");
        MS_OFFICE_SUFFIX_LIST.add("xlsx");
        MS_OFFICE_SUFFIX_LIST.add("docx");
        MS_OFFICE_SUFFIX_LIST.add("ppt");
        MS_OFFICE_SUFFIX_LIST.add("pptx");
        MS_OFFICE_SUFFIX_LIST.add("msg");
    }

    /**
    * 無視する拡張子
    */
    private static final List<String> IGNORE_SUFFIX_LIST = new ArrayList<String>();
    static {
        IGNORE_SUFFIX_LIST.add("JPG");
        IGNORE_SUFFIX_LIST.add("jpg");
        IGNORE_SUFFIX_LIST.add("iso");
        IGNORE_SUFFIX_LIST.add("png");
        IGNORE_SUFFIX_LIST.add("exe");
    }

    /**
    * ファイルをテキストファイルに変換して配置する
    * @param fromDir 変換元ディレクトリ
    * @param toDir コピー&変換先ディレクトリ
    */
    private static void copyAndConvert(File fromDir, File toDir) {
        for (File fromFile:fromDir.listFiles()) {
            if (fromFile.isDirectory()) {
                //ディレクトリのとき
                File newDir = new File(toDir, fromFile.getName());
                newDir.mkdir();
                CopyAndConvert.copyAndConvert(fromFile, newDir);
            } else {
                // 拡張子取得
                String suffix = CopyAndConvert.getSuffix(fromFile.getName());
                if (suffix == null) {
                    CopyAndConvert.copyFile(toDir, fromFile);
                } else {。
                    if (IGNORE_SUFFIX_LIST.contains(suffix)){
                        // 無視する拡張子の場合
                        continue;
                    }
                    if (MS_OFFICE_SUFFIX_LIST.contains(suffix)) {
                        // MSオフィスの場合
                        String msText = CopyAndConvert.getOfficeText(fromFile);
                        if (msText != null) {
                            File newFile = new File(toDir, fromFile.getName());
                            try {
                                CopyAndConvert.saveTextFile(newFile, msText);
                                System.out.println(newFile.getAbsolutePath() +"," + fromFile.getAbsolutePath());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    } else if("pdf".equals(suffix)) {
                        // PDFの場合
                        String pdfText = CopyAndConvert.getPdfText(fromFile);
                        if (pdfText != null) {
                            File newFile = new File(toDir, fromFile.getName());
                            try {
                                CopyAndConvert.saveTextFile(newFile, pdfText);
                                System.out.println(newFile.getAbsolutePath() +"," + fromFile.getAbsolutePath());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    } else {
                        CopyAndConvert.copyFile(toDir, fromFile);
                    }
                }
            }
        }
    }

    /**
    * MSオフィスのファイルからテキストを抽出する
    * @param file MSオフィスのファイル
    * @return MSオフィスのファイルのテキスト
    */
    private static String getOfficeText(File file){
        POITextExtractor textExtractor = null;
        try {
            textExtractor = ExtractorFactory.createExtractor(file);
            return textExtractor.getText();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {。。
            if (textExtractor != null) {
                try {
                    textExtractor.close();
                } catch (IOException e) {
                    e.printStackTrace();
                };
            }
        }
        return null;
    }


    /**
    * PDFからテキストを抽出して返す
    * @param file PDFファイル
    * @return PDFから抽出したテキスト
    */
    private static String getPdfText(File file) {
        PDDocument document = null;
        try {
            document = PDDocument.load(file);
            return new PDFTextStripper().getText(document);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (document != null) {
                try {
                    document.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }


    /**
    * テキストをファイルに保存する
    * @param file ファイル
    * @param text 保存するテキスト
    * @throws IOException IOエラーが発生した場合
    */
    private static void saveTextFile(File file, String text) throws IOException {
        PrintWriter pw = null;
        try {
            pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
            pw.println(text);
        } finally {
            if (pw != null) {
                pw.close();
            }
        }
    }

    /**
    * ファイルの拡張子を取得する
    * @param fileName ファイル名(拡張子込み)
    * @return 拡張子
    */
    private static String getSuffix(String fileName) {
        int point = fileName.lastIndexOf(".");
        if (point != -1) {
            return fileName.substring(point + 1);
        }
        return null;
    }

    /**
    * ファイルをコピーする
    * @param toDir コピー先ディレクトリ
    * @param fromFile コピー対象ファイル
    */
    private static void copyFile(File toDir, File fromFile) {
        try {
            File newFile = new File(toDir, fromFile.getName());
            Files.copy(fromFile.toPath(), newFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);
            System.out.println(newFile.getAbsolutePath() +" -> " + fromFile.getAbsolutePath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

インデックスファイルの作成

whooshのインストール

全文検索にはpython全文検索ライブラリであるwhooshを使用する。まずはpipでインストールする。

$ pip install whoosh

スキーマ作成

whooshを利用するためには、スキーマ定義が必要。 スキーマは、検索に使う情報や検索後に合わせて利用しておきたい情報をぶっこんでおけばいいものという理解。 なので以下の2つのフィールドを入れられるように定義する。

各フィールドをスキーマに定義する際にはフィールド種別っぽいものが必要らしい。 ファイル名と、ファイルパスは https://whoosh.readthedocs.io/en/latest/api/fields.html を読むと、whoosh.fields.IDがよいと読めた。

class whoosh.fields.ID(stored=False, unique=False, field_boost=1.0, sortable=False, analyzer=None) Configured field type that indexes the entire value of the field as one token. This is useful for data you don’t want to tokenize, such as the path of a file. Parameters: stored – Whether the value of this field is stored with the document.

全文検索したいテキストを格納するフィールドはwhoosh.fields.NGRAMに入れるよよいようだ。

以下のような感じでスキーマ定義ができる。

# スキーマ定義
schema = whoosh.fields.Schema(
                              # ファイルパスを格納するフィールド
                              path=whoosh.fields.ID(stored=True),
                              # テキストファイルの行を格納するフィールド
                              content=whoosh.fields.NGRAM(stored=True))

# インデックス作成
whoosh.index.create_in(index_dir_path, schema)

インデックスへのファイルの登録

スキーマ定義が終わったら全文検索したいテキストファイルをインデックスに登録する。

# インデックスオープン
ix = whoosh.index.open_dir(index_dir_path)

# ファイル登録
writer = ix.writer()
writer.add_document(
                    # ファイルパス
                    path=unicode(original_file_path, 'utf-8'),
                    # テキストファイルの中の一行
                    content=unicode(content, 'utf-8'))
# コミット
writer.commit(optimize=True)
# indexクローズ
ix.close()

最終的に出来上がったインデックス作成Pythonコード

最終的に出来上がったインデックス作成用Pythonコードは以下の通り。 指定ディレクトリ配下を再帰的に探索し、テキストファイルを一行ずつインデックスに登録している。

create_index.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging


def read_txt_file(file_path):
    """
    テキストファイルを読み込んで返す
    :param file_path: テキストファイルのパス
    :return: テキストファイル(リスト形式で一行を一要素にして返す)
    """
    with open(file_path) as f:
        return f.readlines()


def create_index(index_dir_path):
    """
    インデックスを作成する
    :param index_dir_path:インデックス保存先ディレクトリ
    :return:インデックスオブジェクト
    """

    # スキーマ定義
    schema = whoosh.fields.Schema(# ファイルパスを格納するフィールド
                                  path=whoosh.fields.ID(stored=True),
                                  # テキストファイルの行を格納するフィールド
                                  content=whoosh.fields.NGRAM(stored=True))

    return whoosh.index.create_in(index_dir_path, schema)


def update_index(index_dir_path, target_dir_path):
    """
    テキストファイルを解析し、インデックスに各テキストファイルの情報を反映する
    :param index_dir_path:インデックス保存先ディレクトリ
    :param target_dir_path:全文検索対象のテキストファイル保存先ディレクトリ
    :return:void
    """
    try:
        # インデックスオープン
        ix = whoosh.index.open_dir(index_dir_path)

        # 再帰的にディレクトリを探索し、テキストファイルを解析する
        for root, dirs, files in os.walk(target_dir_path):
            for file_name in files:
                # テキストファイルパス
                text_file_path = os.path.join(root, file_name)

                logging.info("creating index of %s", text_file_path)

                # テキストファイルをリード
                contents = read_txt_file(text_file_path)
                # オリジナルのファイルのパス
                original_file_path = text_file_path.replace(target_dir_path, "/static")

                writer = None
                try:
                    writer = ix.writer()
                    if contents:
                        for content in contents:
                            # 一行ずつ解析してインデックスに登録する
                            writer.add_document(path=unicode(original_file_path, 'utf-8'),
                                                content=unicode(content, 'utf-8'))
                except UnicodeDecodeError:
                    # バイナリファイル等は無視
                    logging.warn("decode error [%s]", text_file_path)
                finally:
                    if writer is not None:
                        writer.commit(optimize=True)
    finally:
        if ix is not None:
            ix.close()


if __name__ == "__main__":
    try:
        logging.getLogger().setLevel(logging.INFO)
        logging.info("start")
        # インデックス保存先ディレクトリ
        index_dir_path = "piyo"

        # 全文検索対象のテキストファイル保存先ディレクトリ
        target_dir_path = "hoge"

        if not os.path.exists(index_dir_path):
            logging.error("index_dir [%s] is not found", index_dir_path)
            print "index_dir is not found."
            exit()
        if not os.path.exists(target_dir_path):
            logging.error("target_dir [%s] is not found", target_dir_path)
            exit()

        # index作成
        create_index(index_dir_path)

        # index更新
        update_index(index_dir_path, target_dir_path)

    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    finally:
        logging.info("end")

検索プログラム & Webアプリケーションの作成

検索プログラムの作成

検索もwhooshのAPIに沿って作成すれば簡単。 今回は"content"というフィールドにテキストファイルを格納しているため、 テキストファイルに対して全文検索したければ、QueryParserの第一引数に"content"を指定してやればいい。

# インデックスオープン
ix = whoosh.index.open_dir(index_dir_path)

# クエリ作成
parser = whoosh.qparser.QueryParser('content', ix.schema)
query = parser.parse(keywords)

# 検索実行(10000件上限)
searcher = ix.searcher()
results = searcher.search(query, limit=10000)

searcher.close()

検索結果は、ファイルのパスと、検索に引っかかった行の組で返ってくるため、 以降で扱いやすくするため、以下のようなコードでファイルパスごとにまとめておく。

"""
ここからこんなマップを作る
key                     ,value
検索に引っかかったファイルパス,[該当行1, 該当行2,...]
"""
result_map = {}

# 検索結果を一つずつ処理する
for result in results:
    # 1ファイルに関して検索に引っかかった行を全て取得
    rows = result_map.get(result['path'])

    if rows is None:
        rows = []
        result_map[result['path']] = rows

    # 検索に引っかかった行をリストに追加
    rows.append(result['content'])

Webアプリケーション

Pythonの軽量WebフレームワークであるFlaskって検索Webアプリケーションを作る。

flaskのインストール

flaskはお決まりのpipでインストールする。

pip install flask

検索受付URL作成

"@app.route"を関数の前につけるだけで簡単に作成可能。 また特定のHTTPメソッドだけ受け付けるようにする設定も簡単にできる。

@app.route('/')
def index():
    """
    トップページを返す
    :return: トップページ
    """
    return render_template('index.html')

@app.route('/search', methods=['POST'])
def post_search():
  """
  全文検索リスエストを処理して結果を返す
  :return: 検索結果
  """
# 中略
# 検索を実行し、検索結果をテンプレートに反映して返す
return render_template('index.html',
                       keywords=keywords,
                       contents=result_html)

# Appスタート
app.debug = True # デバッグモード有効化
app.run(host='0.0.0.0', port=9999)

上記のコード中で使用している"render_template"関数では、テンプレートとして用意しておいたHTMLファイルに動的に値を埋め込むことができる関数である。今回は、以下の検索用のフォームを持たせたHTMLに"keywords"と"contents"という変数を持たせ、そこを動的に書き換えている。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Search System</title>
  </head>
  <body>
{% block content %}
<div class="form">
  <div class="container">
    <div class="row">
      <div class="col-md-12">
        <p class="lead">
          <h2>
          {% if keywords %}
            Search results of {{ keywords }}
          {% endif %}
          </h2>
        </p>
        <form action="/search" method="post" class="form-inline">
          <label for="keywords">keywords</label>
          <input type="text" class="form-control" placeholder="keywords"
                 id="keywords"
                 name="keywords"
                 value={% if keywords %}{{ keywords }}{% endif %}>
          <button type="submit" class="btn btn-default">search</button>
        </form>
      </div>
    </div>
  </div>
</div>
{% endblock %}
{% if contents %}
    {% autoescape false %}
        {{ contents }}
    {% endautoescape %}
{% endif %}
  </body>
</html>

ディレクトリ構成

今回、私が作成したプログラム郡のディレクトリ構成は以下のようにした。 flaskではstaticディレクトリ配下にリソースファイルを格納すると、そのファイルを公開できる。 今回は検索にヒットしたファイルをWebブラウザ経由でダウンロードできるようにしたいのでテキストファイルへ変換前のwordやpdfファイルを格納している。

./search/
├── create_index.py   <- インデックス作成プログラム
├── index_dir         <- インデックス保存先ディレクトリ
├── search_server.py  <- 検索 & Webアプリ
├── static            <- 全文検索対象のファイル格納ディレクトリ(テキストファイルへ変換前のwordやexcel, pdfファイル)
├── target_dir     <- 全文検索対象のテキストファイル格納ディレクトリ
└── templates
    └── index.html    <- HTMLのテンプレート

検索プログラム & Webアプリケーション全体

今回作成した検索プログラム & Webアプリのコード全体は以下の通り。

search_server.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging
from flask import Flask, render_template, request
import sys

# Web Application
app = Flask(__name__)

# 全文検索用のインデックスオブジェクト(Python-Whoosh)
ix = None


@app.route('/')
def index():
    """
    トップページを返す
    :return: トップページ
    """
    return render_template('index.html')


@app.route('/search', methods=['POST'])
def post_search():
    """
    全文検索リスエストを処理して結果を返す
    :return: 検索結果
    """

    if request.method == 'POST':
        # リクエストパラメタ取得
        keywords = request.form['keywords']

        # 全文検索実行
        result_map = exec_search(keywords)

        # 返却するHTML
        result_html = ""
        for path, rows in result_map.items():
            # 検索に引っかかったファイルの名前、パスを出力
            path_str = path.encode('utf-8')

            file_name = path_str
            if "/" in path_str:
                file_name = path_str[path_str.rindex("/")+1:]


            result_html += "<h2><a href=\"{0}\">{1}({2})</a></h2><br>".format(path_str,
                                                                              file_name,
                                                                              path_str)
            result_html += "<ol>"
            for row in rows:
                # 検索に引っかかった行を出力
                result_html += "<li>{0}</li><br>".format(row.encode('utf-8'))
            result_html += "</ol>"

        # 検索を実行し、検索結果をテンプレートに反映して返す
        return render_template('index.html',
                               keywords=keywords,
                               contents=result_html)

def exec_search(keywords):
    """
    全文検索実行
    :param keywords:検索キーワード
    :return 検索結果
    """

    # クエリ作成
    parser = whoosh.qparser.QueryParser('content', ix.schema)
    query = parser.parse(keywords)

    searcher = None
    try:
        # 検索実行(10000件上限)
        searcher = ix.searcher()
        results = searcher.search(query, limit=10000)

        """
        ここからこんなマップを作る
        key                     ,value
        検索に引っかかったファイルパス,[該当行1, 該当行2,...]
        """
        result_map = {}

        # 検索結果を一つずつ処理する
        for result in results:
            # 1ファイルに関して検索に引っかかった行を全て取得
            rows = result_map.get(result['path'])

            if rows is None:
                rows = []
                result_map[result['path']] = rows

            # 検索に引っかかった行をリストに追加
            rows.append(result['content'])

    finally:
        if searcher is not None:
            searcher.close()
    return result_map


if __name__ == "__main__":
    try:
        # UTF-8をデフォルトのエンコーディングに設定
        # これをやらないとHTMLをレンダリングした際にコケる
        reload(sys)
        sys.setdefaultencoding("utf-8")

        # 検索用インデックス読み込み
        index_dir_path = "hoge"
        if not os.path.exists(index_dir_path):
            logging.error("index_dir [%s] is not found", index_dir_path)
            print "index_dir is not found."
            exit()
        ix = whoosh.index.open_dir(index_dir_path)

        # Appスタート
        app.debug = True # デバッグモード有効化
        app.run(host='0.0.0.0', port=9999)

    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    finally:
        if ix is not None:
            ix.close()

まとめ

今回はPythonの軽量WebフレームワークであるFlask全文検索ライブラリであるwhooshを使用して全文検索Webアプリケーションを作った。

また、実用性をあげるために、ただ単にテキストファイルを検索するだけではなくMSオフィスのファイルとPDFファイルも全文検索できるようにした。

作成にかかったトータル時間は1日程度のはず。 個人でもさくっと全文検索Webアプリケーションが書ける時代がきましたね。 OSSってすごい。

参考にしたサイト

http://a2c.bitbucket.org/flask/ http://qiita.com/ynakayama/items/2cc0b1d3cf1a2da612e4 http://d.hatena.ne.jp/rudi/20110420/1303307332 https://pypi.python.org/pypi/Whoosh/