先日、会計freeeから、以下のような無慈悲なアナウンスがありました。

2月24日(木)以降は、楽天銀行のインターネットバンキングから口座明細データのCSVファイルをダウンロードしfreee会計にアップロードする対応を行っていただく必要があります。

いや、手作業を減らしたくて会計freeeにしたのに、それはないですよfreeeさん、って感じです。

目の前には、以下の三つの選択肢があります。

  • freeeをやめる
  • 楽天銀行をやめる
  • 手作業を受け入れる

どれも選びたくありません。。
よくよく考えると自分はエンジニアなので、第四の選択肢として「スクレイピングして明細を自動で取り込む」ことができそうですので、やってみることにしました。

作るもの

Node.jsで、puppeteer を利用して、楽天銀行の個人ビジネス口座の利用明細CSVをダウンロードして、会計freeeの口座情報に明細アップロードするスクリプトを作ります。
また、Macに元々インストールされているAutometer.appとカレンダー.appを使って、スクリプトを定期的に実行するようにしようと思います。
https://github.com/rinoguchi/link_freee_rakuten

インストール

必要なパッケージをインストールします。

yarn add -D @types/dotenv @types/puppeteer dotenv puppeteer typescript

puppeteerがスクレイピング用で、
dotenvは、.envに記載した内容を環境変数に読み込むために利用します。

スクリプトの作成

スクレイピング処理の実装

puppeteerを利用して、スクレイピングを行うスクリプトを作成します。
基本的な機能しか使ってないので、読めば大体わかると思います。
一応、多少工夫したポイントを列挙しておきます。

  • ヘッドレスだとうまく動かないので、puppeteer.launch({ headless: false });で実際にブラウザを起動する必要があります。
  • page.gotopage.clickで画面遷移する際は、画面がロードされて通信が完了するまで待つ必要があります。そうしないと要素が描画されておらず処理が失敗します。
  • 今回は Top Level Await を使っているので、tsconfig.json"target": "ES2017", "module": "ES2022"の設定が必要です。
  • 利用明細はデフォルトで直近3ヶ月分が対象になりますが、会計freeeにアップロードした際にすでにアップロード済みの明細は取り込み対象外になるので、大丈夫です。
import puppeteer, { Browser } from 'puppeteer';
import 'dotenv/config';
import fs from 'fs';

/** 楽天銀行個人ビジネス口座よりCSVダウンロード */
const downloadFromRakuten = async (browser: Browser) => {
  // 既存のCSVファイルがあれば削除
  try {
    fs.unlinkSync(process.env.CSV_PATH as string);
  } catch (error) {
    console.log('csv file is not found.');
  }

  // タブを開く
  const page = await browser.newPage();
  await page.goto(process.env.RAKUTEN_LOGIN_URL as string, { waitUntil: ['load', 'networkidle0'] });

  // ログイン
  await page.type('#LOGIN\\:USER_ID', process.env.RAKUTEN_ID as string);
  await page.type('#LOGIN\\:LOGIN_PASSWORD', process.env.RAKUTEN_PASSWORD as string);
  await Promise.all([page.waitForNavigation({ waitUntil: ['load', 'networkidle0'] }), page.click('#LOGIN\\:_idJsp43')]);

  // 入出金明細へ遷移
  await Promise.all([
    page.waitForNavigation({ waitUntil: ['load', 'networkidle0'] }),
    page.click('#FORM2\\:_idJsp244'),
  ]);

  // CSVダウンロード
  await page.click('#FORM_DOWNLOAD\\:_idJsp581');
  await page.waitForTimeout(5000);
};

/** 会計freeeにCSVアップロード */
const uploadToFreee = async (browser: Browser) => {
  // タブを開く
  const page = await browser.newPage();
  await page.goto(process.env.FREEE_LOGIN_URL as string, { waitUntil: ['load', 'networkidle0'] });

  // ログイン
  await page.type('#user_email', process.env.FREEE_ID as string);
  await page.type('.login-form [name=password]', process.env.FREEE_PASSWORD as string);
  await Promise.all([
    page.waitForNavigation({ waitUntil: ['load', 'networkidle0'] }),
    page.click('.login-form .login-button'),
  ]);

  // 口座画面に直接遷移
  await page.goto(process.env.FREEE_BANK_ACCOUNT_URL as string, { waitUntil: ['load', 'networkidle0'] });

  // 明細アップロードボタンをクリック
  await Promise.all([page.waitForNavigation({ waitUntil: ['load', 'networkidle0'] }), page.click('#wtxns-upload')]);

  // CSVファイルを選択
  const el = await page.$('#ofx');
  if (!el) return;
  await el.uploadFile(process.env.CSV_PATH as string);

  // 次へボタンをクリック
  await Promise.all([
    page.waitForNavigation({ waitUntil: ['load', 'networkidle0'] }),
    page.click('form .btn[type=submit]'),
  ]);

  // 結果をログ出力
  const message = await page.$eval('.notification-message', (item) => {
    return item.textContent;
  });
  console.log(message);
};

