money-lens:ブラウザ内で完結するCSV分析ツールの実装
長期休暇を利用して、マネーフォワード MEからダウンロードしたCSVを読み込み、収入・支出・収支や資産の推移を振り返る個人用ダッシュボード「money-lens」を作った。
実装は完全にCodexに任せた。Issueの洗い出しをAstraがやって、実装は大体luna、リリース前の終盤はAstraかSolが担当した。
lunaから切り替えたのは、終盤になるとIssueに書かれた課題をきちんと解消してくれないことが増えたため。もっといい方法はありそうだが……。
個人的に、マネーフォワードでは見られなかった年度での集計などが欲しくて作ったのだけど、開発の自動化を試す機会にもなり、長めの休暇の遊びとしてはそれなりに有益だった。
ここからは、できあがったものの構成や、CSVの再取り込み時のデータの扱いなど、実装面を中心に紹介する。
公開デモは合成データで試せる。money-lensは非公式の独立したツールで、マネーフォワード MEへのログインや自動連携は行わず、自分で取得したCSVを読み込む。
構成
アプリ本体は、画面を定義するindex.html、スタイルのstyles.css、読込・集計・描画を担当するapp.jsで構成している。フロントエンドのフレームワークや、実行時に必要な外部ライブラリは使っていない。グラフもHTMLとCSSで描画している。
手動でCSVを選択する使い方なら、index.htmlを直接ブラウザで開くだけで動く。Node.jsはチェックや公開用ファイルの生成に使うもので、利用者が用意する必要はない。
データの流れは次のようになる。
- File APIで選択したCSVを読み込む
- 文字コードをデコードしてCSVを解析する
- ヘッダーや各行の値を検証する
- 既存の明細・資産データとマージする
- ブラウザ内で集計し、グラフと明細を描画する
手動選択したCSVをサーバーにアップロードする処理はなく、読み込んだデータはページを開いている間だけメモリ上に保持する。再読み込みすると消えるので、毎回読み込む手間はあるが、データベースや保存データの管理は必要ない。
CSVは、読み込めることと正しく扱えることが別
CSVの処理では、カンマで区切るだけでは足りない。引用符で囲まれたセルにはカンマや改行が入ることもあり、引用符自体のエスケープも考える必要がある。
CSVをパースするための関数では引用符の内側かどうかを追跡して解析し、各行に元の行番号も持たせている。これにより、不正な行を除外したときに、ファイル名と行番号を画面に出せる。
文字コードとヘッダー
文字コードはUTF-8とShift_JISに対応している。基本的にはTextDecoderでUTF-8としてデコードを試し、失敗した場合にShift_JISを試す。fatal: trueを指定することで、不正なバイト列を置換文字に変えたまま取り込まないようにしている。
CSVの種類はファイル名ではなくヘッダーから判定する。明細なら日付と金額、資産なら日付と合計に相当する列を探す。
ヘッダーは空白や末尾の単位表記を正規化し、内容と摘要、大項目とカテゴリといった表記の違いにも対応する。一方で、識別に使う列が重複しているなど、解釈が曖昧になるCSVはエラーにしている。
セルに格納された値の検証
日付は形式だけでなく、実在する日付かどうかも確認する。JavaScriptのDateは範囲外の日付を繰り上げるので、生成した日付の年・月・日が入力値と一致するかをチェックする。
金額も、単にparseIntで読めた部分だけを採用するのではなく、符号、3桁区切り、通貨記号などの形式を検証してから数値に変換する。明細の金額はNumber.isSafeIntegerの範囲に制限し、収入・支出などの合計にはBigIntを使っている。
入力値の検証と集計を分けておくことで、読めなかった値をいつの間にか0円として扱うような集計を避けられる。
再取り込み時の処理
複数のCSVを読み込めるようにすると、期間が重なるファイルや、同じ明細を訂正してダウンロードしたファイルをどう扱うかが問題になる。
money-lensでは、明細に安定したIDがあるかどうかで処理を分けている。
IDがある場合は更新する
ID・明細ID・取引IDのいずれかがある場合は、そのIDで照合する。同じIDなら後から取り込んだ内容で更新し、別のIDなら内容が同じでも別明細として保持する。
また、計算対象を1から0へ訂正したCSVを再取込した場合は、同じIDの明細を集計・一覧から除外する。単に対象外の行を読み飛ばすだけだと、以前取り込んだ明細が残ってしまうため、この訂正もマージに反映している。
IDがない場合は内容と件数で照合する
IDがない場合のキーは、日付・内容・カテゴリ・金額の組み合わせになる。
ただし、キーをSetで管理して同じ内容をすべて除外すると、同じ日に同じ買い物を2回したような明細まで1件になってしまう。そこで、既存データにあるキーごとの件数を数え、取込側の明細と1件ずつ照合している。
例えば、既存データに同一内容の明細が2件あり、再取込したCSVにも2件あれば追加しない。取込側に3件あれば、照合しきれない1件を追加する。この処理で、同一ファイル内の同じ内容の複数行を保持しつつ、再取込による二重計上を抑えている。
もっとも、IDがなければ「別ファイルにある、本当に別の同一内容の明細」と「重複して出力された明細」を完全には区別できない。この部分は推測で解決できないので、安定したIDがある場合はそちらを優先する設計になっている。
資産は明細とは別で、同じ日付の記録を後から読み込んだ値で置き換える。同じCSV取込でも、データの意味に合わせてマージのルールを変えている。
読み込み中の操作への対応
ファイルを読み込んでいる途中で「データをクリア」したり、デモデータへ切り替えたりすると、先に始めた読み込みが後から完了することがある。その結果をそのまま反映すると、消したはずのデータが戻ってしまう。
この対策として、読み込み処理にはloadGenerationという世代番号を持たせている。中止やデータのクリアで番号を進め、開始時の番号と一致しなくなった処理の結果は反映しない。
ただし、これで処理そのものが即座に止まるわけではない。CSVの解析は同期処理なので、大きなファイルの解析中は中止操作への反応が遅れることがある。このあたりはまだ改善の余地がある。
公開デモ
公開デモはGitHub Pagesで配信している。scripts/build-pages.mjsで公開用ディレクトリを作り、次の4ファイルだけをコピーする。
index.htmlstyles.cssapp.jsfavicon.svg
公開するファイルを限定し、生成後にファイル一覧も検証する。手元のCSVを公開用ファイルに含めないための構成になっている。
また、公開用のapp.jsではデモモードを有効にする。ローカルHTTPサーバーで使うCSVの自動探索は行わず、合成データで画面を表示する。アプリ本体は共通なので、自分のCSVを用意しなくてもグラフや明細の操作を試せる。
検証と残っている課題
検証にも個人のCSVではなく、サンプルや合成データを使っている。文字コード、日付、金額、列の曖昧さ、重複・更新など、ここまでに紹介した処理をスクリプトで確認する。
それとは別に、Playwrightで画面や操作を確認するUI監査と、大量データを使った性能調査も用意している。集計結果が正しいことと、ブラウザで快適に使えることは別なので、両方を確認する必要がある。
明細一覧はページ分割し、5,000件を超える場合は検索入力に120ミリ秒のデバウンスを入れている。入力のたびに検索せず、少し待ってから処理することで、連続入力時の負荷を抑える。
一方、多数のCSVを読み込むとファイルごとに集計・描画が繰り返される。先ほどの中止操作への反応も含め、大量データを扱う場合の課題は残っている。
まとめ
年度での集計が見たい、という個人的な用途から始めたものだけど、CSVを読み込んでグラフにするだけでも、入力値の検証や重複・訂正の扱いなど、考えることはそれなりにある。画面の構成は小さくても、継続して使うための処理は意外と多い。
実装をAIに任せる試みとしても、Issueの洗い出しから実装まで進められた一方、終盤には担当するモデルを切り替える場面もあった。どこまで任せるか、課題をどう伝えるかは、まだ試しようがありそう。
ひとまず、欲しかった集計ができるものは作れたし、開発の自動化も試せたので、休暇中の個人開発としてはよかったと思う。