3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Python / maildrop] 通販サイトの注文メールを自動でZaimに登録

Last updated at Posted at 2021-04-24

はじめに

家計簿アプリの 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

必要な手続き・部品

  1. (事前に)Zaimのアプリケーションを登録し、「コンシューマID」と「コンシューマシークレット」を取得しておく
  2. (事前に)OAuth 1.0a認証によりアクセストークンを取得しておく
  3. メールを解析してZaimに登録するプログラムを書く
  4. .mailfilter の設定を書く

Zaimのアプリケーション登録

Zaim Developers Center にアクセスし、Zaimのアカウントでログインした後「新しいアプリケーションを追加」から追加します。
特に難しいことはないですが、書き込み権限は忘れずに付けましょう。アプリケーションの名前とかは適当でOKです。
image.png

「あなたのアプリケーション一覧」から「コンシューマID」と「コンシューマシークレット」を確認することができます。このコードを後から使います。
image.png

アクセストークンの取得

これが地味に面倒です。日本語の情報も多くないし。(そしてZaimのAPIドキュメントも英語)
ということでZaim用の認証コード書いてみました。

auth.py
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>")
  1. consumer_key, consumer_secret を自分のアプリケーションの設定からコピーする。
  2. auth.py を引数なしで実行すると、「このURLにアクセスしろ」というメッセージが出るので、URLをコピーしてブラウザでアクセス。
  3. ブラウザでログインすると認証が完了したというメッセージが出るが、ジャンプしないので、ページのソースを見て英数字列をテキストエディタにコピーする。URLの oauth_verifier= に続く部分。
  4. oauth_verifier の文字列を引数に指定して auth.py をもう一度実行する。Successfully authenticated と表示されたら準備完了。

(関連記事: GASからZaim APIを利用する - Qiita

ここでできた access_token.txt というファイルに書かれたトークンを使って、Zaimの情報を操作できます。

メールの解析

ここからは個々のサービスに特化した内容になります。
私の場合、ヨドバシ.comで買い物をすることが多いのですが、Zaimの連携に対応していません。というわけでヨドバシを題材に試します。

yodobashi.py
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ドキュメントを見てもよく分からないので、実際の家計簿データを見て類推します。

show.py
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への追加用のデータ作成

先程のスクリプトを実行すると、アプリ上で以下のような履歴が追加されます。

image.png

この画面は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からの差出人メールアドレスを。スクリプトやバイナリのパスは適宜変えてください)。

.mailfilter
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

では、素晴らしい家計簿ライフを!

  1. レシートIDを指定しないで登録することも可能ですが、後から同じレシートに商品を追加することができなくなります。

  2. 技術的には商品名から自動推定させることも可能ですが、開発コストが高すぎます。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?