🌟

【Python・tkinter】高速ファイル検索アプリの作り方

に公開

はじめに

2年ほど前、Pythonを用いた業務改善アプリケーションの開発を行う機会が多くありました。
近いうちにまた、Pythonによる業務改善アプリ制作を行う予定ですので、復習として、その制作方法を記事にすることにしました。
本記事では、実際に作成した内容をわかりやすくご紹介します。
コードの処理内容を詳しくコメントで記載しておりますので、これからPythonに触れる方にも、参考にして頂けたら幸いです。

今回作成したツールの概要

以前実際に作成し、現場の皆様に使って頂き好評だった「Windows向けの検索アプリ」を、再度ブラッシュアップして作成しようと思います。

  • Windowsのエクスプローラー検索の課題
    • 検索処理が遅い
    • テキストファイルの内容の検索が安定していない
    • 検索の進捗が正確にわかない
  • 本アプリで改善すること
    • 検索処理が劇的に早くなる
    • 全文を正確に検索できる
    • 検索の進捗をプログレスバーで正確に確認できる
    • 結果をCSVで出力し、エクセルで確認できる(文字化けもしない)

アプリの起動と動作

アプリの起動および動作は以下になります。

  1. exeファイルをダブルクリックして起動
  2. フォルダの選択ウィンドウが現れるので、検索対象のフォルダを選択
  3. 検索キーワードを入力して、検索ボタンを押下
  4. 検索開始、プログレスバーで進捗を表示
  5. 検索結果をexeファイルと同階層にCSVで出力

アプリの操作方法は「exeファイルのダブルクリック」「キーワードの入力」のみになるので、シンプルで誰でも使うことができます。

実装内容

それでは実装していきましょう。
以下の順で実装を進めます。

  1. フォルダ選択
  2. 検索キーワード指定
  3. 進捗バー表示(tkinter)
  4. CSV出力

1.フォルダ選択

folder = filedialog.askdirectory(title="検索フォルダ選択")  # フォルダ選択ダイアログを出す
if not folder:  # フォルダが選ばれなかった(キャンセルされた)場合
    return  # プログラムを終了する

tkinter.filedialog.askdirectory で、ディレクトリを選択するためのダイアログを表示します。
フォルダが選ばれなかった場合を想定して、エラー回避の処理も入れておきます。

2.検索キーワード指定

class KeywordWindow(tk.Toplevel):
    def __init__(self, master, target_dir):
        super().__init__(master)
        self.target_dir = target_dir  # 検索対象のフォルダを記録

        self.title("検索キーワード入力")  # 窓のタイトル
        self.geometry("350x150")  # 窓のサイズ

        tk.Label(self, text="検索文字列").pack(pady=10)  # 説明テキストを表示
        self.entry = tk.Entry(self, width=30)  # テキスト入力欄を作成
        self.entry.pack()  # テキスト入力欄を表示

        # 検索ボタンを作る。押されたら start_search 関数を実行
        tk.Button(self, text="検索", command=self.start_search).pack(pady=15)

先ず、クラス KeywordWindow を定義し、init メソッド(初期化処理)の中で、ユーザーに見える画面を作ります。
Pythonのデスクトップアプリ作成ライブラリ「Tkinter」を使って、検索キーワードを入力する画面を表示します。

def start_search(self):
    keyword = self.entry.get()  # 入力欄に書かれた文字を取得する
    if not keyword:  # 文字が空かどうか判定
        messagebox.showwarning("警告", "検索文字列を入力してください")  # 注意を出す
        return

    self.destroy()  # 必要なくなるので、ここで入力画面を消す

    progress = ProgressWindow(self.master)  # 進捗表示用の窓を表示する

    # 検索処理を別スレッドで開始する
    thread = threading.Thread(
        target=search_files,  # 実行する関数
        args=(self.target_dir, keyword, progress),  # 関数に渡す値
        daemon=True  # アプリを閉じたらこの処理も一緒に終わるようにする設定
    )
    thread.start()  # 処理を開始

次に、検索ボタン押下後の処理を行う関数 start_search を定義します。
このコードのポイントとして、検索処理の関数 search_files を、別スレッドで処理しています。
別スレッドで処理せずに、この場所で関数を実行してしまうと、検索処理が完了するまで画面がフリーズしてしまい、進捗バーを表示することができません。
また、 daemon=True を指定することで、アプリ本体が閉じられた際に、裏で動いている検索処理も強制終了することができます。

3.進捗バー表示

