薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

Pythonを定期実行してExcelファイルの中身を確認する

指定したExcelファイル内を検索して、特定のワード(語句)があるかどうかを確認するPythonプログラムを定期的に自動実行するようにしました。共有フォルダなど自分の管理外にあるようなExcelファイルが不定期に更新される場合、自分に必要な情報が載っているかを毎回ファイルを開いて確認するのは面倒です。
そこで、Windowsのタスクスケジューラという機能を使い、1時間おき、1日おきなどの間隔でプログラムを自動実行して、検索したい語句があった場合はデスクトップに通知のウィンドウが表示されるようにしました。

f:id:enokisaute:20210526105634p:plain


環境

  • Windows 10
  • Anaconda3/ Python 3.7
  • openpyxl

Excelファイルを扱うためのopenpyxlはAnacondaであれば最初からインストールされています。
なければ以下のコマンドでインストールしておきます。

pip install openpyxl


アプリケーションの概要

  • Excelファイル内の語句の検索はPythonプログラムで行い、プログラムの定期実行はWindowsのタスクスケジューラを設定して行う。
  • 検索したい語句(複数可)と対象のExcelファイルは事前に指定しておく。
  • 検索語句を見つけた場合は以下のようなウィンドウをデスクトップに出して通知する。

f:id:enokisaute:20210530170759p:plain

  • 一度通知した語句は、タスクスケジューラの実行間隔に関係なく、一定の間隔が経つまで通知しないようにプログラム上で表示間隔を指定できるようにする。

他にも細かいところはありますが、後はやりながら見ていこうと思います。

PythonでExcelファイルを検索する

openpyxlモジュールをインポートして、検索対象のExcelファイルのフルパスをopenpyxl.load_workbook()関数に渡すことでファイルを開いてワークブックを操作することができるようになります。

import openpyxl
 
# excel_file.xlsxがpyファイルと同じディレクトリにある場合
wb = openpyxl.load_workbook('excel_file.xlsx')
ws = wb.worksheets[0]     # ワークシート1枚目を取得

検索の方法は、単純にシートの最大行と最大列までのセルを一つずつ順番に見ていく方法です。もしセル内に指定した(複数の)検索語句のうち一つでもあれば、該当する検索語句をリストとして取得しています。
作成した関数には、開いたワークブックのオブジェクトと検索したい語句のリストを渡してやります。

def search_excel(workbook, search_word):
    notice = []
    ws = workbook.worksheets[0]     # ワークシート1枚目を取得
    # 最大行, 最大列までのセルを対象に1セルずつ検索語句が含まれるかを確認していく
    for row in range(1, ws.max_row+1):
        for col in range(1, ws.max_column+1):
            value = str(ws.cell(row=row, column=col).value)
            word = [w for w in search_word if w in normalize(value)]
            if len(word) != 0:
                info = NoticeInfo(word, ws.cell(row, col).coordinate)
                notice.append(info)
    return notice
 
wb = openpyxl.load_workbook('excel_file.xlsx')
search_word = ['○○太郎', '××花子']    # 検索したい語句のリスト
message = search_excel(wb, search_word)

この関数の戻り値はNoticeInfoというクラスのリスト(見つからなければ空のリスト[])ですが、このクラスは現在時刻、見つけた検索語句、'A1'などのセルの場所をインスタンス変数として持つ以下のようなクラスです。

class NoticeInfo:
    def __init__(self, word, cell):
        self.time = datetime.now()  # 現在時刻
        self.word = word            # 語句
        self.cell = cell            # 'A1'などのセルの場所

    def __str__(self):
        return '{}: {}が{}に見つかりました'.format(
            self.time.strftime('%m/%d-%H:%M'), self.word, self.cell)

    def __eq__(self, other):
        # 検索語句とセルの場所が同じ場合に同じオブジェクトとする
        if isinstance(other, self.__class__):
            return self.word == other.word and self.cell == other.cell

