3
1

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 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

面倒だから1Clickでスプレッドシートの家計簿を画像にしてdiscordに送る

Posted at

はじめに

 お金持ちになりたい… 

 という気持ちが強いためか、ありがたいことに家計を任されているので、家計簿をつけて毎月妻に報告してます。MoneyForwardのcsvファイルを用いて家計簿をつけて共有しています。しかし、毎回

csvをダウンロード 

スプレッドシートにアップロード

スクショしていい感じにトリミングして画像保存

妻に画像を共有

という手順を踏むのが面倒…。なので、1Clickで画像取得まで出来るようにしました。

 とはいえ、動かすために常に自分のPCを立ち上げっぱなしにするのも無駄があるし、わざわざサーバーを借りるまでもないし…。そんな中、GitHub Actionsを使えば出来るのではと閃いたので使ってみました。また、自分の学習&ローカル環境を汚したくなかったのでDockerとdocker-composeを使用してます。

 

プログラムの流れ

MoneyForwardから年月を指定してcsvダウンロード

該当する年月のスプレッドシートにcsvをアップロード

スプレッドシートをPDF出力&ダウンロード

PDFを画像に変換

画像をdiscordの指定チャネルに送信

 

バージョン確認

事前準備

家計簿

 スプレッドシートを作成しておきます。ファイルネームは『家計簿_2023』、シート名は『5月』のようにします。どちらも数字は半角です。Q1セルを始点にして収支データを貼り付けると、左側の表に集計されます。画像とする部分はB1:J37です。

スクリーンショット 2023-06-23 8.43.07.png

requirements.txt

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を使ってインストールしたかったのですが、エラーが出てしまい進めなかったので、別の方法にしました。以下を実行すれば、インストールが完了します。詳しくは別記事にしてます。
 

 

set-up-chdriver.sh
# 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』を選びます。

スクリーンショット 2023-06-19 7.44.03.png

スクリーンショット 2023-06-19 7.54.41.png

『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から引き込んで設定できるようにしておきます。

set-up-env.sh
#!/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

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のイメージを使ってます。

Dockerfile
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-utilspoppler-dataとmoneyforwardの2段階認証突破に必要なoathtoolをインストールしておきます。

 また、Dockerを一般ユーザーとして実行するのにローカル環境のグループID・ユーザーIDを使います。ARGで変数として持っておき、docker-compose.ymlの方でも同じ設定をしてdocker-composeコマンドで流し込めるようにします。

 

docker-conpose.yml

docker-compose.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をダウンロードする』、『スプレッドシートにアップロードする』というパートに分けてます。

dl-ul-csv.py
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での取得の仕方は、

  1. 取得したい要素を選択する。
  2. 右クリックから『検証』を選択する。
  3. 『Elements』の該当タグを右クリックする。
  4. 『Copy』→『Copy Xpath』を押してコピー

という流れです。下の画像も参考にしてください。

スクリーンショット 2023-06-24 20.37.17.png

 他、かいつまんでおくと、

year, month = map(int, input().split())

 Actionsで実行する際、取得したい家計簿の年月を指定できるようにします。yearmonthとして入力を受け付けます。

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へ送る

family-finance.py
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の設定

run.yml
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に送られてきます!

スクリーンショット 2023-06-26 8.50.43.png

 

さいごに

 csvダウンロードとスプレッドシートへのアップロードを2つのファイルに分けたほうがわかりやすかったなと記事を書いてて思いました…。お金持ちになれるようこれからも家計簿しっかりつけます。

 

参考

3
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?