class ProgressWindow(tk.Toplevel):
    def __init__(self, master):
        super().__init__(master)  # 親ウィンドウの設定を引き継ぐ
        self.title("検索中")  # 窓のタイトルを設定
        self.geometry("400x120")  # 窓のサイズを設定
        self.resizable(False, False)  # ユーザーが窓の大きさを変えられないようにする

        self.bar = ttk.Progressbar(self, length=360)  # 進捗バーの作成
        self.bar.pack(pady=20)  # バーを画面に配置する(上下に余白20)

        self.label = tk.Label(self, text="0 / 0 (0%)")  # 「何個終わったか」を表示する文字ラベルを作る
        self.label.pack()  # ラベルを画面に配置する

クラス ProgressWindow を定義し、検索キーワード入力時と同じように、init メソッド(初期化処理)の中で、ユーザーに見える画面を作ります。

def set_max(self, max_value):
    self.bar["maximum"] = max_value  # バーの最大値をセットする

関数 set_max で、検索対象のファイルの個数を受け取り、バーの最大値をセットします。

def update_progress(self, value):
    percent = (value / self.bar["maximum"]) * 100  # 進捗率(%)を計算する
    self.bar["value"] = value  # バーのゲージを現在の値まで動かす
    self.label.config(
        text=f"{value} / {int(self.bar['maximum'])} ({percent:.1f}%)"
    )  # ラベルの文字を最新の数字に書き換える
    self.update_idletasks()  # 画面表示を即座に更新する

関数 update_progress で、現在「何個目」まで終わったかを受け取り、パーセントを計算します。
計算したパーセントをもとに、進捗バーの状態を更新し、受け取った個数でテキストラベルを更新します。
Tkinterは処理が重くなると画面の描画を後回しにしてしまうので、画面がフリーズして見えてしまうことがあります。これを防ぐために、self.update_idletasks() を実行し、強制的に画面を更新します。

def finish(self):
    self.after(300, self.destroy)  # 0.3秒待ってからこの窓を閉じる
    messagebox.showinfo("完了", "検索が完了しました")  # 完了メッセージを表示

関数 finish で、検索処置を終了します。
終了する際、ウィンドウを閉じる前に、0.3秒処理を止めています。
この処理を入れないと、進捗バーが100%の状態が表示されずに画面が閉じてしまいます。

4.CSV出力

def search_files(target_dir, keyword, progress_window):
    files = []  # 見つかったファイルの一覧を入れるためリスト
    for root, _, fs in os.walk(target_dir):  # 指定されたフォルダの中身をサブフォルダまで全てスキャンする
        for f in fs:  # 見つかったファイルの数だけループ
            files.append(os.path.join(root, f))  # ファイルのフルパスをリストに追加する

    total = len(files)  # ファイルの総数を取得
    progress_window.set_max(total)  # プログレスバーの最大値に総数を指定

2.検索キーワード指定 で別スレッドで実行した関数 search_files です。
検索対象のディレクトリにある全てのファイルのリストを作成します。
リスト作成後に取得できるファイルの総数で、プログレスバーの最大値を指定します。

    # 書き込み用のCSVファイルを開く(utf-8-sigはExcelで開いても文字化けしないための設定)
    with open(OUTPUT_FILE, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)  # CSVに書き込むためのオブジェクトを定義
        writer.writerow(["file_path", "line", "column"])  # 1行目に見出しを書き込む

        for i, path in enumerate(files, start=1):  # ファイルリストを1つずつ取り出す
            progress_window.update_progress(i)  # 画面のプログレスバーを更新する

            # バイナリファイルは除外する
            if is_binary_file(path):
                continue

            try:
                # ファイルを読み込みモードで開く
                with open(path, "r", encoding="utf-8", errors="ignore") as rf:
                    for line_no, line in enumerate(rf, start=1):  # ファイルの中身を1行ずつ読み込む
                        col = line.find(keyword)  # その行にキーワードが含まれているか探す
                        if col != -1:  # -1以外ならキーワード発見
                            normalized_path = os.path.normpath(path)  # パスをOSに合わせて整える
                            writer.writerow([normalized_path, line_no, col + 1])  # パス、行番号、列番号をCSVに書く
            except Exception:  # 万が一ファイルが開けなかった場合(権限不足など)は
                pass  # そのファイルは無視して次に進む

    progress_window.finish()  # 全ての処理が終わったら終了通知を出す

先程作成したファイルリストをもとに検索を行います。
検索キーワードを含むテキストファイルを発見した場合、そのファイルパスと検索キーワードの出現位置をCSVに書き出します。
open関数に errors="ignore" を指定することで、エラーで処理を停止させず、問題のある行は読み飛ばされます。
また、検索対象がバイナリファイルだった場合を想定して、以下の関数 is_binary_file で判定を行い、除外します。