__init__の他に特殊メソッドの__str__と__eq__もオーバーライド(定義)しています。
__str__(self)はオブジェクトの(人間が見てわかりやすい)文字列表現を返します。後でGUIのウインドウ上で表示するため、ここでは現在時刻、語句、セルの場所の情報を示す文字列として返すようにしています。
__eq__(self, other)はselfとotherが等しい(等価、同じ値)場合にTrueを返し、そうでない場合にはFalseを返します。比較演算子'=='を使った「self == other」に対応しています。ここでは、検索語句とセルの場所が同じであれば同じとするようにしました。
これをわざわざ定義したのは、同じセル・同じ検索語句があった場合でも前回表示時との時間差によって表示する・しないと処理を分ける際に、オブジェクトとして同じかどうかを判別する必要があったからです。(後述)

通知メッセージをGUIで表示する

メッセージを通知するGUI部分は標準ライブラリのtkinterを使いました。
プログラムの構成としては、Frameクラスを継承して、部品を配置したり独自の機能を持つメソッドを定義していきます。GUIアプリのベースとなるFrame(ウィンドウ)にWidget(ボタンやリストボックスなどの部品)を組み込んでいくイメージです。後は、Frameオブジェクトのインスタンスを作り、mainloop()というメソッドを呼ぶという流れです。
tkinterでGUIアプリケーションを作る際にはこれが基本的な使い方となるようです。

import tkinter as tk

# 受け取ったメッセージリストを表示するGUIアプリケーション(メソッドの中身は省略)
class NoticeBoard(tk.Frame):
    def __init__(self, msg, master=None):    # 初期化
        # 何らかの処理
    def create_widgets(self):    # GUI部品の構築
        # 何らかの処理
    def show_msg(self, msg):  # リストボックスに表示する
        # 何らかの処理
 
def start_app(msg):
    root = tk.Tk()
    nb = NoticeBoard(msg, master=root)  # Frameのオブジェクトを作る
    nb.mainloop()     # メインループを開始
 
if __name__ == '__main__':
    search_word = ['○○太郎', '××花子']    # 検索したい語句のリスト
    wb = openpyxl.load_workbook('excel_file.xlsx')
    msg = search_excel(wb, search_word)    # ファイルを検索して結果をリストで返す
    start_app(msg):    # 検索結果を渡してアプリケーションを起動する

GUIアプリケーションには以下の機能を持たせます。

  • 受け取ったメッセージリストの要素をウインドウ上のリストボックスに表示する
  • OKボタンを押せばウインドウを閉じる

この部分の処理に複雑なものはありませんが、受け取ったメッセージのリストの要素は複数のこともあれば、まったく検索語句が見つかなければ空のこともあります。また、もしExcelファイルを何らかの理由(たとえば、ファイル名が変わっていた等)で開くことができなかった場合には、その旨をメッセージとして表示するようにしたかったので受け取ったメッセージによっては処理を変えるようにしました。

def check_msg_type(self, msg):
    if isinstance(msg, Exception):    # ファイルを開けなかった場合は以下を表示させる
        msg = ['ファイルを開けませんでした。']
    elif isinstance(msg, list):
        msg = self.update_msg_list(msg)
        if len(msg) == 0:    # 要素が一つもなければアプリケーションを終了する
            self.master.destroy()
    return msg
 
def show_msg(self, msg):
    # msgのtype()をチェック. Exceptionだとファイルを開けない旨のメッセージをmsgとして返す
    msg = self.check_msg_type(msg)    
    for info in msg:    # msgが空[]だと中のinsert文は実行されない
        self.listbox.insert(tk.END, info)
    self.save()


ユーザへの通知間隔を変更可能にする

