Zaim の CSV インポートのブラウザ操作を Seleniumでちょっと自動化する

Mac

概要

  • ヨドバシのクレジットカードを愛用しているが、Zaimの自動連携がないため毎月CSVインポートで記録している
  • Windows では Power Automate Desktop でちょっと楽をしていたが、macOS でも同様の自動化をしたいと思ったので試してみた

前提

作業環境

macOS Tahoe 26.0.1(25A362)

使うもの

作るもの

  • 所定の形式で作成しているCSVファイルをZaimにインポートする作業を、部分的に自動化するもの
  • 実行すると、Zaimの入出力ページを開いて特定のプルダウンの選択肢を一気に設定してくれる
  • 以下の動作は手動で対応する
    • ログインする
    • アップロードするCSVファイルを選択する
    • アップロードをテスト
    • 本番の家計簿にアップロード
    • スクリプトを終了する

作業ログ

作業ディレクトリを用意

ディレクトリの作成

任意の場所でディレクトリを作成

mkdir zaim-automation

作成したディレクトリに移動

cd zaim-automation
.editorconfig の作成

あとでいろんなファイルを用意するのであらかじめ作っておく

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

Python 環境を用意する

Python のバージョンを指定

Python 3.12系の最新バージョンである 3.12.12 を利用することにした

インストール済みのバージョン一覧確認

pyenv versions

該当バージョンがなければ、以下コマンドでインストール

pyenv install 3.12.12

作業ディレクトリ配下の Python バージョンを固定

pyenv local 3.12.12

該当バージョンに設定されたことを確認

python --version
% python --version
Python 3.12.12
Python 環境を構築

.venv ディレクトリに専用の Python 環境を作成

python -m venv .venv

この時点で which pythonwhich pip を実行すると、ユーザーディレクトリ以下のコマンドが参照されていることがわかる

% which python
/Users/hi3103/.pyenv/shims/python
% which pip
/Users/hi3103/.pyenv/shims/pip

.venv ディレクトリの環境が参照されるように、以下を実行

source .venv/bin/activate

which コマンドを再度実行し、パスが変わったことを確認

% which python             
/Users/hi3103/Sites/zaim-automation/.venv/bin/python
% which pip                
/Users/hi3103/Sites/zaim-automation/.venv/bin/pip
.venv 内の pip を最新に更新

以下のコマンドを実行

pip install -U pip

Selenium をインストール

以下のコマンドを実行

pip install selenium

インストールされたことを確認

pip show selenium

実行結果から、Seleniumのバージョンと、指定のPythonバージョン配下に格納されたことを確認

% pip show selenium 
Name: selenium
Version: 4.39.0
Summary: Official Python bindings for Selenium WebDriver
Home-page: https://www.selenium.dev
Author: 
Author-email: 
License: Apache-2.0
Location: /Users/hi3103/.pyenv/versions/3.12.12/lib/python3.12/site-packages
Requires: certifi, trio, trio-websocket, typing_extensions, urllib3, websocket-client
Required-by: 

.envファイルを用意

python-dotenv をインストール

Python で .env ファイルを扱えるようにする

pip install python-dotenv
.env を作成

ブラウザのプロファイルのパスや、遷移する画面のURLなどを .env ファイルで管理する

# Firefox automation profile directory
PROFILE_DIR=/Users/yourname/Library/Application Support/Firefox/Profiles/xxxxxxxx.xxxxxxxx

# Zaim URLs
LOGIN_URL=https://zaim.net/user_session/new
IMPORT_EXPORT_URL=https://zaim.net/home/money_upload

# Login wait (seconds)
LOGIN_TIMEOUT_SEC=180
チェック用コードを用意して確認

env_check.py というファイル名で以下を保存

# env_check.py
import os
from dotenv import load_dotenv

load_dotenv()

KEYS = [
  "PROFILE_DIR",
  "LOGIN_URL",
  "IMPORT_EXPORT_URL",
  "LOGIN_TIMEOUT_SEC",
]

for key in KEYS:
  value = os.getenv(key)
  if value is None:
    print(f"[NG] {key} is not loaded")
  else:
    print(f"[OK] {key} = {value}")

以下を実行

python env_check.py

.env に記載した値が出力されることを確認

% python env_check.py
[OK] PROFILE_DIR = /Users/yourname/Library/Application Support/Firefox/Profiles/xxxxxxxx.xxxxxxxx
[OK] LOGIN_URL = https://zaim.net/user_session/new
[OK] IMPORT_EXPORT_URL = https://zaim.net/home/money_upload
[OK] LOGIN_TIMEOUT_SEC = 180

Git 管理のためのもろもろ

最終的に Github のリポジトリにあげておきたいので、必要なファイルを諸々用意する

Git 初期化
git init
README.md を作成
touch README.md
requirements.txt を作成

現状の構成を書き出しておく

pip freeze > requirements.txt
.env.sample を作成

.env ファイルは Git 管理外としたいので、 .env.sample という名称で複製し、ダミーデータを流し込んでおく

.gitignore を作成
# Python
__pycache__/
*.py[cod]

