楽天カードを使いすぎるとお猿さんが大暴れする装置  ~PART①:明細スクレイピング~

# はじめに

皆さんこんにちは。ABEJAアドベントカレンダー2021の6日目の記事です。１日目には[こんな記事](https://qiita.com/xecus/items/c96bec764e20946059a0)を書いていました。今回はまた別の話です。

最近(ここ半年ぐらいで)、結婚・引っ越し・車購入と家購入と....とライフステージの変化が発生し、人生最高レベルの出費が重なりました。ようやく落ち着いてきたものの...最近出費が増えることに慣れてしまったのか、最近お財布の紐（気持ち）が緩みがちです。例えば外食がかなり多くなってしまい、エンゲル係数がわりと高めになったり。

![couple_kakei.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/bb55e5d3-dea7-d381-2313-3d706eaef1a5.png)

家庭内CFO（家庭内最高財務責任者）の自分ですが、使用状況を正しく認識する＆自分自身を律するモノ・仕組みがないと、ダメだなーっていう課題感が今回のスタートポイントになります。

# 課題の分析

- お財布の紐がゆるくなっている原因になってなもの
    - **①各予算(消耗品、食費、通信費、娯楽）に対する実績が見えていない事**
        -  「今月そんなに外食行ってない気がするから、今日も外食するか」 → ※実は既に予算オーバー
    - **②ライフステージ変化で出費が多くなって、出費が増える事に慣れてしまった事**
        - 例: 「必要経費だから、今はいいか。来年から貯金本気出すぞ〜」 
        - 来年から頑張るモードによる課題の見て見ぬ振り。
    - **③お金をセーブする事の目標設定や動機づけ不全**
        - 例： 「来月は○○を買うために、○○円貯めよう。だから今はセーブしよう」がない。
        - ダイエットもゴール設定が大事と聞く。ゴールがないダイエットはなーなーになる。
    - **④節約しようという空気感を作れていない**
        - ②が続いた結果。ただ、③とも連携した課題。
        - 節約しようって言うのが苦手。自分に対しても、家族に対しても。

他にも、家計簿アプリを入れて三日坊主(三ヶ月坊主)になってしまったり、仕事終わりのIPAが美味しすぎてやめられなかったり、まぁ自分自身の性格面の話とかも色々あるのですが...自分の場合、代表的な所はこんなところかなぁと思っています。


# ソリューションコンセプト

**「人間は弱い。だから仕組みで律するんだ」**っていうところで、こんなシステム全体像を考えています。（主語でかいですね。すみませんｗ。）　　**カード使用量と用途の見える化支援**と**使いすぎるとお猿さんに怒られる**というようなシステムです。「節約」ってあんまりおもしろくないので、実用性も担保しつつ、UX的に少しでも面白くなればなぁという思いです。

![スクリーンショット 2021-12-07 0.19.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/86a7a3a2-3e7b-5537-9a64-3155cf6a6a77.png)

- 機能開発ロードマップ
    - **①「体重計的な機能」 (家計の状況を正しく見える化する)**
        - カードの使用履歴から何にいくら使ったかを見えるか
           - Python x Seleniumによるカード使用履歴の取得
               - 今回のPART①記事の対象範囲。
           - Pandas x Matplotlibを使った使用履歴の自動分類・可視化部分
               - 次回以降PART②記事の対象範囲
    - **②「ライザップ的な機能」　（節約の成功に導いてくれる機能)**
      - 節約を促す機構
          - 使いすぎると、リビングのお猿さんが大暴れする
          - RaspberryPi x 物理リレーの利用
          - 次回以降PART③記事の対象範囲
      - 目標設定機能
          - ※別機会で開発予定。

お猿さんには、カードの使用履歴と予算設定に応じて、**人間に反省を促す装置**になってもらおうと思います。ちなみに、今回使用予定のお猿さんは下記のようなおもちゃです。(意外と良い値段だったので、既に嫁からは無駄遣いと指摘がはいっております...既に、無駄にはしないぜお猿さんという背水の陣マインドです）

![スクリーンショット 2021-12-16 3.03.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/43db79f6-7505-5786-4f01-8eb1cbab890c.png)

<iframe width="560" height="315" src="https://www.youtube.com/embed/4O2E3llAn6A" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

今回の記事では、「Python x Seleniumによるカード使用履歴の取得」という部分を執筆していきます。長くなるので、他のパートは別機会に。


# 楽天カードの明細取得フロー (参考情報)

今回、カードの使用履歴を取得するにあたって、明細を取得するAPI等がぱっとみつかりませんでした。なので、ヘッドレスブラウザを使ってログイン→カード選択→当該月選択→CSVダウンロードをする事を考えます。具体的には下記のような画面を自動で入力したりボタンをクリックしたりするモノを作るイメージになります。

(もし、ベターな方法があればコメントいただけますと嬉しいです！）

## ログイン画面

![スクリーンショット 2021-12-16 2.54.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/005dda2e-bbc6-49bd-0bf2-df0d0792a5df.png)


## 第2パスワード入力画面
![スクリーンショット 2021-12-16 2.54.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/0183d112-5a2b-b7fd-5512-108c61832e59.png)

※自分は、メインのパスワードに加えて第２パスワードも設定しているので、こういった画面がでます。


## カード選択

※自分は複数枚カードをもっている為、対象のカードを取得するにはこのセレクトボックスからカード切り替えを行う必要性があります。

![スクリーンショット_2021-12-16_2_54_34-2-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/4fb4c7b1-470d-ff9d-172c-136c8908dbd6.png)


## 明細画面 & CSVダウンロード

- 「前月」「次月」というボタンを押すと、期間選択ができます。
- 下にスクロールしますと、CSVで履歴をダウンロードするボタンがあります。

![スクリーンショット_2021-12-16_2_54_42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/19ae639d-7173-1a30-6cbd-898de6f33c65.png)

![スクリーンショット_2021-12-16_2_54_56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/973c1390-2b4e-6784-c18a-73f6aa46c5dd.png)


# メイキング

## 環境面の準備

ヘッドレスブラウザとSeleniumが動く環境をDockerで作ります。
将来的に、EKSやGKEのCronjobに載せたくて、開発環境も兼ねて最初からコンテナ化しています。
(FIXME: 動くことファーストで書いてあるので、後日きれいにする　)

```Dockerfile
FROM python:3.9

ENV DEBIAN_FRONTEND noninteractive
ENV GECKODRIVER_VER v0.30.0
ENV FIREFOX_VER 87.0
ENV PYTHONIOENCODING utf-8
ENV TZ="Asia/Tokyo"
ENV LANG=C.UTF-8
ENV LANGUAGE=en_US:en

RUN apt update && apt install -y firefox-esr

WORKDIR /tmp
RUN wget https://github.com/mozilla/geckodriver/releases/download/${GECKODRIVER_VER}/geckodriver-${GECKODRIVER_VER}-linux64.tar.gz
RUN tar zxvf geckodriver-${GECKODRIVER_VER}-linux64.tar.gz
RUN chmod +x geckodriver
RUN mv geckodriver /usr/bin/geckodriver
RUN rm -rf geckodriver-${GECKODRIVER_VER}-linux64.tar.gz

RUN apt install -y libx11-xcb1 libdbus-glib-1-2 \
  && curl -sSLO https://download-installer.cdn.mozilla.net/pub/firefox/releases/${FIREFOX_VER}/linux-x86_64/en-US/firefox-${FIREFOX_VER}.tar.bz2 \
  && tar -jxf firefox-* \
  && mv firefox /opt/ \
  && chmod 755 /opt/firefox \
  && chmod 755 /opt/firefox/firefox \
  && rm -f firefox-${FIREFOX_VER}.tar.bz2

RUN wget https://noto-website-2.storage.googleapis.com/pkgs/Noto-hinted.zip && unzip Noto-hinted.zip \
  && mkdir -p /usr/share/fonts/opentype/noto \
  && rm -f README LICENSE_OFL.txt \
  && mv *.otf *.ttf /usr/share/fonts/opentype/noto \
  && fc-cache -f -v \
  && rm Noto-hinted.zip

RUN mkdir -p /app
ADD requirements.txt /app/requirements.txt
ADD main.py /app/main.py

WORKDIR /app
RUN pip3 install --upgrade pip
RUN pip3 install -r requirements.txt
```

```requirements.txt
async-generator==1.10
attrs==21.2.0
certifi==2021.10.8
cffi==1.15.0
charset-normalizer==2.0.9
cryptography==35.0.0
h11==0.12.0
idna==3.3
outcome==1.1.0
pycparser==2.20
pyOpenSSL==21.0.0
requests==2.26.0
selenium==4.0.0
six==1.16.0
sniffio==1.2.0
sortedcontainers==2.4.0
trio==0.19.0
trio-websocket==0.9.2
urllib3==1.26.7
wsproto==1.0.0
```

## 明細取得部分

今回は、Python x Seleniumを利用しています。
(FIXME: 一部、Deprecatedな関数を使っているので、後日修正予定です)

- 設定ファイル (クレデンシャル情報等を格納)

```config.json
{
  "card_rakuten": {
    "user_id": "honyarara",
    "password": "honyarara",
    "password2": "honyarara",
    "login_url": "https://www.rakuten-card.co.jp/e-navi/",
    "card_num": "1234"
  }
}
```

- 本体のソースコード

```Python:main.py
import os
import json
import glob
import selenium
import traceback
import requests
from selenium import webdriver
from selenium.webdriver import Firefox, FirefoxOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.select import Select

site_name = "card_rakuten"
config = json.load(open("config.json", "r", encoding="utf-8"))
# 楽天カードログインURL
login_url = config[site_name]["login_url"]
# 楽天カードユーザーID
user_id = config[site_name]["user_id"]
# 楽天カードパスワード
password = config[site_name]["password"]
# 楽天カード第２パスワード
password2 = config[site_name]["password2"]
# 対象カードの末尾４桁 (例:1234)
main_card_num = config[site_name]["card_num"]
# 明細ダウンロード先の一時フォルダ
download_dir_path = '/app/results'

# main_card_numで指定したカードを選択する関数
def select_card(browser, select_card_num, p = None):
   # 現在選択中のカードIDをとってくる。選択できて入れえば終わり。
   WebDriverWait(browser, 10).until(
     ec.presence_of_element_located((By.XPATH, '//*[@id="j_idt609"]/div[2]/div/div[2]/div'))
   )
   current_card_num_element = browser.find_element(By.XPATH, '//*[@id="j_idt609"]/div[2]/div/div[2]/div')
   card_num_str = current_card_num_element.get_attribute('innerHTML')
   splited_card_num = card_num_str.split(' - ')
   if len(splited_card_num) != 4:
       raise Exception('Invalid CardID Format')
   card_num = splited_card_num[3]
   print(f'Current Selected CardNum=[{card_num}]')
   if select_card_num == card_num:
       print(f'{select_card_num} is selected!')
       return
   # 現在選択しているカードじゃないやつを選ぶ
   card_selector = Select(browser.find_element(By.XPATH, '//*[@id="j_idt609:card"]'))
   if p is None:
       p = 0
   if p == len(card_selector.options):
       raise Exception('Not found card...')
   card_value_to_try = card_selector.options[p].get_attribute('value')
   print(f'Selecting...value={card_value_to_try}')
   card_selector.select_by_value(card_value_to_try)
   # 正しいカードが選択できるまで再帰的にトライする
   select_card(browser, select_card_num, p + 1)

def login(browser):
    browser.get(login_url)
    browser.save_screenshot("captures/login-screen.png")
    # ID自動入力
    e = browser.find_element(By.ID, "u")
    e.clear()
    e.send_keys(user_id)
    # PASSWORD自動入力
    e = browser.find_element(By.ID, "p")
    e.clear()
    e.send_keys(password)
    # ログイン実施
    button = browser.find_element(By.ID, "loginButton")
    button.click()
    # 待機
    WebDriverWait(browser, 10).until(
      ec.presence_of_element_located((By.ID, "indexForm:password"))
    )
    browser.save_screenshot("captures/clicked-loginButton.png")
    # 第２パスワードの自動入力
    e = browser.find_element(By.ID, "indexForm:password")
    e.clear()
    e.send_keys(password2)
    button = browser.find_element(By.NAME, "indexForm:j_idt78")
    button.click()
    WebDriverWait(browser, 10).until(
        ec.presence_of_element_located((By.CLASS_NAME, "rf-font-bold"))
    )
    browser.save_screenshot("captures/logined.png")
    print('-> Logined')

def access_meisai(browser):
    url = 'https://www.rakuten-card.co.jp/e-navi/members/statement/index.xhtml?tabNo={}'.format(i)
    browser.get(url)

def get_meisai_title(browser):
    title_element = browser.find_element(By.XPATH, "//*[@id='js-payment-calendar-btn']/span")
    title = title_element.get_attribute('innerHTML')
    print(f'meisai_title={meisai_title}')
    return title

def get_meisai_csv_url(browser):
    csv_link_tag = browser.find_element(By.CLASS_NAME, 'stmt-csv-btn')
    href = csv_link_tag.get_attribute('href')
    return href

def get_cookies(browser):
    c = {}
    for cookie in browser.get_cookies():
        name = cookie['name']
        value = cookie['value']
        c[name] = value
    return c

options = FirefoxOptions()
options.add_argument('--headless')
fp = webdriver.FirefoxProfile()
fp.set_preference("browser.download.folderList", 2)
fp.set_preference("browser.download.useDownloadDir", True)
fp.set_preference("browser.download.dir", download_dir_path)
fp.set_preference("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream;text/csv")
fp.set_preference("browser.helperApps.alwaysAsk.force", False)
fp.set_preference("browser.download.manager.showWhenStarting", False)
browser = Firefox(options=options, firefox_profile=fp)

try:
    login(browser)
    # 直近三ヶ月のカード利用明細へアクセス
    for i in range(3):
        # 明細ページへ遷移
        access_meisai(browser)
        # カード番号を指定
        select_card(browser, main_card_num)
        # 明細のタイトル名を取得
        meisai_title = get_meisai_title(browser)
        # ダウンロードリンクの取得
        meisai_csv_url = get_meisai_csv_url(browser)
        # requestsで使うためのCookie情報取り出し
        c = get_cookies(browser)
        # CSVダウンロードパス
        download_path = os.path.join(download_dir_path, f'{meisai_title}.csv')
        print(f'Downloading... {download_dir_path}')
        # requestsを利用してデータのダウンロード
        r = requests.get(meisai_csv_url, cookies=c)
        with open(download_path, 'wb') as f:
                f.write(r.content)
except Exception as e:
    print(traceback.format_exc())

browser.quit()
```

# 所感・まとめ


## 動作の様子

上記のスクリプトを動かすと、下記のようにCSVファイルがダウンロードされます。
![スクリーンショット 2021-12-16 3.53.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/3a1bbd6d-7fe4-c88f-9131-8155b7ddd798.png)

開いてみると、使用履歴情報がガッツリと乗っている事が確認できます。


![スクリーンショット_2021-12-16_3_55_08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/b8decbda-d991-e5ba-31cf-a4ee921e3d0d.png)


## つまづいたポイント

楽天のオーバーレイ広告とCSVダウンロードボタンが重なってしまうと、SleniumからのCSVダウンロードボタンクリックがうまく行かないことがあります。この時、スクロールしてごまかすことも可能ですが、もうちょっとロバストな解決手法がほしかったのでCookieを取得してPythonのrequestsモジュール経由でダウンロードするようにしました。

![スクリーンショット 2021-12-16 3.21.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128123/5896882d-a331-7b98-49db-3266db691bde.png)

- 広告の影響を受けやすいコード

```Python
# CSVダウンロードボタン待機
WebDriverWait(browser, 100).until(
    ec.presence_of_element_located((By.CLASS_NAME, "stmt-csv-btn"))
)
# PopUpの広告バーを避けるために、ちょいとスクロール
browser.execute_script("window.scrollTo(0, 300);")
# CSVダウンロードボタンのクリック
link = browser.find_element(By.CLASS_NAME, "stmt-csv-btn")
link.click()
```

- 広告の影響を受けないコード

```Python
# ダウンロード先のリンクアドレスをゲット
tag = browser.find_element(By.CLASS_NAME, 'stmt-csv-btn')
href = tag.get_attribute('href')
# ブラウザのクッキー情報をrequests用に準備
c = {}
for cookie in browser.get_cookies():
    name = cookie['name']
    value = cookie['value']
    c[name] = value
# requestsを利用してデータのダウンロード
r = requests.get(href, cookies=c)
with open("rakuten-card.csv", 'wb') as f:
    f.write(r.content)
```



## 今後の展望

- 全体的に雑に作っているのでエラーハンドリングとか頑張る

- GKEやEKSのCronjobで自動的に動かすようにする

- CSVファイルをパースしてDBに書き込むようにする (ファイルのまま扱わない)

- 使用履歴の自動分類や可視化部分の作成 & Slack自動連携
  - pandasやmatplotlibとの連携

- Part２以降の記事の執筆