はじめに
家計簿アプリの Zaim で月々の家計の収支を管理しています。
買い物のレシートを撮影して入力したり、Amazonと連携設定して買い物履歴を自動で入力したりしているのですが、連携に対応していないサービスを手動で入力するのが地味に面倒。入力忘れるし。
でもZaimには開発用APIがあります。オンライン通販なら注文確認メールが届くので、メールの内容を解析して登録したらいいじゃない。
Zaim Developers Center
さくらのレンタルサーバを使っているので、メールの自動処理にはmaildropのフィルターが使えます。自分で書いたコマンドにメールを流し込むことができるので、うまく連携できるように作ってみましょう。
なお、設定を間違えると、届いたメールが破棄されたりするかもしれません。くれぐれもご注意を。
実験環境
- さくらのレンタルサーバ スタンダード
- FreeBSD 11.2
- Python 3.6.10 (pyenvで導入)
- requests_oauthlib 1.3.0
事前に必要なPythonライブラリを入れます。
pip3 install requests_oauthlib
必要な手続き・部品
- (事前に)Zaimのアプリケーションを登録し、「コンシューマID」と「コンシューマシークレット」を取得しておく
- (事前に)OAuth 1.0a認証によりアクセストークンを取得しておく
- メールを解析してZaimに登録するプログラムを書く
- .mailfilter の設定を書く
Zaimのアプリケーション登録
Zaim Developers Center にアクセスし、Zaimのアカウントでログインした後「新しいアプリケーションを追加」から追加します。
特に難しいことはないですが、書き込み権限は忘れずに付けましょう。アプリケーションの名前とかは適当でOKです。
「あなたのアプリケーション一覧」から「コンシューマID」と「コンシューマシークレット」を確認することができます。このコードを後から使います。
アクセストークンの取得
これが地味に面倒です。日本語の情報も多くないし。(そしてZaimのAPIドキュメントも英語)
ということでZaim用の認証コード書いてみました。
import sys
from pathlib import Path
from requests_oauthlib import OAuth1Session
# Provider info
provider_base = "https://api.zaim.net/v2/auth/"
request_url = f"{provider_base}request"
authorize_url = "https://auth.zaim.net/users/auth"
access_url = f"{provider_base}access"
resource_url = "https://api.zaim.net/v2/home/user/verify"
# Consumer info
consumer_key = "bb****************"
consumer_secret = "e5****************"
if Path("access_token.txt").exists():
# read access token
with open("access_token.txt", "r") as f:
l = f.read().strip()
oauth_token, oauth_token_secret = l.split(",")
client = OAuth1Session(consumer_key, client_secret=consumer_secret,
resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret)
ret = client.get(resource_url)
print(ret.text)
print("Successfully authenticated")
elif Path("request_token.txt").exists():
if len(sys.argv) <= 1:
print("Get verifier and run:")
print(f" {sys.argv[0]} <verifier>")
exit(1)
verifier = sys.argv[1]
# read request token
with open("request_token.txt", "r") as f:
l = f.read().strip()
oauth_token, oauth_token_secret = l.split(",")
client = OAuth1Session(consumer_key, client_secret=consumer_secret,
resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret,
verifier=verifier)
token = client.fetch_access_token(access_url)
oauth_token = token["oauth_token"]
oauth_token_secret = token["oauth_token_secret"]
with open("access_token.txt", "w") as f:
f.write(f"{oauth_token},{oauth_token_secret}")
ret = client.get(resource_url)
print(ret.text)
print("Successfully authenticated")
Path("request_token.txt").unlink()
else:
# Using OAuth1Session
client = OAuth1Session(consumer_key, client_secret=consumer_secret, callback_uri=authorize_url)
token = client.fetch_request_token(request_url)
oauth_token = token["oauth_token"]
oauth_token_secret = token["oauth_token_secret"]
with open("request_token.txt", "w") as f:
f.write(f"{oauth_token},{oauth_token_secret}")
# Authorization Endpoint
auth_url = client.authorization_url(authorize_url, oauth_token)
print(f"Access {auth_url} via web browser, get verifier, and run:")
print(f" {sys.argv[0]} <verifier>")
-
consumer_key
,consumer_secret
を自分のアプリケーションの設定からコピーする。 -
auth.py
を引数なしで実行すると、「このURLにアクセスしろ」というメッセージが出るので、URLをコピーしてブラウザでアクセス。 - ブラウザでログインすると認証が完了したというメッセージが出るが、ジャンプしないので、ページのソースを見て英数字列をテキストエディタにコピーする。URLの
oauth_verifier=
に続く部分。 -
oauth_verifier
の文字列を引数に指定してauth.py
をもう一度実行する。Successfully authenticated
と表示されたら準備完了。
(関連記事: GASからZaim APIを利用する - Qiita)
ここでできた access_token.txt
というファイルに書かれたトークンを使って、Zaimの情報を操作できます。
メールの解析
ここからは個々のサービスに特化した内容になります。
私の場合、ヨドバシ.comで買い物をすることが多いのですが、Zaimの連携に対応していません。というわけでヨドバシを題材に試します。
import sys
import email
from email import policy
import re
import time
from pathlib import Path
from requests_oauthlib import OAuth1Session
SELF = Path(__file__).parent
resource_url = "https://api.zaim.net/v2/home/money/payment"
# Consumer info
consumer_key = "bb****************"
consumer_secret = "e5****************"
if not (SELF / "access_token.txt").exists():
print("Not authenticated")
exit(1)
# メッセージのBodyをデコード
message = email.message_from_file(sys.stdin, policy=policy.default)
body = message.get_body().get_content()
# 改行の正規化
lines = re.sub(r"^\n", "", re.sub("<br ?/?>", "\n", body), flags=re.MULTILINE).split("\n")
# 品名・数量・価格をスクレイピング
items = []
scanning = False
title = None
price = None
title_closed = True
date_order = None
for l in lines:
l = l.strip()
if l == "": continue
if scanning:
if title:
# 商品ごとの小計
m = re.search(r"([0-9,]*)\s*点\s*([0-9,]+)\s*円", l)
if m:
# 商品ごとの小計
quantity = int(m.group(1).replace(",", ""))
price = int(m.group(2).replace(",", ""))
memo = f"{quantity}個@{price//quantity}" if quantity > 1 else None
items.append((title, price, memo))
title = None
price = None
title_closed = True
elif not title_closed:
# タイトルの続き
title += l.rstrip("」")
title_closed = l[-1] == "」"
elif "配達料金" in l:
m = re.search(r"配達料金:\s*([0-9,]+)\s*円", l)
price = int(m.group(1).replace(",", ""))
if price != 0:
items.append(("配達料金", price, None))
break
elif l.startswith("・"):
# 商品名
title = l.lstrip("・「").rstrip("」")
# 鍵括弧が閉じていなければ、次の行に続きがあるとみなす
title_closed = l[-1] == "」"
elif "【ご注文商品】" in l:
scanning = True
elif "ご注文日" in l:
m = re.search(r"([0-9]{4})年([0-9]{2})月([0-9]{2})日", l)
if m:
date_order = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
elif "ゴールドポイントでのお支払い" in l:
m = re.search(r"([0-9,]+)\s*円", l)
discount = int(m.group(1).replace(",", ""))
items.append(("ポイント利用", -discount, None))
scanning = True
# Zaimへの入力
with open(SELF / "access_token.txt", "r") as f:
l = f.read().strip()
oauth_token, oauth_token_secret = l.split(",")
client = OAuth1Session(consumer_key, client_secret=consumer_secret,
resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret)
# 同じレシートIDで登録すると1つの記録にまとまる
receipt_id = int(time.time())
# 商品は1件ごとに登録する
for x in items:
payload = {
"mapping": 1,
"from_account_id": 0,
"date": date_order, # yyyy-mm-dd
"place_uid": "zm-**********", # 事前に「ヨドバシ・ドット・コム」に対応するIDを調べておく
"receipt_id": receipt_id,
"category_id": 106,
"genre_id": 10604, # 事前に「家電」に対応するIDを調べておく
"name": x[0],
"amount": x[1]
}
if x[2] is not None:
payload["comment"] = x[2]
ret = client.post(resource_url, data=payload)
いくつかポイントがあるので見てみましょう。
メールのデコード
このスクリプトは、標準入力に流し込まれたメールの内容を解析します。まずはMIMEエンコードとかされているのをデコードして平文にしなければいけません。難しそうだと思ったのですが、Pythonの標準ライブラリを使ってこれだけで終わりでした。(もっとも、policy=policy.default
を書かないと動かないのはハマったポイントでした)
message = email.message_from_file(sys.stdin, policy=policy.default)
body = message.get_body().get_content()
email: 使用例 — Python 3.9.4 ドキュメント
Unable to extract the body of the email file in python - Stack Overflow
あとは過去に受け取った注文確認メールの内容を観察し、必要な部分を頑張って取り出します。このスクリプトは
- 配送してもらう場合と、店舗での受け取りの場合の両パターン
- ポイント払いした金額のマイナス表示
- 複数個買った場合に、メモ欄に個数と単価の表示(例: 「2個@100」)
に対応しています。
家計簿データの観察
入力データを具体的にどう指定すればよいか、APIドキュメントを見てもよく分からないので、実際の家計簿データを見て類推します。
import sys
import time
from pathlib import Path
from requests_oauthlib import OAuth1Session
resource_url = "https://api.zaim.net/v2/home/money?mapping=1"
# Consumer info
consumer_key = "bb****************"
consumer_secret = "e5****************"
if Path("access_token.txt").exists():
# read access token
with open("access_token.txt", "r") as f:
l = f.read().strip()
oauth_token, oauth_token_secret = l.split(",")
client = OAuth1Session(consumer_key, client_secret=consumer_secret,
resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret)
ret = client.get(resource_url)
records = ret.json()
以下のコマンドを実行すると、スクリプト終了後対話モードに切り替わるので
python3 -i show.py
>>> records["money"][0]
のようにして様子を見ます。以下のように、同じ店で同時に購入した商品を見てみると(伏せている部分があります)
>>> records["money"][1000]
{'id': ******21, 'user_id': '******', 'date': '2020-12-05', 'mode': 'payment', 'category_id': 101, 'genre_id': 10101, 'from_account_id': 0, 'to_account_id': 0, 'amount': 69, 'comment': '', 'active': 1, 'created': '2020-12-10 18:49:54', 'currency_code': 'JPY', 'name': 'シュークリーム', 'receipt_id': ******97, 'place_uid': 'gg-******', 'place': '******'}
>>> records["money"][1001]
{'id': ******20, 'user_id': '******', 'date': '2020-12-05', 'mode': 'payment', 'category_id': 101, 'genre_id': 10101, 'from_account_id': 0, 'to_account_id': 0, 'amount': 83, 'comment': '', 'active': 1, 'created': '2020-12-10 18:49:53', 'currency_code': 'JPY', 'name': 'うどん', 'receipt_id': ******97, 'place_uid': 'gg-******', 'place': '******'}
receipt_id
が同じになっていたり、APIドキュメントで解説されている place
の他に place_uid
という謎のキーに同じ値が設定されていたりします。これらの情報から
- 同じ店で買ったものには同じ
place_uid
を付ければよい - 1枚のレシートにまとめたいものには同じ
receipt_id
を付ければよい
と類推できます(実際、試してみたら当たっているようです)。これを踏まえて、データ作成部分を見てみます。
Zaimへの追加用のデータ作成
先程のスクリプトを実行すると、アプリ上で以下のような履歴が追加されます。
この画面は1枚のレシートに対応しますが、商品が2つ登録されています。
コードで payload
を作っている部分をもう少し詳しく見てみましょう。
payload = {
"mapping": 1, # 常に 1
"from_account_id": 0, # 口座連携している場合はそのIDが使える?(0でもよい)
"date": date_order, # yyyy-mm-dd
"place_uid": "zm-**********", # 事前に「ヨドバシ・ドット・コム」に対応するIDを調べておく
"receipt_id": receipt_id,
"category_id": 106,
"genre_id": 10604, # 事前に「家電」に対応するIDを調べておく
"name": x[0],
"amount": x[1]
}
このレコードは入力1件に対応しています。レシート1枚ではありません。例えば、1枚のレシートに「キャベツ、卵、牛乳…」と書いてあったら、その行数だけレコードが必要です。
各レシートに対応するIDが receipt_id
です。同じ receipt_id
を持つ商品は1件のレシートとしてまとめて表示されます1。Zaimアプリから入力したレコードにはどうもUNIX時間でIDが付番されるっぽいので、真似をして
receipt_id = int(time.time())
によってIDを振っています。
place_uid
, category_id
, genre_id
についても、過去の入力履歴から指定すべき値を探します。一度でもアプリから「ヨドバシ・ドット・コム」の店名を手動で記入して登録したことがあれば、その時のレコードに入っている値を持ってくればよいです。カテゴリもとりあえずデフォルトで「家電」を指定して、必要ならば後からアプリから修正する運用でよいでしょう。2
コマンドラインで動作確認する
メーラーでメッセージ (.eml) を名前を付けて保存し、そのメールをさくらのレンタルサーバに転送します(くれぐれも ~/www
の下には置かないようにご注意を!)。そして
python3 yodobashi.py < message.eml
のように標準入力に流し込みます。うまく行けば、これでZaimにデータが入力されます。
メールをトリガーに自動実行
最後に、メールが来た時に自動実行するようにmaildropを設定します。以下はさくらのレンタルサーバの例ですが、他のmaildropを使っているサーバでも同様に設定できるかもしれません。
最初に管理画面から受信用メールアドレスを追加しておきます。
メールアドレスの作成・変更・削除 – さくらのサポート情報
各サービスの登録メールアドレスを変えるか、作ったアドレスに転送させるように設定します(Gmailなどだと転送設定ができますね)。
すでにさくらのレンタルサーバのメールアドレスで通知メールを受け取っていたら、新たにメールアドレスを作らなくても大丈夫です。
作成したメールアドレスのユーザ名(@ より前の部分)が hogehoge
だったら、${HOME}/MailBox/hogehoge
というディレクトリができています。この中にある .mailfilter
が設定ファイルです。ヨドバシ.comからの注文確認メールを処理したければ、以下のようになります(xxxxxx にヨドバシ.comからの差出人メールアドレスを。スクリプトやバイナリのパスは適宜変えてください)。
if (/^From:.*xxxxxx@yodobashi\.com/)
{
cc "| /home/username/.pyenv/versions/3.6.10/bin/python3 /home/username/path/to/yodobashi.py"
exit
}
こうするとフィルタにヒットしたメールはスクリプトの標準入力に投入され、exit
によりメールボックスには残らずに破棄されます(元々使っているメールアドレスの場合は exit
を書いてはダメです)。
取りこぼしとかがあったときのデバッグ用に、ヒットしなかったものは残すようになっていますが、そこはお好みでどうぞ。
おわりに
他のサービスの場合も同じです。家計簿の入力に必要な情報がメールで届くサービスならば、どんなサービスでも自動登録が可能なはずです。
でも最近はログインしないと金額などが確認できないサービスもありますね。そんな場合でも、メールをトリガーとして、ログイン+HTMLのスクレイピングを自動化するなどの方法が考えられます。興味のある方は↓など参考にしてみてください。
【Python3】ログイン機能付サイトでスクレイピング【requests】【BeautifulSoup】 - Qiita
では、素晴らしい家計簿ライフを!