俺の備忘録

個人的な備忘録です。

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

サマリ

前回(Flaskとwhooshで簡単全文検索Webアプリケーション)は、Flaskとwhooshで全文検索Webアプリケーションを作成した。 ただし、前回作成したものは実運用するにはまだまだ改善すべき点があった。
(特に前回その辺には触れなかったが...)
今回は前回作ったものを、もう少し実運用に耐えられるようにいくらか改善みた。
(エラー処理は相変わらず適当ではあるが...)

改善すべき点/前回の問題点

その1

全文検索用のインデックスに登録するテキストデータを無駄にディスクに保存していた。 何を言っているかというとwhooshのインデックスに登録したいファイルを、元のディレクトリから全部コピーしていたため、ディスクの容量を2倍食う状態であった。 MSオフィスやPDFファイルはインデックス登録のために、一度テキスト変換する必要はあるが、 インデックス登録時にだけあればよいのでディスク上に保持しておく必要はない。

その2

全文検索の対象のファイルが更新されるケースについて対応してなかった。 そのため、前回までの状態だとファイルが更新された場合、一度インデックスを全て削除して作り直しが必要であった。 また、インデックスの更新を定期的に自動実行する作りこみをしていなかったため、 インデックスを更新したい場合、手動でコマンド叩いたり更新作業をする必要があった。

改善内容

ほとんど、上で書いた通りだが、

  • インデックス登録用のファイルを2重で持たないようにした。具体的には、インデックス登録用の中間ファイル(テキストファイル)は作成せずに、pythonプログラムの中からインデックス登録の際にオリジナルのファイルを読み込むようにした。MSオフィスのファイルとPDFのファイルは、テキストを抽出するjavaのプログラムをpythonからキックし、結果を標準出力で受け取るようにした。

  • インデックスの自動更新対応 1時間間隔でインデックスを自動更新できるようにした。ファイルのタイムスタンプを見て前回インデックス登録時からタイムスタンプが変更されていたら更新するようにした。 なお、ファイルが消されるケースは業務では少なさそうなため、まだ非対応。

コード

前回から差分があるのは一部でjavaコードと、インデックスPythonコード(create_index.py)だけである。 Webアプリケーション部分やディレクトリ構成等は変わらないため、前回(Flaskとwhooshで簡単全文検索Webアプリケーション)、を参照のこと。

以下は改良後のjavaコード。MSオフィスとPDFファイルからテキストを抽出し、標準出力に出力する。jarファイル化してPythonコードから呼び出す。

Extractor.java

import java.io.File;
import java.io.IOException;
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;

/**
 * 全文検索用にファイルをテキストファイルに変換するクラス。
 * PDFとMSオフィスのファイルからテキストを抽出し、標準出力に出力する
 */
public class Extractor {

    /**
    * 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");
    }

    public static void main(String[] args) {
        String filePath = args[0];

        // テキスト抽出
        Extractor.extractText(new File(filePath));
    }

    /*
    * ファイルからテキストファイルを抽出して標準出力に出力する
    * @param file テキスト抽出元ファイル
    */
    private static void extractText(File file) {
        // 拡張子取得
        String suffix = Extractor.getSuffix(file.getName());
        if (suffix != null) {
            if (MS_OFFICE_SUFFIX_LIST.contains(suffix)) {
                // MSオフィスの場合
                Extractor.getOfficeText(file);
            } else if("pdf".equals(suffix)) {
                // PDFの場合
                Extractor.getPdfText(file);
            }
        }
    }

    /**
    * MSオフィスのファイルからテキストを抽出し、標準出力に出力する
    * @param file MSオフィスのファイル
    */
    private static void getOfficeText(File file){
        POITextExtractor textExtractor = null;
        try {
            textExtractor = ExtractorFactory.createExtractor(file);
            System.out.println(textExtractor.getText());
        } catch (Exception e) {
            System.err.println("failed to extract from " + file.getAbsolutePath());
        } finally {
            if (textExtractor != null) {
                try {
                    textExtractor.close();
                } catch (IOException e) {
                }
            }
        }
    }

    /**
    * PDFからテキストを抽出し、標準出力に出力する
    * @param file PDFファイル
    */
    private static void getPdfText(File file) {
        PDDocument document = null;
        try {
            document = PDDocument.load(file);
            System.out.println(new PDFTextStripper().getText(document));
        } catch (IOException e) {
            System.err.println("failed to extract from " + file.getAbsolutePath());
        } finally {
            if (document != null) {
                try {
                    document.close();
                } catch (IOException e) {
                }
            }
        }
    }

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

以下が改良したインデックス登録pythonコード。1時間おきにインデックスの更新をするようになった。また、javaのコマンドを叩いてMSオフィスとPDFファイルからテキストを抽出するようになった。

create_index.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging
import datetime
import time
from subprocess import Popen, PIPE

# インデックス保存先ディレクトリ
INDEX_DIR = "index_dir"

# 検索対象のファイル配置先ディレクトリ
TARGET_DIR = "static"

# インデックスオブジェクト
INDEX = None

# インデックスの更新間隔
UPDATE_INTERVAL = 3600


def exec_cmd(cmd):
    """
    外部コマンド実行を実行して結果を標準出力で得る
    :param cmd:コマンド
    :return: コマンドの実行結果
    """
    p = Popen(cmd, stdout=PIPE, stderr=PIPE)
    out, err = p.communicate()
    if err and err is not "":
        logging.warn(err)
    return [s for s in out.split('\n') if s]


def get_txt(file_path):
    """
    ファイルを読み込んでテキストに変換して返す
    :param file_path: ファイルパス
    :return: テキストファイル(リスト形式で一行を一要素にして返す)
    """

