見出し画像

「デジタル普通預金通帳」を作ってみる。

前提の考え

①  極力入力を減らすことと、チェックが簡単であること
・ファイル名や条件設定の入力は避ける。
・記帳した「普通預金通帳」と同じように残高も計算しエクセル上に集計する
② データの再利用が可能であること
・振替伝票用のデータを生成するためにデータを利用する。
このページでは①までを説明します。

PDFから必要なTextデータを抜き出す

みずほ銀行では「みずほビジネスWEB」のサービス提供をしています。契約すると、期間指定で入出金情報を取得できます。ただし、このPDFには日々の残高は記載されていません。また別に、「メールでのお知らせサービス」があり「契約口座に入出金がございましたのでご連絡いたします。」のメールが、PDFファイルを添付して送られてきます。メール添付のPDFファイルは、1日の取引単位で残高も記載されています。
これらのPDFファイルには、契約時に登録した「パスワード」が設定されています。
Python から「パスワード」が設定されPDFファイルを操作できるモジュールは《 PyMuPDF 》だけなようです。PyMuPDF では座標指定でデータを取得できないので、取得した全textデータを解析して利用する必要があります。
インポートは"fitz"です。モジュールの名前はPyMuPDFですが、 import fitz で呼び出します。

生成されるエクセル表は「普通預金通帳」と見映えを同じようし、さらに実行時の入力を減らす。

見映えを同じようにしたいのは「チェック時間の短縮」が目的です。

必要な要件

・複数のPDFファイルを読み込むこと。
・重複データを避けること。
・対象となるデータを選べること。
・残高を計算して表示すること。
・エクセルファイルに記載した付加情報は消さない(上書きしない)こと。

実現方法

・経理上は1ヶ月単位での集計で良いと考えエクセルのテンプレートを作成し、指定したcellにデータと計算式を書き込む方式を採用しました。
エクセルのテンプレートです。

エクセル・テンプレートの説明

ファイル名は、「202401月普通預金.xlsx」の形式にしています。指定のファオルダー('./pdf 普通預金exl/')にファイルを保存します。ファイル名に「普通預金」と「.xls」が含まれるファイル名のみを対象にしています。したがって、この文字が含まれていれば問題ありません。処理月のファイルだけですので1ファイルです。
'A2' に 月の初めを 2024/01/01 のように入力します。この日付をPython で読み対象月(この場合1月)と月末(31日)を計算しています。
'A4' は 計算する対象ではありません。確認した前月の最終日を入れています。
'D4' は前月末の残高を入力します。(テンプレートには100を入れておきました)
Python からは、5行目以下に、 [日付、引出金額、預入金額、残高、取引内容] の項目のデータが上書きされます。
[コード、科目名、補助コード、補助科目] は、会計用のデータを生成するための項目です。この項目は上書きされません。今回の説明では省きます。

プログラムの説明

複数のPDFファイルを読み込む