プログラムが定期実行で1時間おきにファイルを確認してくれるとしても、そのたびに毎回ウィンドウが出てくるのはちょっと目障りです。かといって、定期実行の間隔を伸ばすと小まめにチェックができなくなり、通知されたときには何時間も前に更新されていた、なんてことも起こりそうです。
そこで、「一度表示した検索語句はこちらが指定した時間が経過するまでは再表示しない」という機能を持たせることにしました。
f:id:enokisaute:20210529122434p:plain
これを行うため、一度表示したものと今回取得したオブジェクトが同じものである場合(この判別のためにNoticeInfoクラスの__eq__を定義しました)は時間差が一定以上であるものを表示させるようにしています。
一度表示したものはオブジェクトをpickleで保存しておき、アプリケーション起動時にロードすることで前回表示時と今回取得時の時間を比較できるようにしました。

# NoticeBoard.INTERVAL_SECONDSはクラス変数として定義

def update_msg_list(self, info_list):
    show_list = []
    for info in info_list:  # infoは今回Excelから取得したもの
        if info in self.save_list:
            # 保存リストにあれば前回表示時との時間差を求めて
            # 差がINTERVAL_SECONDS以上のものだけをshow_listに加える
            idx = self.save_list.index(info)
            diff_time = abs(self.save_list[idx].time - info.time)
            if diff_time.seconds >= NoticeBoard.INTERVAL_SECONDS:
                show_list.append(info)
                self.save_list[idx].time = info.time  # 今回表示するので時刻を更新しておく
        else:
            show_list.append(info)       # 過去に表示したことがなければ表示する
            self.save_list.append(info)  # 保存リストにも追加しておく
    return show_list


PythonのGUIプログラムを定期実行する

これでプログラムの方はできたので後はWindowsのタスクスケジューラで定期実行できるように設定していきます。
今回はせっかくGUIのアプリケーションを作ったので、実行時には表示の必要のないコマンドプロンプト(黒い画面)を出さずに定期実行できるようにしたいと思います。

まずは今回作成したプログラムの拡張子を「.pyw」にしておきます。

次に、タスクスケジューラの設定です。こちらのサイトを参考にしました。
#WindowsタスクスケジューラでPythonを走らせる - Qiita

・画面左下Cortanaの検索窓に「タスク」と入力してタスクスケジューラをクリックして起動
・基本タスクの生成をクリック
・名前、タスクの実行頻度等を順に設定していく
こちらの画面まできたところでは以下のように設定します。
f:id:enokisaute:20210529142659p:plain
・最後に完了を押して終了です。

さらに当該タスクのプロパティからいろいろな設定を行うことも可能です。
私は「トリガー」→「編集」で繰り返し間隔を1時間として、毎日1時間おきに定期実行するようにしました。
(注意:設定完了後の状態が"無効"だと実行されません。一度"実行する"で動きを確認しておくと良いと思います。)

これで定期実行でExcelファイルの確認を毎日1時間おきにできるようになりました。手動でしていたときとは違い、チェックを忘れたり遅れたりすることがなくなって良かったと思います。

参考

みんなのPython 第4版
・退屈なことはPythonにやらせよう ―ノンプログラマーにもできる自動化処理プログラミング

import openpyxl
import tkinter as tk
import unicodedata
from datetime import datetime
from pickle import dump, load
 
# データ保存用ファイル名
DUMPFILE = 'save_list.dat'
 
class NoticeInfo:
    def __init__(self, word, cell):
        self.time = datetime.now()  # 現在時刻
        self.word = word            # 語句
        self.cell = cell            # 'A1'などのセルの場所
 
    def __str__(self):
        return '{}: {}が{}に見つかりました'.format(
            self.time.strftime('%m/%d-%H:%M'), self.word, self.cell)
 
    def __eq__(self, other):
        # 検索語句とセルの場所が同じ場合に同じオブジェクトとする
        if isinstance(other, self.__class__):
            return self.word == other.word and self.cell == other.cell
 
 