# Virtualenv
.venv/

# System
.DS_Store

# Logs
*.log

# Environment
.env

テストスクリプトを作成

Python も Selenium も初めて触るので、細かい単位で色々試してみる

Selenium で Firefox を起動して任意のURLを表示する

smoke.py として保存

from selenium import webdriver

driver = webdriver.Firefox()
driver.get("https://hi3103.net")
input("表示できたら Enter で終了: ")
driver.quit()

実行

python smoke.py
Selenium で Firefox を任意のプロファイルで起動する

profile_smoke.py として保存

from selenium import webdriver

PROFILE_DIR = "/Users/yourname/Library/Application Support/Firefox/Profiles/xxxxxxxx.xxxxxxxx"

options = webdriver.FirefoxOptions()
options.profile = PROFILE_DIR

driver = webdriver.Firefox(options=options)

driver.get("about:profiles")

input(
  "\nabout:profiles を開いています。\n"
  "「このプロファイルは現在使用中です」が\n"
  "指定した PROFILE_DIR になっていることを確認したら Enter を押してください..."
)

driver.quit()

実行

python profile_smoke.py
Selenium で Firefox を .env で指定した任意のプロファイルで起動する

profile_env_smoke.py として保存

import os
from dotenv import load_dotenv
from selenium import webdriver

load_dotenv()

PROFILE_DIR = os.getenv("PROFILE_DIR")
if not PROFILE_DIR:
  raise RuntimeError("PROFILE_DIR is not set in .env")

options = webdriver.FirefoxOptions()
options.profile = PROFILE_DIR

driver = webdriver.Firefox(options=options)

driver.get("about:profiles")

input(
  "\nabout:profiles を開いています。\n"
  "「このプロファイルは現在使用中です」が\n"
  "指定した PROFILE_DIR になっていることを確認したら Enter を押してください..."
)

driver.quit()

実行

python profile_env_smoke.py

成果物

作成したスクリプト(zaim_flow.py)

あとはひたすらChatGPTとラリーして実行してを繰り返した
自分が月イチローカルで動かすだけなので、作り甘いところあっても許容

from __future__ import annotations

import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path

from dotenv import load_dotenv
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException

@dataclass(frozen=True)
class Config:
  profile_dir: Path
  login_url: str
  import_export_url: str
  login_email: str
  login_password: str
  login_timeout_sec: int = 180

SEL_LOGIN_EMAIL = (By.NAME, "email")
SEL_LOGIN_PASSWORD = (By.NAME, "password")
SEL_LOGOUT = (By.CSS_SELECTOR, 'a[href="/user_session"][data-method="delete"]')
SEL_IMPORT_FILE = (By.CSS_SELECTOR, '#GeneralUploadFile[name="general_upload_file"][type="file"]')
SEL_UPLOAD_COLLAPSE_TOGGLE = (By.CSS_SELECTOR, 'h3.title[data-bs-toggle="collapse"][href="#collapseGeneralUpload"]')
SEL_LINES = [
  (By.NAME, "date_line"),
  (By.NAME, "category_line"),
  (By.NAME, "genre_line"),
  (By.NAME, "comment_line"),
  (By.NAME, "place_line"),
  (By.NAME, "from_account_line"),
  (By.NAME, "to_account_line"),
  (By.NAME, "name_line"),
  (By.NAME, "payment_line"),
  (By.NAME, "income_line"),
  (By.NAME, "transfer_line"),
  (By.NAME, "transfer_flag"),
  (By.NAME, "calc_line"),
]
SEL_FLASH_MESSAGE = (By.ID, "flashMessage")

def load_config() -> Config:
  load_dotenv()

  def must(name: str) -> str:
    v = os.getenv(name)
    if not v:
      raise RuntimeError(f"Missing required env var: {name}")
    return v

  profile_dir_raw = must("PROFILE_DIR")
  login_url = must("LOGIN_URL")
  import_export_url = must("IMPORT_EXPORT_URL")
  login_email = must("LOGIN_EMAIL")
  login_password = must("LOGIN_PASSWORD")
  login_timeout_sec = int(os.getenv("LOGIN_TIMEOUT_SEC", "180"))

  profile_dir = Path(profile_dir_raw).expanduser()

  if not profile_dir.exists():
    raise RuntimeError(f"PROFILE_DIR does not exist: {profile_dir}")
  if not profile_dir.is_dir():
    raise RuntimeError(f"PROFILE_DIR is not a directory: {profile_dir}")

  return Config(
    profile_dir=profile_dir,
    login_url=login_url,
    import_export_url=import_export_url,
    login_email=login_email,
    login_password=login_password,
    login_timeout_sec=login_timeout_sec,
  )

def is_logged_in(driver: webdriver.Firefox) -> bool:
  return bool(driver.find_elements(*SEL_LOGOUT))

def fill_login_inputs(driver: webdriver.Firefox, wait: WebDriverWait, email: str, password: str) -> None:
  email_el = wait.until(EC.presence_of_element_located(SEL_LOGIN_EMAIL))
  password_el = wait.until(EC.presence_of_element_located(SEL_LOGIN_PASSWORD))

  email_el.clear()
  email_el.send_keys(email)

  password_el.clear()
  password_el.send_keys(password)