// メイン処理
const browser = await puppeteer.launch({ headless: false });
await downloadFromRakuten(browser);
await uploadToFreee(browser);
await browser.close();

このスクリプトは、個人ビジネス口座専用です。
普通の楽天銀行口座だとおそらくうまく動きません。
(サイトの作りはほぼ同じっぽいのでセレクタを適当に書き換えれば動くような気はしますが)

.envの作成

ID/パスワードなどは、Gitリポジトリに登録できないので、一旦環境変数から読み込むようにしようと思います。
もちろんこの.env.gitignoreに記載して、Git管理対象外にしてます。

RAKUTEN_ID=xxxxxxxxxx
RAKUTEN_PASSWORD=xxxxxxxxxx
RAKUTEN_LOGIN_URL=https://fes.rakuten-bank.co.jp/MS/main/RbS?CurrentPageID=START&&COMMAND=LOGIN
CSV_PATH=/Users/xxxxxxxxxx/Downloads/RB-torihikimeisai.csv
FREEE_ID=xxxxxxxxxx
FREEE_PASSWORD=xxxxxxxxxx
FREEE_LOGIN_URL=https://secure.freee.co.jp
FREEE_BANK_ACCOUNT_URL=https://secure.freee.co.jp/bank_account/walletables/xxxxxxxxxx

.envに記載した変数は、dotenvを利用することで、以下のような感じでプログラム内で利用できます。

import 'dotenv/config'; // process.envに取り込み
console.log(process.env.RAKUTEN_ID);

ただし、ローカルPC上のファイルにID/パスワードを保存していること自体がかなり危険です。
ウイルスに感染してファイルが流出してしまうかもしれません。

なので、一旦この記事ではこの形でやってみましたが、最終的には別の方法でクレデンシャルを管理する予定です。(詳細はここには書きません)

実行

package.jsonにスクリプト定義を追加して

  "scripts": {
    "execute": "tsc && node dist/script.js"
  },

以下のコマンドで実行できます。

yarn execute

これまで10回ぐらい実行してみましたが、安定して全てうまく動きました。

定期実行

毎度手作業で実行するのはだるいので、定期実行するようにしてみます。
とりあえず、Macに初期装備されているAutometerを使ってやってみようと思います。

Autometerのアプリケーションを作成

連携スクリプトを実行するシェルスクリプトをアプリケーション(実行ファイル)として作成します。

  1. アプリケーションフォルダに入っているAutometer.appを起動
  2. 書類の種類で「アプリケーション」を選択
  3. 「シェルスクリプトを実行」を選択し、以下のようなスクリプトを入力
    source ~/.bash_profile # PATHを通すため
    cd xxx/link_freee_rakten # スクリプトを配備したフォルダを指定
    yarn execute # スクリプト実行
  4. 試しに画面右上の「実行▶︎」をクリック
    => スクリプトが実行されればOK
  5. ファイルを保存
    => 任意のフォルダに保存する

カレンダーで定期実行

最後にカレンダーの通知機能を使って、先ほど作成したAutometerアプリケーションを定期的に実行します。

  1. アプリケーションフォルダに入っているAutometer.appを起動
  2. 右クリックで「新規イベント」を作成
  3. 日時設定
    • 繰り返し設定で定期実行にする
  4. 通知設定
    • 通知で「カスタム」を選択
    • 「ファイルを開く」を選択
    • 「その他」を選択
    • ファイル選択ダイアログで先ほど作成したAutometerアプリケーションを選択
    • 「イベント開始時刻」を選択
  5. 設定内容
  6. 初回実行時確認
    初回実行時だけ以下のダイアログが表示されるので、「OK」をクリックします。
    二回目以降は表示されないので安心してください。

これで、指定した時間にスクリプトが定期実行されるようになっていると思います。

トラブルシューティング

puppeteerのインポートでSyntaxError

普通にimportしようとすると、以下のエラーが発生するのですが、

import puppeteer from 'puppeteer';
^^^^^^
SyntaxError: Cannot use import statement outside a module

package.jsonに以下を追加することで回避できました。

  "type": "module"

明細の重複

Freeeの口座連携で同期されている間は、明細が重複します。
これは、微妙に取引明細の「入出金内容」の文字列が、自動で同期する場合とCSVファイルダウンロードする場合で異なることが原因です。
Freeeの口座の設定で「認証の削除」をすることで同期が停止するので、それで対処できます。
また重複してしまったものに関しては、Freee上で片方を「明細を無視」してしまえばOKでしす。

免責事項

この記事はあくまで情報提供を目的としたものです。
本情報を利用したことにより生じたいかなる損害についても、私は一切の責任を負うことができませんので、利用される場合は自己責任でお願いします。

最後に

今回、楽天と会計Freeeの連携の利用明細を定期的に連携する仕組みを作ってみましたが、結構すんなり作ることができました。
しかし、ここで紹介したID/パスワードの管理方法はセキュリティ的に危険がありますし、サイトの規約としてスクレイピング自体禁止されているかもしれません。
自分も作ってみたものの実際にこれを動かすかは微妙だな、と感じています。

というわけで、参考にされる方は自己責任でよろしくお願いします。