class NoticeBoard(tk.Frame):
    INTERVAL_SECONDS = 3600 * 24   # 同じ内容の通知間隔は24時間(変更可)
 
    def __init__(self, msg, master=None):
        tk.Frame.__init__(self, master)
        self.save_list = []     # 一度表示したものを保存する
        self.pack()
        self.load()
        self.create_widgets()
        self.show_msg(msg)
 
    def create_widgets(self):
        self.listbox = tk.Listbox(self, height=10, width=40)
        self.yscroll = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.listbox.yview)
        self.xscroll = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.listbox.xview)
        self.button = tk.Button(self, text='OK', command=self.master.destroy)
        # 配置
        self.listbox.grid(row=0, column=0, sticky=tk.NS)
        self.yscroll.grid(row=0, column=1, sticky=tk.NS)
        self.xscroll.grid(columnspan=3, row=2, sticky=tk.EW + tk.S)
        self.button.grid(row=3, column=0)
 
    def show_msg(self, msg):
        msg = self.check_msg_type(msg)
        for info in msg:
            self.listbox.insert(tk.END, info)
        self.save()
 
    def check_msg_type(self, msg):
        if isinstance(msg, Exception):
            msg = ['ファイルを開けませんでした。']
        elif isinstance(msg, list):
            msg = self.update_msg_list(msg)
            if len(msg) == 0:
                self.master.destroy()
        return msg
 
    def update_msg_list(self, info_list):
        show_list = []
        for info in info_list:  # infoは今回Excelから取得したもの
            if info in self.save_list:
                # 保存リストにあれば前回表示時との時間差を求めて
                # 差がINTERVAL_SECONDS以上のものだけを表示用リストに加える
                idx = self.save_list.index(info)
                diff_time = abs(self.save_list[idx].time - info.time)
                if diff_time.seconds >= NoticeBoard.INTERVAL_SECONDS:
                    show_list.append(info)
                    self.save_list[idx].time = info.time  # 今回表示するので時刻を更新しておく
            else:
                show_list.append(info)       # 過去に表示したことがなければ表示する
                self.save_list.append(info)  # 保存リストにも追加しておく
        return show_list
 
    def save(self):
        f = open(DUMPFILE, 'wb')
        dump(self.save_list, f)
 
    def load(self):
        try:
            f = open(DUMPFILE, 'rb')
            self.save_list = load(f)
        except IOError:
            pass
 
 
def start_app(msg):
    root = tk.Tk()
    root.option_add('*font', "Bold 12")
    root.title('お知らせ')
    root.attributes("-topmost", True)
    nb = NoticeBoard(msg, master=root)
    nb.mainloop()
 
 
def search_excel(workbook, search_word):
    notice = []
    ws = workbook.worksheets[0]     # ワークシート1枚目を取得
    # 最大行, 最大列までのセルを対象に1セルずつ検索語句が含まれるかを確認していく
    for row in range(1, ws.max_row+1):
        for col in range(1, ws.max_column+1):
            value = str(ws.cell(row=row, column=col).value)
            word = [w for w in search_word if w in normalize(value)]
            if len(word) != 0:
                # 見つけた語句とセルの場所をリストに加える
                info = NoticeInfo(word, ws.cell(row, col).coordinate)
                notice.append(info)
    return notice
 
 
# 全角半角を正規化してスペースを全て削除する
def normalize(value):
    return unicodedata.normalize('NFKC', value).replace(' ', '')
 
 
if __name__ == '__main__':
    # 検索対象Excelファイルのフルパス
    EXCEL_FILE = 'C:/Users/****/Desktop/excel_file.xlsx'
    # 検索したい文字列のリスト. 複数可. スペースは入れないこと
    SEARCH_WORD = ['○○太郎', '××花子', '△△次郎']
 
    try:
        wb = openpyxl.load_workbook(EXCEL_FILE)
    except Exception as error:
        message = error
    else:
        message = search_excel(wb, SEARCH_WORD)
    finally:
        start_app(message)