def wait_for_manual_login(driver: webdriver.Firefox, timeout_sec: int) -> None:
  end = time.time() + timeout_sec
  while time.time() < end:
    if is_logged_in(driver):
      return
    time.sleep(0.5)
  raise TimeoutException(f"manual login timeout: {timeout_sec}s")

def select_by_value_selenium(
  driver: webdriver.Firefox,
  wait: WebDriverWait,
  locator: tuple,
  value: str,
) -> None:
  el = wait.until(EC.visibility_of_element_located(locator))
  wait.until(EC.element_to_be_clickable(locator))
  Select(el).select_by_value(value)
  wait.until(lambda d: d.find_element(*locator).get_attribute("value") == value)

def main() -> int:
  try:
    cfg = load_config()
  except Exception as e:
    print(f"[FATAL] config error: {e}", file=sys.stderr)
    return 10

  options = webdriver.FirefoxOptions()
  options.profile = str(cfg.profile_dir)

  driver = webdriver.Firefox(options=options)
  wait = WebDriverWait(driver, 30)

  try:
    # 1) ログインURLを開く
    driver.get(cfg.login_url)

    # 1.5) 未ログインならメール/パスワードを入力だけしておく(submitしない)
    if not is_logged_in(driver):
      fill_login_inputs(driver, wait, cfg.login_email, cfg.login_password)
      print("[INFO] メール/パスワードを入力済み(ログインボタンは手動)", file=sys.stderr)

    # 2-3) 手動ログイン待機 & ログイン完了判定
    if not is_logged_in(driver):
      print(
        f"[INFO] 手動でログインしてください({cfg.login_timeout_sec}秒でタイムアウト)",
        file=sys.stderr,
      )
      wait_for_manual_login(driver, cfg.login_timeout_sec)

    print("[INFO] ログイン完了を検知。5秒待機", file=sys.stderr)
    time.sleep(5

    # 4) ファイル入出力ページへ遷移
    print("[INFO] ファイル入出力ページ遷移", file=sys.stderr)
    driver.get(cfg.import_export_url)

    wait.until(EC.presence_of_element_located(SEL_IMPORT_FILE))
    print("[INFO] ファイル入出力ページ到達を確認。5秒待機", file=sys.stderr)
    time.sleep(5)

    # 5) アコーディオン展開(select群を表示させる)
    print("[INFO] アコーディオンを展開", file=sys.stderr)
    toggle = wait.until(EC.element_to_be_clickable(SEL_UPLOAD_COLLAPSE_TOGGLE))
    driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", toggle)
    toggle.click()

    # 6) 13個のselectを value=1..13 で順に選択
    for i, locator in enumerate(SEL_LINES, start=1):
      value = str(i)
      print(f"[INFO] select {locator} -> value={value}", file=sys.stderr)
      select_by_value_selenium(driver, wait, locator, value)

    print("[INFO] 全selectの選択完了。手動アップロード待機中...", file=sys.stderr)
    input("作業が終わったら return キーを押してください")
    driver.quit()

    return 0

  except TimeoutException as e:
    print(f"[ERROR] timeout: {e}", file=sys.stderr)
    return 3

if __name__ == "__main__":
  raise SystemExit(main())

ディレクトリ構成

├── .editorconfig
├── .env
├── .env.sample
├── .git
├── README.md
├── requirements.txt
└── scripts
    ├── test
    │     ├── env_check.py
    │     ├── profile_env_smoke.py
    │     ├── profile_smoke.py
    │     └── smoke.py
    └── zaim_flow.py

セットアップ手順

git clone 直後に初回だけ行う作業として以下を想定

Python環境の構築
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
環境変数の設定
cp .env.sample .env
vim .en

.env.sample から .env を作成し、中身を書き換える

  • PROFILE_DIR: Firefoxのプロファイルディレクトリを指定
  • LOGIN_EMAIL: Zaimのログインメールアドレス
  • LOGIN_PASSWORD: Zaimのログインパスワード

使い方

python scripts/zaim_flow.py
  1. 🤖 Firefoxが指定のプロファイルで起動し、Zaimログイン画面が表示される
  2. 🤖 メールアドレス・パスワードが自動入力される
  3. 👨 手動でログイン実行する
  4. 🤖 5秒待機後、入出力ページに自動遷移
  5. 🤖 5秒待機後、「一般的な CSV ファイルをアップロードする」のアコーディオン展開と、1〜13列目のプルダウン選択が自動実行される
  6. 👨 任意のCSVファイルをアップロードし、[アップロードをテスト]後、[本番の家計簿にアップロード]する
  7. 👨 return キーを押下で終了

Comments

  • スパム対策のため、コメント本文にURLが含まれている場合は「承認待ち」となり、すぐに投稿が反映されません。ご了承ください。
  • 公序良俗に反する内容、個人が特定できる情報、スパム投稿と思われるコメント等については、予告なく編集・削除する場合があります。