def is_binary_file(path, check_size=4096):
    try:
        # ファイルを「バイナリ読み込みモード ("rb")」で開く
        with open(path, "rb") as f:
            # ファイルの先頭データを4KBだけ読み込む
            chunk = f.read(check_size)
            
            # 読み込んだバイト列の中に「ヌル文字 (\x00)」が含まれているか確認
            if b"\x00" in chunk:
                return True
                
    except Exception:
        # エラー発生時もバイナリデータと判定
        return True

    # ヌル文字が見つからなかった場合は、テキストファイルとみなして False を返す
    return False

チェック対象のファイルをバイナリ読み込みモードで開き、先頭から4KBのみ読み込み、ヌルが含まれている場合はバイナリファイルと判定します。
一般的に、テキストファイルにヌル文字が含まれることは稀であり、含まれている場合は実行ファイルや画像などのバイナリファイルと判断できます。

検索が完了すると、以下の形式でCSVファイルが書き出されます。

ファイルパス,出現位置行番号,出現位置列番号
ファイルパス,出現位置行番号,出現位置列番号
ファイルパス,出現位置行番号,出現位置列番号

まとめ

最終的なコードは以下になります。

import os
import csv
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk

OUTPUT_FILE = "result.csv"

def is_binary_file(path, check_size=4096):
    try:
        with open(path, "rb") as f:
            chunk = f.read(check_size)
            if b"\x00" in chunk:
                return True
    except Exception:
        return True
    return False

def search_files(target_dir, keyword, progress_window):
    files = []
    for root, _, fs in os.walk(target_dir):
        for f in fs:
            files.append(os.path.join(root, f))

    total = len(files)
    progress_window.set_max(total)

    with open(OUTPUT_FILE, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow(["file_path", "line", "column"])

        for i, path in enumerate(files, start=1):
            progress_window.update_progress(i)

            if is_binary_file(path):
                continue

            try:
                with open(path, "r", encoding="utf-8", errors="ignore") as rf:
                    for line_no, line in enumerate(rf, start=1):
                        col = line.find(keyword)
                        if col != -1:
                            normalized_path = os.path.normpath(path)
                            writer.writerow([normalized_path, line_no, col + 1])
            except Exception:
                pass

    progress_window.finish()

class ProgressWindow(tk.Toplevel):
    def __init__(self, master):
        super().__init__(master)
        self.title("検索中")
        self.geometry("400x120")
        self.resizable(False, False)

        self.bar = ttk.Progressbar(self, length=360)
        self.bar.pack(pady=20)

        self.label = tk.Label(self, text="0 / 0 (0%)")
        self.label.pack()

    def set_max(self, max_value):
        self.bar["maximum"] = max_value

    def update_progress(self, value):
        percent = (value / self.bar["maximum"]) * 100
        self.bar["value"] = value
        self.label.config(
            text=f"{value} / {int(self.bar['maximum'])} ({percent:.1f}%)"
        )
        self.update_idletasks()

    def finish(self):
        self.after(300, self.destroy)
        messagebox.showinfo("完了", "検索が完了しました")

class KeywordWindow(tk.Toplevel):
    def __init__(self, master, target_dir):
        super().__init__(master)
        self.target_dir = target_dir

        self.title("検索キーワード入力")
        self.geometry("350x150")

        tk.Label(self, text="検索文字列").pack(pady=10)
        self.entry = tk.Entry(self, width=30)
        self.entry.pack()

        tk.Button(self, text="検索", command=self.start_search).pack(pady=15)

    def start_search(self):
        keyword = self.entry.get()
        if not keyword:
            messagebox.showwarning("警告", "検索文字列を入力してください")
            return

        self.destroy()

        progress = ProgressWindow(self.master)

        thread = threading.Thread(
            target=search_files,
            args=(self.target_dir, keyword, progress),
            daemon=True
        )
        thread.start()

def main():
    root = tk.Tk()
    root.withdraw()

    folder = filedialog.askdirectory(title="検索フォルダ選択")
    if not folder:
        return

    KeywordWindow(root, folder)
    root.mainloop()

if __name__ == "__main__":
    main()

今回は省略しますが、Pyinstallerを使ってexeファイルを作成することで、社内での配布も可能になります。
今回はPythonの基本的な機能のみを使ってアプリを作成しましたが、必要に応じて、GUIライブリを使ったアプリ作成も行えたらと思っております。
記事をご覧頂きありがとうございました。

91works Tech Blog

Discussion