・指定したフォルダー('pdf_data')にみずほ銀行からのPDFファイルを入れます。Webから落としたPDFファイルでもメールで届いたPDFファイルでも正しく読めました。(取得した全ファイルでチェックしたらエラーが発生しました。対処策は後に書きました。以下のプログラムはトラブル前のものです。
・パスワードを指定し、複数ファイルのデータを順次読み込みます。

folder_path = 'pdf_data'
# フォルダ内のすべてのファイルを取得します
file_list = os.listdir(folder_path)
# import os の機能を利用して file_listを作ります。
file_list = [filename for filename in file_list if not (filename.startswith('.') or '~$' in filename)]
# 隠しファイルなどを取り除く
# フォルダ内の各エクセルファイルに対して処理を行います
for file_name in file_list:
    # if not ('.pdf' in file_name and 'みずほ' in file_name):
    # 対象となるエクセルファイルを選択 Win では  'みずほ' は  'みす"ほ'になるようです。
    # ファイル名の日本語はプログラム内では使わない方が良いようです。
    if not ('.pdf' in file_name):  # 対象となるPDFファイルを選択
        print(f"対象 以外 : {file_name}")
        continue

    else:
    	# ここから処理

PDFから必要なtextデータを抽出し、不要な文字を削除し、使用可能なデータに成形する。

・一つのPDFファイルから全てのtextデータを一括して取得します。
print()文では制御文字が変換されてしまい、データの解析が難しくなります。
データの正規化(データを扱いやすいように綺麗にする意味で使っています)を行うため、制御文字も含んでも表示させるために、pdf_txt_listに追加(append)してprint()文で表示できるようにしています。

test_txt = extracted_text
pdf_txt_list.append(test_txt)
 print(pdf_txt)  # 正規化する対象データの表示 

正規化のコードの '\u3000' はWindows の空白文字です。

extracted_text = extracted_text.replace('\u3000','')

・全てのPDFのtextデータを1行にして区切り文字("\n")を要素の区切り文字と考え1次元リストpdf_1D_listを作成します。
・作成した1次元リストpdf_1D_listから要素ごとの2次元リストpdf_2D_listを作成します。

普通預金の明細データを、2次元リスト(yokin_list)に生成します。

「pdf_2D_listと全く同じpdf_2D_list_copyを作成しておき、pdf_2D_listをループして読みながら、マッチしたデータを新たにdata_itemに生成し、yokin_listに加えてゆく方式」です。
・yokin_listの1行の要素(data_item)は、[番号(日付+枝番)、日付、出金額、入金額、残高、取引内容]としました。
・入金項目の(1)'振込'と出金項目の(2)'振替支払'と(3)'現金'に一致したデータを取得した時のindex番号を基準にpdf_2D_list_copyから対応するデータを取得しています。
・番号は日付を数字の列に変換し、枝番号(3桁)を追加して生成しています。
同一データと判断する基準と(枝番が無いと同日の「フリコミテスウリヨウ」が金額が同じの場合には、同一データと判断され削除されます)、預金通帳の記帳と同じように表示するためです。
・同じPDFファイルが存在し二重のデータがある可能性があるので
data_list = [list(t) for t in set(tuple(row) for row in yokin_list)]
で二重データを削除しdata_list を作ります。その後で、yokin_list[0](番号)でソートします。

記帳した「普通預金通帳」と同じようにエクセル上で残高を集計する

・テンプレートのエクセルファイルを、指定のファオルダー('./pdf 普通預金exl/')に保存し、このファイルを読み込み、処理対象月の1日目と月末の計算をしています。2024/01/01の場合には20240101に000を加えた数になります。月末は999を加えた20240131999になります。data_lisutの範囲指定を行い新たにfiltered_listを作成しています。

filtered_list = [sublist for sublist in data_list if int(sublist[0]) >= int(ymd) and int(sublist[0]) <= int(ymd_end)]

・A列からE列までデータを書き込みます。
worksheet.cell(ii + 3, column=4).value = "=D" + str(ii+2) + "-B" + str(ii+3) + "+C" + str(ii+3)
のようにエクセルに計算式も書き込めます。大変便利です!

プログラム

import fitz
# pip install PyMuPDF で fitz が使えます。
import os
import datetime
import openpyxl


pdf_1D_list = []
pdf_1D_txt = ''
pdf_txt_list = []  # 取得の生データを見るため
def extract_text_from_password_protected_pdf(pdf_path, password):
    extracted_text = ""

    # PDFファイルを開く
    pdf_document = fitz.open(pdf_path)
    # パスワードが必要な場合は設定
    if password != "":
        pdf_document.authenticate(password)

    # 全ページのテキストを抽出
    for page_num in range(pdf_document.page_count):
        page = pdf_document.load_page(page_num)
        extracted_text += page.get_text()
        #print(page)

    # PDFを閉じる
    pdf_document.close()

    return extracted_text

def calculate_month_end(start_date):
    # 月の最終日を計算 データの範囲(1ヶ月)を指定するため 月末を計算する。
    year = start_date.year
    month = start_date.month
    # 月の次の月の最初の日を計算
    if month == 12:
        year += 1
        next_month = 1
    else:
        next_month = month + 1
    # 月末を計算
    end_date = datetime.date(year, next_month, 1) - datetime.timedelta(days=1)

    return end_date

folder_path = 'pdf_data'
# みずほからのPDF データ
# フォルダ内のすべてのファイルを取得します
# 重複するデータがあっても良いように行う。
file_list = os.listdir(folder_path)
# import os の機能を利用して file_listを作ります。

#print(file_list)
file_list = [filename for filename in file_list if not (filename.startswith('.') or '~$' in filename)]
# 隠しファイルなどを取り除く

# フォルダ内の各エクセルファイルに対して処理を行います
for file_name in file_list:
    # if not ('.pdf' in file_name and 'みずほ' in file_name):
    # 対象となるエクセルファイルを選択 Win では  'みずほ' は  'みずほ'になるようです。
    # ファイル名の日本語はプログラム内では使わない方が良いようです。
    if not ('.pdf' in file_name):  # 対象となるPDFファイルを選択

        print(f"対象 以外 : {file_name}")
        continue

    else:

        pdf_path = './' + folder_path + '/' + file_name
        password = 'XXXX'  # ここにパスワードを入れます
        # print(pdf_path)
        # 関数の呼び出し
        extracted_text = extract_text_from_password_protected_pdf(pdf_path, password)
        #test_txt = extracted_text
        #pdf_txt_list.append(test_txt)
        #print(pdf_txt_list)  # 正規化する対象データの表示
        #print('++',extracted_text)
        # データの正規化
        extracted_text = extracted_text.replace(',','')
        extracted_text = extracted_text.replace(' ','')
        extracted_text = extracted_text.replace('\u3000','')

        # ファイルが複数あった場合の対処策 全てのデータを extracted_text に
        pdf_1D_txt += extracted_text

    # 文字列を\nで分割し、空白文字を削除して要素が一つの一次元リストを作成 
    pdf_1D_list = [line for line in pdf_1D_txt.split('\n') if line.strip()]

#print('pdf_1D_list', pdf_1D_list)
# 結果の表示
#print(pdf_txt_list)  # 正規化する対象データの表示


pdf_2D_list = []
pdf_2D_list_copy = []

# 対象データを、マッチすることが簡単にできることを目的に二次元リスト化をしています。 
# # 一次元リストを要素ごとに分割して二次元リストに展開
for i in pdf_1D_list:  # 要素ごとに切り出す。 
    one_line = [None]
    one_line[0] = str(i)
    pdf_2D_list.append(one_line)
    #print(i)

# 関連する対象データを取得するため、同じリストを作りindexを基準に同じ二次元リストの値を取得
# '振込' が出てきたら関連のデータをコピーしたListのpdf_2D_list_copy から値を取得するため

pdf_2D_list_copy = pdf_2D_list

print('+++++++++++')
yokin_list = []
for index, row in enumerate(pdf_2D_list):  # index を取得するため

    # print(f'Index {index}: {row}')
    if row[0] == '振込':  #
        data_item = [None, None, 0, 0, None]
        #print(f'Index {index}: {row}')
        date_yokin = pdf_2D_list_copy[index + 2][0]
        date_yokin = (date_yokin.replace('年', '-').
                      replace('月', '-').replace('日', ''))
        branch_No = pdf_2D_list_copy[index + 4][0]
        date_1 = date_yokin.replace('-', '') + branch_No
        # 入金の順番(明細行)を取得できないと表示できない!

        #print('= 1=', date_1, branch_No)

        tekiyou = pdf_2D_list_copy[index + 1][0]  # 適用欄
        kingaku = pdf_2D_list_copy[index + 5][0]  # 入金額

        #print(date_1, date_yokin, tekiyou, kingaku)
        data_item[0] = date_1  # 日付+ 明細番号 *重複データを排除するのに必要
        data_item[1] = date_yokin  # 日付
        data_item[3] = int(kingaku)  # 入金額
        data_item[4] = tekiyou  # 適用 
        yokin_list.append(data_item)
        continue

    if row[0] == '振替支払':
        data_item = [None, None, 0, 0, None]
        #print(f'Index {index}: {row}')
        # 明細データが頭の3文字になり、入金と出金で異なる
        date_yokin = pdf_2D_list_copy[index + 2][0]
        branch_No = date_yokin[:3]
        date_yokin = date_yokin[3:]
        date_yokin = (date_yokin.replace('年', '-').
                      replace('月', '-').replace('日', ''))
        date_1 = date_yokin.replace('-', '') + branch_No
        #print('= 2=', date_1)

        tekiyou = pdf_2D_list_copy[index + 1][0]
        kingaku = pdf_2D_list_copy[index + 3][0]

        print(date_1, date_yokin, tekiyou, kingaku)
        data_item[0] = date_1
        data_item[1] = date_yokin
        data_item[2] = int(kingaku)  # 出金金額
        data_item[4] = tekiyou
        yokin_list.append(data_item)
        continue

    if row[0] == '現金':
        data_item = [None, None, 0, 0, None]
        #print(f'Index {index}: {row}')
        date_yokin = pdf_2D_list_copy[index + 2][0]
        branch_No = date_yokin[:3]
        date_yokin = date_yokin[3:]
        date_yokin = (date_yokin.replace('年', '-').
                      replace('月', '-').replace('日', ''))
        date_1 = date_yokin.replace('-', '') + branch_No
        #print('= 3=', date_1)

        tekiyou = pdf_2D_list_copy[index + 1][0]
        kingaku = pdf_2D_list_copy[index + 3][0]

        #print(date_1, date_yokin, tekiyou, kingaku)
        data_item[0] = date_1
        data_item[1] = date_yokin
        data_item[2] = int(kingaku)  # 出金金額
        data_item[4] = tekiyou
        yokin_list.append(data_item)
        continue

# 重複を削除した新しいリストを作成  PDFファイルが複数ある場合に対処
# 明細番号を入れて対処
data_list = [list(t) for t in set(tuple(row) for row in yokin_list)]

data_list.sort(key=lambda x: x[0])  # 明細番号を入れたdata_list[0] でソートする
i = 0

#for row in data_list:
#    i += 1
#    print(i, row)

# ***********************
#  "明細通番","日付","引出金額","預入金額","残高","取引内容"
# ***********************

# フォルダのパス  以下のフォルダの中に対象のファイルを入れます
folder_path = './pdf 普通預金exl/'

# フォルダ内のすべてのファイルを取得します
file_list = os.listdir(folder_path)  # import os の機能を利用して file_listを作ります。
#print(file_list)
# 隠しファイルをスキップする リスト内包表記の記述方式で List を整形
file_list = [filename for filename in file_list
             if not (filename.startswith('.') or '~$' in filename)]
#print(file_list)

# フォルダ内の各エクセルファイルに対して処理を行います。
for file_name in file_list:
    if not ('.xls' in file_name and '普通預金' in file_name):  # 対象となるエクセルファイルを選択
        #print(f" 対象外 : {file_name}")
        continue

    else:  # ここから処理
        file_name = folder_path + file_name
        #file_name = "+2310月普通預金.xlsx"  # 当面実験ようにファイルを固定
        print(file_name)

        # 選択された項目だけをエクセルに展開
        workbook = openpyxl.load_workbook(file_name)
        sheet_name = '普通預金'
        worksheet = workbook[sheet_name]
        # 指定されたエクセルファイルの「処理月」を取得する。この月の範囲でデータを書き込むため。
        mm = worksheet.cell(2, column=1).value
        ymd = str(mm)[:10]
        print('ymd ' + ymd)
        yy = int(ymd[:4])
        mm = int(ymd[5:7])

        # 対象となるデータを選ぶため 1日から月末までを取得し明細番号を加える。
        # print(yy,mm)
        start_date = datetime.date(yy, mm, 1)
        # print(start_date)
        end_date = str(calculate_month_end(start_date))

        ymd = int(ymd.replace('-', '') + '000')
        # print(ymd) 最小値
        ymd_end = int(end_date.replace('-', '') + '999')
        # 月末を数値データにして、'999'を加える。 最大値。
        filtered_list = [sublist for sublist in data_list
                         if int(sublist[0]) >= int(ymd) and int(sublist[0]) <= int(ymd_end)]
        # data_List から条件(月初 から月末まで)にあった新たなfiltered_listを作成。 

        # 3行目を固定
        worksheet.freeze_panes = 'A4'  # 'A4' は固定するセルの位置を指定

        i = 1
        for i in range(5, 100):  # 指定の範囲でデータを削除 5行めから100行目まで
            worksheet.cell(i, column=1).value = ""
            worksheet.cell(i, column=2).value = ""
            worksheet.cell(i, column=3).value = ""
            worksheet.cell(i, column=4).value = ""
            worksheet.cell(i, column=5).value = ""

        for i in ['B', 'C', 'D']:  # エクセルのコンマ区切りのフォーマット
            for cell in worksheet[i]:
                cell.number_format = '#,##0'

        ii = 1
        for row in filtered_list:  # データをエクセルに展開
            ii += 1
            # print(row)
            worksheet.cell(ii + 3, column=1).value = row[1]
            worksheet.cell(ii + 3, column=2).value = row[2]
            worksheet.cell(ii + 3, column=3).value = row[3]
            worksheet.cell(ii + 3, column=4).value = "=D" + str(ii+2) + "-B" + str(ii+3) + "+C" + str(ii+3)
            # 残高計算の数式を入れる
            worksheet.cell(ii + 3, column=5).value = row[4]


        workbook.save(file_name)
        workbook.close()

# PDFから取得した拗ねてのデータを表示 確認のため
data_list.sort(key=lambda x: x[0])  # 明細番号を入れたdata_list[0] でソートする
for row in data_list:
    print(row)

発生した問題

(1)備考が2行の場合が存在し、プログラム・エラーに!

手持ちのメールに添付されてきた全データで動かしてみました。
振込の案件で1件エラーが発生しました。原因は、適用欄が2行で、取得した要素が想定した1個ではなく2個でした。この結果、pdf_2D_list_copy[index + X][0] のインデクスがずれ、int に変換できないことが原因でした。
《キーとなる「文字」でindexで copy のリストからデータを取得する》、この方法ではデータ構造が「可変」なので、読み違いは避けては通れない問題だと考えています。(アスクルでも商品名取得で同じ問題が発生しました。)
if文で条件をさらに細かくすべきかと考えましたが、問題が発生したら「その時考える」ことでアンチョコにelse: で処理しました。対策のプログラムです。

for index, row in enumerate(pdf_2D_list):  # index を取得するため
    # print(f'Index {index}: {row}')
    if row[0] == '振込':  #
        next_row_0 = pdf_2D_list_copy[index + 8][0]  # 1行の終わりを判断するため
        if next_row_0 == '振込' or next_row_0 == '現金' or next_row_0 == '振替支払' or '件' in next_row_0:
            data_item = [None, None, 0, 0, None]
            #print(f'Index {index}: {row}')
            date_yokin = pdf_2D_list_copy[index + 2][0]
            date_yokin = (date_yokin.replace('年', '-').
                          replace('月', '-').replace('日', ''))
            branch_No = pdf_2D_list_copy[index + 4][0]
            date_1 = date_yokin.replace('-', '') + branch_No
            # 入金の順番(明細行)を取得できないと表示できない!

            #print('= 1=', date_1, branch_No)

            tekiyou = pdf_2D_list_copy[index + 1][0]  # 適用欄
            kingaku = pdf_2D_list_copy[index + 5][0]  # 入金額
        else:
            data_item = [None, None, 0, 0, None]
            # print(f'Index {index}: {row}')
            date_yokin = pdf_2D_list_copy[index + 3][0]
            date_yokin = (date_yokin.replace('年', '-').
                          replace('月', '-').replace('日', ''))
            branch_No = pdf_2D_list_copy[index + 5][0]
            date_1 = date_yokin.replace('-', '') + branch_No
            # 入金の順番(明細行)を取得できないと表示できない!
            # print('= 1=', date_1, branch_No)
            tekiyou = pdf_2D_list_copy[index + 1][0]  # 適用欄
            # 1行目のデータだけにしました
            kingaku = pdf_2D_list_copy[index + 6][0]  # 入金額

        #print(date_1, date_yokin, tekiyou, kingaku)
        data_item[0] = date_1  # 日付+ 明細番号 *重複データを排除するのに必要
        data_item[1] = date_yokin  # 日付
        data_item[3] = int(kingaku)  # 入金額
        data_item[4] = tekiyou  # 適用 
        yokin_list.append(data_item)
        continue

if文の《 or  '件' in next_row_0 》はページの最後の行が「振込」の場合の対処策です。
「Pythonで文字列が数字だけで構成されているか否かを判断できますか」とChatGPTに聞きました。

text1 = "123"
result = text1.isdigit()
print(result)  # True

できるとの回答でしたので、if文での使い方を教えてもらい。簡単に付け足してみました。例外処理(try-except文)でエラーが発生した場合の処理を、ChatGPTは勧めますが、素人には、どこでエラーが発生したか見当がつきません。プログラムが問題なのか、取得したデータが問題なのか判断するために以下のようなチェック・プログラムを随所に書いた方が良いのではないかと考えています。

# row[0] は「番号」で数字だけが想定されています。
# エラー処理 データが正しく取得されているかを確認。
i = 0
for row in yokin_list:
    i += 1
    text_1 = str(row[0])
    if text_1.isdigit():
        #print("True",i , text1)
        continue
    else:
        print('番号に数値以外の文字が入っています。データを調べてください。')
        print('False**', i, text_1)
        sys.exit(1)  # プログラムを中止する。

import sys が必要でした。

(2)メールに添付されてくる PDF データにみずほ銀行の「長野支店」のデータも入っていた!

PDFの一つのファイルに、ページが分か書かれて支店ごとの入出金が書かれていました。PDFを読み込む時点で、「長野支店」の言葉が存在したらそのページはスキップすることにしました。PDFを読み込みtextを抽出する関数の中で行なっています。
複数の支店を利用している場合には、別のプログラムを書いた方が良いと思います。

# 全ページのテキストを抽出
for page_num in range(pdf_document.page_count):
    page = pdf_document.load_page(page_num)
    page_text = page.get_text()
    if '長野支店' in page_text:
        print(page_text)
        continue
    extracted_text += page.get_text()


この記事が気に入ったらサポートをしてみませんか?