はじめに
お金持ちになりたい…
という気持ちが強いためか、ありがたいことに家計を任されているので、家計簿をつけて毎月妻に報告してます。MoneyForwardのcsvファイルを用いて家計簿をつけて共有しています。しかし、毎回
csvをダウンロード
↓
スプレッドシートにアップロード
↓
スクショしていい感じにトリミングして画像保存
↓
妻に画像を共有
という手順を踏むのが面倒…。なので、1Clickで画像取得まで出来るようにしました。
とはいえ、動かすために常に自分のPCを立ち上げっぱなしにするのも無駄があるし、わざわざサーバーを借りるまでもないし…。そんな中、GitHub Actionsを使えば出来るのではと閃いたので使ってみました。また、自分の学習&ローカル環境を汚したくなかったのでDockerとdocker-composeを使用してます。
プログラムの流れ
MoneyForwardから年月を指定してcsvダウンロード
↓
該当する年月のスプレッドシートにcsvをアップロード
↓
スプレッドシートをPDF出力&ダウンロード
↓
PDFを画像に変換
↓
画像をdiscordの指定チャネルに送信
バージョン確認
事前準備
家計簿
スプレッドシートを作成しておきます。ファイルネームは『家計簿_2023』、シート名は『5月』のようにします。どちらも数字は半角です。Q1セルを始点にして収支データを貼り付けると、左側の表に集計されます。画像とする部分はB1:J37です。
requirements.txt
aiohttp==3.8.4
aiosignal==1.3.1
async-generator==1.10
async-timeout==4.0.2
attrs==22.2.0
cachetools==5.3.0
certifi==2022.12.7
charset-normalizer==3.0.1
discord.py==2.1.1
exceptiongroup==1.1.1
frozenlist==1.3.3
google-api-core==2.11.0
google-auth==2.16.0
google-auth-httplib2==0.1.0
google-auth-oauthlib==1.0.0
googleapis-common-protos==1.58.0
gspread==5.7.2
h11==0.14.0
httplib2==0.21.0
idna==3.4
multidict==6.0.4
oauth2client==4.1.3
oauthlib==3.2.2
outcome==1.2.0
packaging==23.0
pdf2image==1.16.2
Pillow==9.4.0
protobuf==4.22.0
pyasn1==0.4.8
pyasn1-modules==0.2.8
pyparsing==3.0.9
PySocks==1.7.1
python-dotenv==0.21.1
requests==2.28.2
requests-oauthlib==1.3.1
rsa==4.9
selenium==4.8.3
six==1.16.0
sniffio==1.3.0
sortedcontainers==2.4.0
tqdm==4.65.0
trio==0.22.0
trio-websocket==0.10.2
uritemplate==4.1.1
urllib3==1.26.14
# webdriver-manager==3.8.5
wsproto==1.2.0
yarl==1.8.2
set-up-chdriver.sh
seleniumを使う際にChromeDriverが必要になります。webdriver-managerを使ってインストールしたかったのですが、エラーが出てしまい進めなかったので、別の方法にしました。以下を実行すれば、インストールが完了します。詳しくは別記事にしてます。
# Ubuntu no longer distributes chromium-browser outside of snap
#
# Proposed solution: https://askubuntu.com/questions/1204571/how-to-install-chromium-without-snap
# Add debian buster
cat > /etc/apt/sources.list.d/debian.list <<'EOF'
deb [arch=amd64 signed-by=/usr/share/keyrings/debian-buster.gpg] http://deb.debian.org/debian buster main
deb [arch=amd64 signed-by=/usr/share/keyrings/debian-buster-updates.gpg] http://deb.debian.org/debian buster-updates main
deb [arch=amd64 signed-by=/usr/share/keyrings/debian-security-buster.gpg] http://deb.debian.org/debian-security buster/updates main
EOF
# Add keys
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DCC9EFBF77E11517
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 648ACFD622F3D138
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 112695A0E562B32A
apt-key export 77E11517 | gpg --dearmour -o /usr/share/keyrings/debian-buster.gpg
apt-key export 22F3D138 | gpg --dearmour -o /usr/share/keyrings/debian-buster-updates.gpg
apt-key export E562B32A | gpg --dearmour -o /usr/share/keyrings/debian-security-buster.gpg
# Prefer debian repo for chromium* packages only
# Note the double-blank lines between entries
cat > /etc/apt/preferences.d/chromium.pref << 'EOF'
Package: *
Pin: release a=eoan
Pin-Priority: 500
Package: *
Pin: origin "deb.debian.org"
Pin-Priority: 300
Package: chromium*
Pin: origin "deb.debian.org"
Pin-Priority: 700
EOF
# Install chromium and chromium-driver
apt-get update
apt-get install -y chromium chromium-driver
GitHub Actionsのsecretsを設定
Actionsを使ってプログラム実行する際に、.envファイルをその場で作ります。そこに流し込む環境変数を先にsecretsに登録しておきましょう。
リポジトリのsettingsから『Actions』を選びます。
『New repository secret』を押下し、以下の5つを環境変数に登録します。discordのbot作成はこの記事では解説しませんので、検索してみてください!
DISCODE_BOT_TOKEN_FAMILYFINANCE=<Discordのbotトークン>
MFEMAIL=<MoneyForwardIDのメールアドレス>
MFPASSWORD=<MoneyForwardIDのパスワード>
TWO_STEP_AUTHENTICATION_CODE=<MoneyForwardIDの二段階認証用コード>
CREDENTIALS_JSON=<スプレッドシートのクレデンシャル情報>
{
"type": "xxxxxxxx",
"project_id": "xxxxxxxx",
"private_key_id": "xxxxxxxx",
"private_key": "xxxxxxxx",
"client_email": "xxxxxxxx",
"client_id": "xxxxxxxx",
"auth_uri": "xxxxxxxx",
"token_uri": "xxxxxxxx",
"auth_provider_x509_cert_url": "xxxxxxxx",
"client_x509_cert_url": "xxxxxxxx"
}
CREDENTIALS_JSON
はjson形式で設定します。これを用いて、Actionsのワークフローでjsonを作成します。
二段階認証の設定方法は以下の記事が参考になります。(追加してMoneyForwardIDへのログインが必要になりますが、大筋変わりません)
set-up-env.sh
.envファイル作成用のシェルスクリプトです。Actionsのsecretsから引き込んで設定できるようにしておきます。
#!/bin/bash
echo "DISCODE_BOT_TOKEN_FAMILYFINANCE = $DISCODE_BOT_TOKEN_FAMILYFINANCE" >> .env
echo "MFEMAIL = $MFEMAIL" >> .env
echo "MFPASSWORD = $MFPASSWORD" >> .env
echo "TWO_STEP_AUTHENTICATION_CODE = $TWO_STEP_AUTHENTICATION_CODE" >> .env
settings.py
import os, json
from os.path import join, dirname
from dotenv import load_dotenv
dotenv_path = join(dirname(__file__), ".env")
load_dotenv(dotenv_path)
DISCODE_BOT_TOKEN_FAMILYFINANCE = os.environ.get("DISCODE_BOT_TOKEN_FAMILYFINANCE")
MFEMAIL = os.environ.get("MFEMAIL")
MFPASSWORD = os.environ.get("MFPASSWORD")
TWO_STEP_AUTHENTICATION_CODE = os.environ.get("TWO_STEP_AUTHENTICATION_CODE")
Dockerfile
今回のDockerfileでは、python3のイメージを使ってます。
FROM python:3
RUN apt-get update && \
apt-get -y install locales && \
localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG=ja_JP.UTF-8
ENV LANGUAGE=ja_JP:ja
ENV LC_ALL=ja_JP.UTF-8
ENV TZ=JST-9
ENV TERM=xterm
RUN apt-get install -y vim \
less \
poppler-utils \
poppler-data \
oathtool
# パッケージの読み込みとselenium用chdriverの設定
WORKDIR /workspace/
COPY requirements.txt .
COPY set-up-chdriver.sh .
RUN pip install --upgrade pip setuptools && \
pip install -r ./requirements.txt && \
chmod +x ./set-up-chdriver.sh && \
sh ./set-up-chdriver.sh
# root権限を避けるための一般ユーザーの設定と適用
ARG UID
ARG GID
ARG USERNAME
ARG GROUPNAME
RUN groupadd -g ${GID} ${GROUPNAME} -f && \
useradd -m -s /bin/bash -u ${UID} -g ${GID} ${USERNAME}
USER ${USERNAME}
pdf2image(pdfの画像化)に必要なpoppler-utils
、poppler-data
とmoneyforwardの2段階認証突破に必要なoathtool
をインストールしておきます。
また、Dockerを一般ユーザーとして実行するのにローカル環境のグループID・ユーザーIDを使います。ARG
で変数として持っておき、docker-compose.ymlの方でも同じ設定をしてdocker-composeコマンドで流し込めるようにします。
docker-conpose.yml
version: "3"
services:
web:
restart: always
build:
context: .
args:
- UID
- GID
- USERNAME=user
- GROUPNAME=user
container_name: family-finance
tty: true
stdin_open: true
working_dir: "/workspace/"
volumes:
- .:/workspace
env_file:
- .env
moneyforwardから月別収支のcsvをダウンロード&アップロードする
まずは全体像です。GitHub Actionsで実行する際スムーズに進むように、1つのファイルの中で『moneyforwardからcsvをダウンロードする』、『スプレッドシートにアップロードする』というパートに分けてます。
import csv, glob, os, re, subprocess
import gspread
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.chrome.options import Options as ChromeOptions
from time import sleep, strftime
from oauth2client.service_account import ServiceAccountCredentials
import settings
#### 家計簿csvのダウンロード ####
# 家計簿の年月を指定
year, month = map(int, input().split())
EMAIL = settings.MFEMAIL
PASSWORD = settings.MFPASSWORD
TWO_STEP_AUTHENTICATION_CODE = settings.TWO_STEP_AUTHENTICATION_CODE
options = ChromeOptions()
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument('--disable-blink-features=AutomationControlled')
options.add_argument(f'user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36')
service = ChromeService("/usr/bin/chromedriver") # ChromeDriverManager().install()を使いたい
browser = webdriver.Chrome(service=service, options=options)
browser.get("https://id.moneyforward.com/sign_in/email")
# メールアドレスを入力&ログイン
print(">> start input mail..")
elem_input_email = browser.find_element(By.XPATH, "/html/body/main/div/div/div/div[1]/div[1]/section/form/div[2]/div/input")
elem_input_email.send_keys(EMAIL)
elem_login = browser.find_element(By.XPATH, "/html/body/main/div/div/div/div[1]/div[1]/section/form/div[2]/div/div[3]/input")
elem_login.click()
print(">> done!")
sleep(3)
# パスワード入力&ログイン
print(">> start input password..")
elem_input_password = browser.find_element(By.XPATH, "/html/body/main/div/div/div/div/div[1]/section/form/div[2]/div/input[2]")
elem_input_password.send_keys(PASSWORD)
elem_login2 = browser.find_element(By.XPATH, "/html/body/main/div/div/div/div/div[1]/section/form/div[2]/div/div[3]/input")
elem_login2.click()
print(">> done!")
sleep(3)
# 二段階認証
print(">> start input two step authentication code..")
two_step_authentication = ["oathtool", "--totp", "--base32", TWO_STEP_AUTHENTICATION_CODE]
auth_code = re.findall(r'\d+', subprocess.check_output(two_step_authentication).decode("utf-8"))
elem_input_authcode = browser.find_element(By.XPATH, "/html/body/main/div/div/div/section/div[1]/section/form/div[2]/div/div[1]/input")
elem_input_authcode.send_keys(auth_code[0])
elem_login3 = browser.find_element(By.XPATH, "/html/body/main/div/div/div/section/div[1]/section/form/div[2]/div/div[2]/button")
elem_login3.click()
print(">> done!")
sleep(3)
# マネーフォワードMEのTOPページへ
print(">> start select service & account..")
elem_select_service_moneyforwardme = browser.find_element(By.XPATH, "/html/body/main/div/div[2]/div/div[1]/div/ul/li/a")
elem_select_service_moneyforwardme.click()
sleep(3)
elem_enter_moneyforwardme_use_account = browser.find_element(By.XPATH, "/html/body/main/div/div/div/div/div[1]/section/form/div[2]/div/div[2]/input")
elem_enter_moneyforwardme_use_account.click()
print(">> done!")
sleep(3)
# 家計簿ページへ
print(">> enter the main page..")
elem_kakeibo = browser.find_element(By.XPATH, "//*[@id=\"header-container\"]/header/div[2]/ul/li[2]/a")
elem_kakeibo.click()
sleep(3)
# 家計簿をダウンロードするために年月を指定する
print(">> enter the kakeibo page & select year and month..")
elem_select_year_and_month = browser.find_element(By.XPATH, "//*[@id=\"in_out\"]/div[2]/div/span")
elem_select_year_and_month.click()
actions = ActionChains(browser)
actions.move_to_element(browser.find_element(By.XPATH, f"//*[@id=\"in_out\"]/div[2]/div/div/div[{int(strftime('%Y'))-year+1}]"))
actions.move_to_element(browser.find_element(By.XPATH, f"//*[@id=\"in_out\"]/div[2]/div/div/div[{int(strftime('%Y'))-year+1}]/div/a[{month}]"))
actions.click()
actions.perform()
sleep(3)
# csvをダウンロード
print(">> downloading..")
elem_download_dropdown = browser.find_element(By.XPATH, "//*[@id=\"js-dl-area\"]/a")
elem_download_dropdown.click()
elem_dlcsv = browser.find_element(By.XPATH, "//*[@id=\"js-csv-dl\"]/a")
elem_dlcsv.click()
sleep(3)
print(">> OK!!")
print(">> every program completed")
browser.close()
#### スプレッドシートにcsvをアップロード ####
scope = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]
credentials = ServiceAccountCredentials.from_json_keyfile_name("./credentials.json", scope)
gc = gspread.authorize(credentials)
# csvファイルが同階層にない場合プログラムを終了
glob_csv = glob.glob("*.csv")
if glob_csv == []:
print(">> No Such csv File!! Please Download csv File.")
exit()
csv_file_name = glob_csv[0]
spreadsheet_name = f"家計簿_{year}"
spreadsheet = gc.open(spreadsheet_name)
worksheet = spreadsheet.worksheet(f"{month}月")
spreadsheet.values_clear(f"{month}月!Q1:Z200")
csv_list = list(csv.reader(open(csv_file_name, encoding="shift_jis")))
# csv.readerで読み込んだものは全て文字列となるため、金額部分のみint型に変更
for row in csv_list[1:]:
row[3] = int(row[3])
worksheet.update("Q1:Z200", csv_list)
# アップロードしたcsvファイルは削除
os.remove(f"{csv_file_name}")
前半(csvダウンロード)
seleniumを使用していきます。要素はXpathで取得しました。ブラウザはchromeになりますが、Xpathでの取得の仕方は、
- 取得したい要素を選択する。
- 右クリックから『検証』を選択する。
- 『Elements』の該当タグを右クリックする。
- 『Copy』→『Copy Xpath』を押してコピー
という流れです。下の画像も参考にしてください。
他、かいつまんでおくと、
year, month = map(int, input().split())
Actionsで実行する際、取得したい家計簿の年月を指定できるようにします。year
、month
として入力を受け付けます。
options.add_argument(f'user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36')
user-agent
を設定しておきましょう。ないとMoneyForward側から弾かれます。
moneyforwardの仕様変更でhtmlが変われば、正しくダウンロード出来ません。
変更はたびたびあるようなので、気をつけてください。
後半(スプレッドシートへアップロード)
gspread
を使ってアップロードしていきます。
credentials = ServiceAccountCredentials.from_json_keyfile_name("./credentials.json", scope)
スプレッドシートを開くために、クレデンシャル情報が必要になります。現状ではcredentials.json
は存在してませんが、後述のActionsワークフローで作成していきます。
スプレッドシートから画像を作成し、discordへ送る
import requests, sys
import discord, gspread
from pdf2image import convert_from_path
from oauth2client.service_account import ServiceAccountCredentials
import settings
# 欲しい家計簿の年月を指定
year, month = map(int, input().split())
scope = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]
credentials = ServiceAccountCredentials.from_json_keyfile_name("./credentials.json", scope)
gc = gspread.authorize(credentials)
spreadsheet_name = f"家計簿_{year}"
spreadsheet = gc.open(spreadsheet_name)
spreadsheet_url = "https://docs.google.com/spreadsheets/d/" + spreadsheet.id
spreadsheet_url_options = (
"/export?format=pdf" +
f"&gid={spreadsheet.worksheet(f'{month}月').id}" +
"&range=B1:J37" +
"&portrait=true" +
"&size=a5" +
"&fitw=true" +
"&horizontal_alignment=CENTER" +
"&top_margin=0.00" +
"&bottom_margin=0.00" +
"&left_margin=0.00" +
"&right_margin=0.00" +
"&scale=4"
)
''' spreadsheetのformat設定一覧
&format=pdf //export format
&size=a4 //A3/A4/A5/B4/B5/letter/tabloid/legal/statement/executive/folio
&portrait=false //true= Potrait / false= Landscape
&scale=1 //1= Normal 100% / 2= Fit to width / 3= Fit to height / 4= Fit to Page
&top_margin=0.00 //All four margins must be set!
&bottom_margin=0.00 //All four margins must be set!
&left_margin=0.00 //All four margins must be set!
&right_margin=0.00 //All four margins must be set!
&gridlines=false //true/false
&printnotes=false //true/false
&pageorder=2 //1= Down, then over / 2= Over, then down
&horizontal_alignment=CENTER //LEFT/CENTER/RIGHT
&vertical_alignment=TOP //TOP/MIDDLE/BOTTOM
&printtitle=false //true/false
&sheetnames=false //true/false
&fzr=false //true/false
&fzc=false //true/false
&attachment=false //true/false
'''
# discode.pyを使ってスプレッドシートを切り取り、画像として送信していく
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
# botが準備できた段階で画像送信のon_messageを発火させるコメントを送信
@client.event
async def on_ready():
guild = discord.utils.get(client.guilds)
channel = discord.utils.get(guild.text_channels, name="一般")
await channel.send(f"{year}年{month}月の家計簿でーす!")
# 画像送信
@client.event
async def on_message(message):
if message.author == client.user:
if "家計簿" in message.content:
# pdfを取得
pdf_export_url = spreadsheet_url + spreadsheet_url_options
pdf_name = "output.pdf"
headers = {'Authorization': 'Bearer ' + credentials.create_delegated("").get_access_token().access_token}
res = requests.get(pdf_export_url, headers=headers)
with open(pdf_name, mode="wb") as f:
f.write(res.content)
# 取得したpdfを画像に変換
image = convert_from_path(pdf_name)
image[0].save("output.png", "png")
await message.channel.send(file=discord.File("output.png"))
await client.close()
client.run(settings.DISCODE_BOT_TOKEN_FAMILYFINANCE)
画像を送信するために、一度botからメッセージを送らせます。ただ、画像だけ送られてきても面白くないのでこういう形にしました。
botからのメッセージで家計簿を画像化するプログラムが発火します。まず、pdfにしてダウンロードし、その後画像にします。pdfダウンロードの際に詰まった部分があったので、それは別記事に。
GitHub Actionsの設定
name: Send Image
on:
workflow_dispatch:
inputs:
year:
description: "年"
required: True
type: choice
options:
- "2023"
- "2022"
month:
description: "月"
required: True
type: choice
options:
- "1"
- "2"
- "3"
- "4"
- "5"
- "6"
- "7"
- "8"
- "9"
- "10"
- "11"
- "12"
jobs:
activate_program:
name: Activate Program
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
with:
name: "credentials.json"
json: ${{ secrets.CREDENTIALS_JSON }}
- name: Generate .env
run: chmod +x set-up-env.sh && sh set-up-env.sh
env:
DISCODE_BOT_TOKEN_FAMILYFINANCE: ${{ secrets.DISCODE_BOT_TOKEN_FAMILYFINANCE }}
MFEMAIL: ${{ secrets.MFEMAIL }}
MFPASSWORD: ${{ secrets.MFPASSWORD }}
TWO_STEP_AUTHENTICATION_CODE: ${{ secrets.TWO_STEP_AUTHENTICATION_CODE }}
- name: Docker-compose Run & download-upload csv
run: |
docker-compose build --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" && docker-compose up -d
docker-compose exec -T web bash -c "echo ${{ inputs.year }} ${{ inputs.month }} | python dl-ul-csv.py"
- name: Send image on discode
run: |
docker-compose exec -T web bash -c "echo ${{ inputs.year }} ${{ inputs.month }} | python family-finance.py"
inputs
で手動トリガーする際に年月を指定します。家計簿の年に合わせて増やしていけます。また、 set-up-env.sh
を用いて、.env
を作成しておきます。
最後に、docker-compose
でDockerを起動し、連続してpythonファイルも起動します。パイプラインを使ってinputs
で受け取ったものをpythonファイルに受け取らせます。
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
with:
name: "credentials.json"
json: ${{ secrets.CREDENTIALS_JSON }}
ちなみに、credentials.json
を作成するのに、jsdaniell/create-json@v1.2.2
を使ってます。詳しくはリンク先をご覧ください。(secretsに登録したらjsonってどうやってActionsで使えるようになるん…?と悶々としていた時に見つけて感動しました…)
以上のファイルをGitHubにあげて、Actionsを実行すればdiscordに送られてきます!
さいごに
csvダウンロードとスプレッドシートへのアップロードを2つのファイルに分けたほうがわかりやすかったなと記事を書いてて思いました…。お金持ちになれるようこれからも家計簿しっかりつけます。