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

# はじめに

家計簿アプリの [Zaim](https://zaim.net/) で月々の家計の収支を管理しています。
買い物のレシートを撮影して入力したり、Amazonと連携設定して買い物履歴を自動で入力したりしているのですが、連携に対応していないサービスを手動で入力するのが地味に面倒。入力忘れるし。

でもZaimには開発用APIがあります。オンライン通販なら注文確認メールが届くので、メールの内容を解析して登録したらいいじゃない。
[Zaim Developers Center](https://dev.zaim.net/)

さくらのレンタルサーバを使っているので、メールの自動処理には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](https://dev.zaim.net/) にアクセスし、Zaimのアカウントでログインした後「新しいアプリケーションを追加」から追加します。
特に難しいことはないですが、書き込み権限は忘れずに付けましょう。アプリケーションの名前とかは適当でOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/563e157d-6261-df12-595c-826e60d6c48c.png)

「あなたのアプリケーション一覧」から「コンシューマID」と「コンシューマシークレット」を確認することができます。このコードを後から使います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/48f8f925-66ef-e076-db65-87ec9159eb93.png)

# アクセストークンの取得

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

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

（関連記事: [GASからZaim APIを利用する - Qiita](https://qiita.com/shutosg/items/6845057432bca551024b)）

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

# メールの解析

ここからは個々のサービスに特化した内容になります。
私の場合、[ヨドバシ.com](https://www.yodobashi.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`` を書かないと動かないのはハマったポイントでした）

```python
message = email.message_from_file(sys.stdin, policy=policy.default)
body = message.get_body().get_content()
```

[email: 使用例 — Python 3.9.4 ドキュメント](https://docs.python.org/ja/3/library/email.examples.html)
[Unable to extract the body of the email file in python - Stack Overflow](https://stackoverflow.com/questions/45124127/unable-to-extract-the-body-of-the-email-file-in-python)

あとは過去に受け取った注文確認メールの内容を観察し、必要な部分を頑張って取り出します。このスクリプトは

- 配送してもらう場合と、店舗での受け取りの場合の両パターン
- ポイント払いした金額のマイナス表示
- 複数個買った場合に、メモ欄に個数と単価の表示（例: 「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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/1935f29f-24a7-4420-c173-3394d93ce0b3.png)

この画面は1枚のレシートに対応しますが、商品が2つ登録されています。
コードで ``payload`` を作っている部分をもう少し詳しく見てみましょう。

```python
    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が付番されるっぽいので、真似をして

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

```python
receipt_id = int(time.time())
```

によってIDを振っています。

``place_uid``, ``category_id``, ``genre_id`` についても、過去の入力履歴から指定すべき値を探します。一度でもアプリから「ヨドバシ・ドット・コム」の店名を手動で記入して登録したことがあれば、その時のレコードに入っている値を持ってくればよいです。カテゴリもとりあえずデフォルトで「家電」を指定して、必要ならば後からアプリから修正する運用でよいでしょう。[^2]

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

## コマンドラインで動作確認する

メーラーでメッセージ (.eml) を名前を付けて保存し、そのメールをさくらのレンタルサーバに転送します（くれぐれも ``~/www`` の下には置かないようにご注意を！）。そして

```
python3 yodobashi.py < message.eml
```

のように標準入力に流し込みます。うまく行けば、これでZaimにデータが入力されます。

# メールをトリガーに自動実行

最後に、メールが来た時に自動実行するようにmaildropを設定します。以下はさくらのレンタルサーバの例ですが、他のmaildropを使っているサーバでも同様に設定できるかもしれません。

最初に管理画面から受信用メールアドレスを追加しておきます。
[メールアドレスの作成・変更・削除 – さくらのサポート情報](https://help.sakura.ad.jp/360000225362/)

各サービスの登録メールアドレスを変えるか、作ったアドレスに転送させるように設定します（Gmailなどだと転送設定ができますね）。
すでにさくらのレンタルサーバのメールアドレスで通知メールを受け取っていたら、新たにメールアドレスを作らなくても大丈夫です。

作成したメールアドレスのユーザ名（@ より前の部分）が ``hogehoge`` だったら、``${HOME}/MailBox/hogehoge`` というディレクトリができています。この中にある ``.mailfilter`` が設定ファイルです。ヨドバシ.comからの注文確認メールを処理したければ、以下のようになります（xxxxxx にヨドバシ.comからの差出人メールアドレスを。スクリプトやバイナリのパスは適宜変えてください）。

```text:.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](https://qiita.com/syunyo/items/36af8bcb501baf8c7014)

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