    # 拡張子取得
    file_name = file_path
    if "/" in file_path:
        file_name = file_path[file_path.rindex("/")+1:]

    suffix = ""
    if "." in file_name:
        suffix = file_name[file_name.rindex("."):]

    # 対象ファイル種別でなかった場合は処理しない
    if suffix not in [".txt",
                      ".html", ".xml", ".mxml", ".htm",
                      ".sh", ".bat", ".ps1",
                      ".py", ".rb", ".c", ".as", ".js", ".java", ".h", ".cpp",
                      ".xls", ".xlsx", ".doc", ".docx", ".ppt", ".pptx",
                      ".pdf"]:
        return None

    # MSオフィス/PDFの場合はテキストを抽出
    if suffix in [".xls", ".xlsx", ".doc", ".docx", ".ppt", ".pptx", ".pdf"]:
        return exec_cmd(["java", "-jar", "Extractor.jar", file_path])

    # 普通のテキストファイル等
    with open(file_path) as f:
        return f.readlines()


def get_last_modified(file_path):
    """
    ファイルの最終更新時刻を返す
    :param file_path: ファイルパス    :return: ファイルの最終更新時刻を示す文字列
    :return: ファイルの最終更新時刻
    """
    last_modified = os.stat(file_path).st_mtime
    return datetime.datetime.fromtimestamp(last_modified)


def create_index(index_dir):
    """
    インデックスを作成する
    :param index_dir インデックス保存先ディレクトリ
    """

    # インデックスがまだ作成されていない場合のみインデックスを作成
    if len(os.listdir(index_dir)) == 0:

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

        whoosh.index.create_in(INDEX_DIR, schema)


def get_registered_last_modified(file_path, index):
    """
    インデックスに登録されているファイルの最終更新時刻を取得
    :param file_path: ファイルパス
    :param index: インデックスオブジェクト
    :return:インデックスに登録されているファイルの最終更新時刻
    """
    # クエリ作成
    parser = whoosh.qparser.QueryParser('path', index.schema)
    query = parser.parse(unicode(file_path, 'utf-8'))
    searcher = None
    try:
        # 検索実行
        searcher = index.searcher()
        results = searcher.search(query, limit=1)
        for result in results:
            return result['last_modified']
        return None
    finally:
        if searcher is not None:
            searcher.close()


def delete_document(file_path, index):
    """
    インデックスからfile_pathに指定したドキュメントを削除する
    :param file_path: ファイルパス
    :param index: インデックスオブジェクト
    :return:void
    """

    # 前回インデックス作成時から更新されている場合は、一度インデックスから削除する
    writer = None
    count = 0
    try:
        writer = index.writer()
        writer.delete_by_term('path', file_path)
        count += 1
    finally:
        if writer is not None and count > 0:
            writer.commit(optimize=False)


def add_document(file_path, last_modified, index):
    """
    インデックス登録
    :param file_path:ファイルパス
    :param last_modified:ファイル更新時刻
    :param index:インデックスオブジェクト
    """

    # ファイルの中身をリード
    contents = get_txt(file_path)

    writer = None
    count = 0
    try:
        writer = index.writer()
        if contents:
            for content in contents:
                # 一行ずつ解析してインデックスに登録する
                writer.add_document(path=unicode(file_path, 'utf-8'),
                                    last_modified=last_modified,
                                    content=unicode(content, 'utf-8'))
                count += 1
        if count == 0:
            logging.warn("No data was registered. (path=%s)", file_path)
    except UnicodeDecodeError:
        # インデックス登録失敗。バイナリ等が混ざっている。
        logging.warn("Decode error. Creating Index failed. (path=%s)", file_path)
    finally:
        if writer is not None and count > 0:
            writer.commit(optimize=False)


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

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

                # シンボリックリンクの場合飛ばす
                if os.path.islink(file_path):
                    continue

                logging.info("Creating index of %s", file_path)

                # ファイルの最終更新時刻を取得
                last_modified = get_last_modified(file_path)

                # インデックスに登録されているファイルの最終更新時刻を取得
                registered_last_modified = get_registered_last_modified(file_path, index)

                # 最終更新時刻が更新されていた場合インデックスから削除
                if registered_last_modified is not None and last_modified > registered_last_modified:
                    delete_document(file_path,  index)

                # インデックスに登録
                if registered_last_modified is None or last_modified > registered_last_modified:
                    add_document(file_path, last_modified, index)
    finally:
        if index is not None:
            index.close()
        logging.info("Updating index end.")


if __name__ == "__main__":
    try:
        logging.getLogger().setLevel(logging.INFO)
        logging.info("start")

        if not os.path.exists(INDEX_DIR):
            logging.error("Directory [%s] is not found", INDEX_DIR)
            exit()
        if not os.path.exists(TARGET_DIR):
            logging.error("Directory [%s] is not found", TARGET_DIR)
            exit()

        # index作成
        create_index(INDEX_DIR)

        while True:
            # index更新
            update_index(INDEX_DIR, TARGET_DIR)

            # スリープ
            time.sleep(UPDATE_INTERVAL)

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

追記(2016/10/10)

  • PDFからのテキスト抽出はpdftotextコマンド使ったほうが楽。
  • ファイルパスに日本語が含まれていたりする場合で、get_registered_last_modified関数でパス名でうまく検索できないことがある。whooshのスキーマにパスをハッシュ化したものを追加登録することで対応可能。