├── requirements.txt ├── README.md ├── LICENSE ├── quickstart.py ├── .gitignore └── anapay2mf.py /requirements.txt: -------------------------------------------------------------------------------- 1 | google-api-python-client==2.149.0 2 | google-auth-httplib2==0.2.0 3 | google-auth-oauthlib==1.2.1 4 | gspread==6.1.4 5 | helium==5.1.0 6 | python-dateutil==2.9.0.post0 7 | python-dotenv==1.0.1 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # anapay2moneyforward 2 | 3 | * ANA Payのメールから支払い情報を取り出して、マネーフォワードに登録するスクリプト。 4 | 5 | ## 環境構築 6 | 7 | * 必要なライブラリをvenvにインストールする 8 | 9 | ```bash 10 | $ python 3.11 -m venv env 11 | $ . env/bin/activate 12 | (env) $ pip install -r reqirements.txt 13 | ``` 14 | 15 | ## GmailとGoogle SpreadsheetのAPIを使えるようにする 16 | 17 | * 以下のページも参考にして、GoogleのAPIを使えるようにする 18 | * [Python クイックスタート  |  Gmail  |  Google for Developers](https://developers.google.com/gmail/api/quickstart/python?hl=ja) 19 | * Google Cloudコンソールでプロジェクトを作成する 20 | * プロジェクトでGmail APIとGoogle Sheets APIを有効にする 21 | * OAuth 同意画面でアプリを作成する 22 | * テストユーザーで自分のGoogleアカウントを追加 23 | * 認証情報をダウンロードし、`credentials.json` として保存 24 | * 以下のように `quickstart.json` を実行する 25 | * 自分のGoogleアカウントで **同意する** 26 | * 処理が成功すると `token.json` が生成される 27 | 28 | ``` 29 | (env) $ python quickstart.py 30 | (env) $ ls *.json 31 | credentials.json token.json 32 | ``` 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Takanori Suzuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /quickstart.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os.path 4 | 5 | from google.auth.transport.requests import Request 6 | from google.oauth2.credentials import Credentials 7 | from google_auth_oauthlib.flow import InstalledAppFlow 8 | from googleapiclient.discovery import build 9 | from googleapiclient.errors import HttpError 10 | 11 | # If modifying these scopes, delete the file token.json. 12 | SCOPES = [ 13 | 'https://www.googleapis.com/auth/gmail.readonly', 14 | 'https://www.googleapis.com/auth/spreadsheets', 15 | ] 16 | 17 | 18 | def main(): 19 | """Shows basic usage of the Gmail API. 20 | Lists the user's Gmail labels. 21 | """ 22 | creds = None 23 | # The file token.json stores the user's access and refresh tokens, and is 24 | # created automatically when the authorization flow completes for the first 25 | # time. 26 | if os.path.exists('token.json'): 27 | creds = Credentials.from_authorized_user_file('token.json', SCOPES) 28 | # If there are no (valid) credentials available, let the user log in. 29 | if not creds or not creds.valid: 30 | if creds and creds.expired and creds.refresh_token: 31 | creds.refresh(Request()) 32 | else: 33 | flow = InstalledAppFlow.from_client_secrets_file( 34 | 'credentials.json', SCOPES) 35 | creds = flow.run_local_server(port=0) 36 | # Save the credentials for the next run 37 | with open('token.json', 'w') as token: 38 | token.write(creds.to_json()) 39 | 40 | try: 41 | # Call the Gmail API 42 | service = build('gmail', 'v1', credentials=creds) 43 | results = service.users().labels().list(userId='me').execute() 44 | labels = results.get('labels', []) 45 | 46 | if not labels: 47 | print('No labels found.') 48 | return 49 | print('Labels:') 50 | for label in labels: 51 | print(label['name']) 52 | 53 | except HttpError as error: 54 | # TODO(developer) - Handle errors from gmail API. 55 | print(f'An error occurred: {error}') 56 | 57 | 58 | if __name__ == '__main__': 59 | main() 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | credentials.json 2 | token.json 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /anapay2mf.py: -------------------------------------------------------------------------------- 1 | """ 2 | ANA Payの情報をメールから取得してスプレッドシートに書き込む 3 | 4 | それからスプレッドシートの情報を元に、Money Fowardに情報を書き込む 5 | """ 6 | 7 | import base64 8 | import logging 9 | import os 10 | from dataclasses import dataclass 11 | from datetime import datetime 12 | from pathlib import Path 13 | 14 | import gspread 15 | import helium 16 | from dateutil import parser 17 | from dotenv import load_dotenv 18 | from google.auth.exceptions import RefreshError 19 | from google.oauth2.credentials import Credentials 20 | from googleapiclient.discovery import build 21 | 22 | import quickstart 23 | 24 | SCOPES = [ 25 | "https://www.googleapis.com/auth/gmail.readonly", 26 | "https://www.googleapis.com/auth/spreadsheets", 27 | ] 28 | 29 | # Google Spreadsheet ID and Sheet name 30 | SHEET_ID = "143Ewai1jFlt4d4msZI8fXersf2IErrzTQfFjjrwzOwM" 31 | SHEET_NAME = "ANAPay" 32 | 33 | MF_URL = "https://ssnb.x.moneyforward.com/cf" 34 | 35 | format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 36 | logging.basicConfig(format=format, level=logging.INFO) 37 | 38 | load_dotenv() 39 | 40 | 41 | @dataclass 42 | class ANAPay: 43 | """ANA Pay information""" 44 | 45 | email_date: datetime = None 46 | date_of_use: datetime = None 47 | amount: int = 0 48 | store: str = "" 49 | 50 | def values(self) -> tuple[str, str, str, str]: 51 | """return tuple of values for spreadsheet""" 52 | return self.email_date_str, self.date_of_use_str, self.amount, self.store 53 | 54 | @property 55 | def email_date_str(self) -> str: 56 | return f"{self.email_date:%Y-%m-%d %H:%M:%S}" 57 | 58 | @property 59 | def date_of_use_str(self) -> str: 60 | return f"{self.date_of_use:%Y-%m-%d %H:%M:%S}" 61 | 62 | 63 | def get_mail_info(res: dict) -> ANAPay | None: 64 | """ 65 | 1件のメールからANA Payの利用情報を取得して返す 66 | """ 67 | ana_pay = ANAPay() 68 | for header in res["payload"]["headers"]: 69 | if header["name"] == "Date": 70 | date_str = header["value"].replace(" +0900 (JST)", "") 71 | ana_pay.email_date = parser.parse(date_str) 72 | 73 | # 本文から日時、金額、店舗を取り出す 74 | # ご利用日時:2023-06-28 22:46:19 75 | # ご利用金額:44,308円 76 | # ご利用店舗:SMOKEBEERFACTORY OTSUKATE 77 | data = res["payload"]["body"]["data"] 78 | body = base64.urlsafe_b64decode(data).decode() 79 | for line in body.splitlines(): 80 | if line.startswith("ご利用"): 81 | key, value = line.split(":") 82 | if key == "ご利用日時": 83 | ana_pay.date_of_use = parser.parse(value) 84 | elif key == "ご利用金額": 85 | ana_pay.amount = int(value.replace(",", "").replace("円", "")) 86 | elif key == "ご利用店舗": 87 | ana_pay.store = value 88 | return ana_pay 89 | 90 | 91 | def get_anapay_info(after: str) -> list[ANAPay]: 92 | """ 93 | gmailからANA Payの利用履歴を取得する 94 | """ 95 | ana_pay_list = [] 96 | 97 | creds = Credentials.from_authorized_user_file("token.json", SCOPES) 98 | service = build("gmail", "v1", credentials=creds) 99 | 100 | # https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list 101 | query = f"from:payinfo@121.ana.co.jp subject:ご利用のお知らせ after:{after}" 102 | results = service.users().messages().list(userId="me", q=query).execute() 103 | messages = results.get("messages", []) 104 | for message in reversed(messages): 105 | # https://developers.google.com/gmail/api/reference/rest/v1/users.messages/get 106 | res = service.users().messages().get(userId="me", id=message["id"]).execute() 107 | ana_pay = get_mail_info(res) 108 | if ana_pay: 109 | ana_pay_list.append(ana_pay) 110 | return ana_pay_list 111 | 112 | after = "2023/06/28" 113 | 114 | 115 | def get_last_email_date(records: list[dict[str, str]]): 116 | """get last email date for gmail search""" 117 | after = "2023/06/28" 118 | if records: 119 | last_email_date = parser.parse(records[-1]["email_date"]) 120 | after = f"{last_email_date:%Y/%m/%d}" 121 | return after 122 | 123 | 124 | def gmail2spredsheet(worksheet): 125 | """gmailからANA Payの利用履歴を取得しスプレッドシートに書き込む""" 126 | # get all records from spreadsheet 127 | records = worksheet.get_all_records() 128 | logging.info("Records in spreadsheet: %d", len(records)) 129 | 130 | # get last day from records 131 | after = get_last_email_date(records) 132 | logging.info("Last day on spreadsheet: %s", after) 133 | email_date_set = set(parser.parse(r["email_date"]) for r in records) 134 | 135 | # get ANA Pay email from Gamil 136 | ana_pay_list = get_anapay_info(after) 137 | logging.info("ANA Pay emails: %d", len(ana_pay_list)) 138 | 139 | # add ANA Pay record to spreadsheet 140 | count = 0 141 | for ana_pay in ana_pay_list: 142 | # メールの日付が存在しない場合はレコードを追加 143 | if ana_pay.email_date not in email_date_set: 144 | worksheet.append_row(ana_pay.values(), value_input_option="USER_ENTERED") 145 | count += 1 146 | logging.info("Record added to spreadsheet: %s", ana_pay.values()) 147 | logging.info("Records added to spreadsheet: %d", count) 148 | 149 | 150 | def login_mf(): 151 | """login moneyforward sbi""" 152 | 153 | email = os.getenv("EMAIL") 154 | password = os.getenv("PASSWORD") 155 | 156 | # https://selenium-python-helium.readthedocs.io/en/latest/api.html 157 | logging.info("Login to moneyfoward") 158 | helium.start_firefox(MF_URL) 159 | helium.wait_until(helium.Button("ログイン").exists) 160 | helium.write(email, into="メールアドレス") 161 | helium.write(password, into="パスワード") 162 | helium.click("ログイン") 163 | 164 | helium.wait_until(helium.Button("手入力").exists) 165 | 166 | 167 | def add_mf_record(dt: datetime, amount: int, store: str, store_info: dict | None): 168 | """ 169 | add record to moneyfoward 170 | """ 171 | 172 | # https://selenium-python-helium.readthedocs.io/en/latest/api.html 173 | helium.click("手入力") 174 | # breakpoint() 175 | helium.write(f"{dt:%Y/%m/%d}", into="日付") 176 | helium.click("日付") 177 | 178 | helium.write(amount, into="支出金額") 179 | asset = helium.find_all(helium.ComboBox())[0] 180 | for option in asset.options: 181 | if option.startswith("ANA Pay"): 182 | helium.select(asset, option) 183 | 184 | if store_info: 185 | category = helium.find_all(helium.Link("未分類"))[0] 186 | l_category = helium.find_all(helium.S("#js-large-category-selected"))[0] 187 | helium.click(l_category) 188 | helium.click(store_info["大項目"]) 189 | 190 | m_category = helium.find_all(helium.S("#js-middle-category-selected"))[0] 191 | helium.click(m_category) 192 | helium.click(store_info["中項目"]) 193 | 194 | helium.write(store_info["店名"], into="内容をご入力下さい(任意)") 195 | else: 196 | helium.write(store, into="内容をご入力下さい(任意)") 197 | 198 | helium.click("保存する") 199 | logging.info(f"Record added to moneyforward: {dt:%Y/%m/%d}, {amount}, {store}") 200 | 201 | helium.wait_until(helium.Button("続けて入力する").exists) 202 | helium.click("続けて入力する") 203 | 204 | 205 | def spreadsheet2mf(worksheet, store_dict: dict[str, dict[str, str]]) -> None: 206 | """スプレッドシートからmoneyfowardに書き込む""" 207 | 208 | records = worksheet.get_all_records() 209 | 210 | # すべてmoneyforwardに登録済みならなにもしない 211 | if all(record["mf"] == "done" for record in records): 212 | return 213 | 214 | login_mf() # login to moneyfoward 215 | added = 0 216 | for count, record in enumerate(records): 217 | if record["mf"] != "done": 218 | date_of_use = parser.parse(record["date_of_use"]) 219 | amount = int(record["amount"]) 220 | store = record["store"] 221 | add_mf_record(date_of_use, amount, store, store_dict.get(store)) 222 | 223 | # update spread sheets for "done" message 224 | worksheet.update_cell(count + 2, 5, "done") 225 | added += 1 226 | helium.kill_browser() 227 | 228 | logging.info(f"Records added to moneyforward: {added}") 229 | 230 | 231 | def main(): 232 | try: 233 | creds = Credentials.from_authorized_user_file('token.json', SCOPES) 234 | service = build('gmail', 'v1', credentials=creds) 235 | results = service.users().labels().list(userId='me').execute() 236 | except RefreshError: 237 | # recreate token 238 | Path("token.json").unlink(missing_ok=True) 239 | quickstart.main() 240 | 241 | gc = gspread.oauth( 242 | credentials_filename="credentials.json", authorized_user_filename="token.json" 243 | ) 244 | sheet = gc.open_by_key(SHEET_ID) 245 | anapay_sheet = sheet.worksheet("ANAPay") 246 | store_sheet = sheet.worksheet("ANAPayStore") 247 | store_dict = {store["store"]: store for store in store_sheet.get_all_records()} 248 | 249 | gmail2spredsheet(anapay_sheet) 250 | spreadsheet2mf(anapay_sheet, store_dict) 251 | 252 | 253 | if __name__ == "__main__": 254 | main() 255 | --------------------------------------------------------------------------------