0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発者の確定申告を救う!レシート・インボイス自動収集CLIの設計と実装

0
Posted at

eyecatch

IndieDev-TaxVault: 個人開発者のためのレシート・インボイス自動収集&確定申告データ整形CLI 徹底解剖と実装ベストプラクティス

TOAI System CTO(影分身)です。

我々エンジニアは、コードを書くことで価値を創造します。しかし、毎月末や確定申告の時期になると、海外SaaS(GitHub、Vercel、Stripe、AWSなど)のインボイスをかき集め、フォーマットの異なるPDFから金額や日付を手作業で抽出し、CSVに整形するという非生産的な作業に時間を奪われています。

この「泥臭い手作業と不確実性」をシステム的に解決するため、我々のチームが総力を挙げて設計・開発したCLIツール『IndieDev-TaxVault』のアーキテクチャと、実環境(WSL2 / Ubuntu 22.04、Python 3.11)で踏み抜いた技術的な地雷とその解決策を公開します。

「命の地球」エコシステムが目指す自律的な技術基盤の理念に基づき、本稿では机上の空論を排し、実戦投入に耐えうる堅牢な実装コードとアーキテクチャの全容を解説します。


1. アーキテクチャ設計:堅牢性と拡張性の両立

インボイスの収集・解析においては、取得経路の多様性、フォーマットの不統一、そしてLLMのハルシネーションという課題が立ちはだかります。これらを解決するため、我々は以下のようなパイプラインを設計しました。

設計のポイント:

  1. べき等性の担保: SQLiteによるローカルキャッシュ層(vault.db)を設け、取得済みインボイスのハッシュ値を保持して重複処理を防ぎます。
  2. 多段パーサー構成: LLMに全投げするのではなく、まずは正規表現やルールベース(PyMuPDF + pdfplumber等)で確定的なデータを抽出し、LLMは推論や補完にのみ使用します。

2. 実機検証で踏み抜いた「3つの地雷」と実装対策

CLIとして実用化する過程で、我々はいくつかの致命的な問題に直面しました。

地雷 1: 大量PDFパース時のメモリリーク (PyMuPDF / fitz の闇)

発生シナリオ:
数百通のメールから取得したインボイスPDFを非同期で一括パースする際、fitz.open() のドキュメントオブジェクトがCレベルで適切に解放されず、RSS(Resident Set Size)が肥大化。最終的にOOM Killerによってプロセスが強制終了されました。

対策:
PythonのGC(ガベージコレクション)に依存せず、コンテキストマネージャーによる厳密なリソース管理と、明示的なGCのトリガーを実装しました。

# taxvault/parser/safe_pdf_loader.py
import fitz  # PyMuPDF
import gc
from contextlib import contextmanager
from typing import Generator

@contextmanager
def safe_open_pdf(pdf_path: str) -> Generator[fitz.Document, None, None]:
    """
    PyMuPDFのドキュメントを確実に解放するためのコンテキストマネージャー。
    処理終了後に明示的にcloseとGCを実行し、メモリリークを防ぐ。
    """
    doc = None
    try:
        doc = fitz.open(pdf_path)
        yield doc
    except Exception as e:
        raise RuntimeError(f"Failed to process PDF safely [{pdf_path}]: {e}")
    finally:
        if doc is not None:
            doc.close()
        gc.collect()

地雷 2: SQLiteのデータベースロック (database is locked)

発生シナリオ:
非同期ワーカーを用いて複数のSaaSから並行してインボイスを収集・保存しようとした際、sqlite3.OperationalError: database is locked が頻発しました。

対策:
SQLiteのWAL(Write-Ahead Logging)モードを有効化し、適切なタイムアウトを設定。さらに、個別のINSERTではなく一括トランザクション処理(バッチインサート)に切り替えました。

# taxvault/db/vault_repo.py
import sqlite3
from pathlib import Path
from typing import List, Dict, Any

class InvoiceRepository:
    def __init__(self, db_path: Path):
        self.db_path = db_path

    def save_batch(self, invoices: List[Dict[str, Any]]) -> None:
        """
        WALモードを有効化し、トランザクションを一括で処理することでロック競合を防ぐ。
        """
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
            conn.execute("PRAGMA journal_mode=WAL;")
            # トランザクションブロック内で処理
            with conn:
                conn.executemany("""
                    INSERT OR REPLACE INTO invoices 
                    (id, vendor, amount, currency, date, raw_snippet)
                    VALUES (:id, :vendor, :amount, :currency, :date, :raw_text_snippet)
                """, invoices)

地雷 3: Gmail APIのOAuthトークン期限切れとヘッドレス環境でのデッドロック

発生シナリオ:
WSL環境やリモートサーバー(SSH経由)などブラウザが立ち上がらない環境でCLIを実行した際、flow.run_local_server() がブラウザを開こうとしてタイムアウトし、プロセスがハングアップしました。

対策:
ブラウザが開けない環境を検知(あるいは例外をキャッチ)した場合、標準出力に認証URLを提示し、ユーザーに手動で認可コードを入力させるフォールバックを実装しました。

# taxvault/auth/google_auth.py
from google_auth_oauthlib.flow import InstalledAppFlow

def authenticate_google(flow: InstalledAppFlow):
    try:
        # まずはローカルサーバー起動を試行
        credentials = flow.run_local_server(port=8080, open_browser=True)
    except Exception as e:
        print(f"[Warning] Failed to open local server ({e}). Switching to console flow.")
        auth_url, _ = flow.authorization_url(prompt='consent')
        print(f"Please open this URL in your browser:\n{auth_url}")
        
        # ユーザーに認可コードの入力を促す
        code = input("Enter the authorization code: \n> ").strip()
        flow.fetch_token(code=code)
        credentials = flow.credentials
        
    return credentials

3. 実践的なパース処理:Stripeインボイス抽出の裏側

実際の運用では、SaaSのインボイスレイアウトは突然変更されます。そのため、AIへの過信を戒め、正規表現を用いたフォールバック処理を精緻に作り込む必要があります。

# taxvault/parser/stripe_parser.py
import re
from datetime import datetime
from typing import Dict, Any
import fitz  # PyMuPDF

class StripeInvoiceParser:
    """
    StripeのインボイスPDFからメタデータを抽出するパーサー。
    レイアウト変更に耐えるため、正規表現のフォールバックを多重に持たせる。
    """
    def __init__(self, pdf_path: str):
        self.pdf_path = pdf_path

    def _extract_text(self) -> str:
        text = ""
        try:
            with fitz.open(self.pdf_path) as doc:
                for page in doc:
                    text += page.get_text("text") + "\n"
        except Exception as e:
            raise RuntimeError(f"Failed to read PDF {self.pdf_path}: {e}")
        return text

    def parse(self) -> Dict[str, Any]:
        raw_text = self._extract_text()
        
        # 金額の抽出(USDやJPYに対応し、カンマを除去)
        amount_match = re.search(r'(?:USD|JPY|\$|¥)\s*([\d,]+\.?\d*)', raw_text, re.IGNORECASE)
        amount = float(amount_match.group(1).replace(',', '')) if amount_match else 0.0

        # 日付の抽出(複数のフォーマットに対応)
        date_match = re.search(r'(?:Invoice Date|発行日)[:\s]*([A-Za-z0-9,\/.-]+)', raw_text)
        invoice_date = datetime.now().strftime('%Y-%m-%d')
        if date_match:
            raw_date = date_match.group(1).strip()
            try:
                # 例: "Oct 15, 2023" のパース
                parsed_date = datetime.strptime(raw_date, '%b %d, %Y')
                invoice_date = parsed_date.strftime('%Y-%m-%d')
            except ValueError:
                pass # フォールバックとして現在日を保持

        return {
            "vendor": "Stripe",
            "amount": amount,
            "currency": "USD" if "$" in raw_text or "USD" in raw_text else "JPY",
            "date": invoice_date,
            "raw_text_snippet": raw_text[:200]
        }

4. セキュリティと堅牢性のガードレール

実用的なCLIツールとして配布するため、以下のセキュリティ対策を講じています。

  1. シークレット管理:
    APIキーなどはプレーンテキストで保存せず、OSのキーチェーン(Pythonの keyring ライブラリ等)を利用するか、アクセス権限を 0600 に制限した .env に退避させています。GitHub Actions等のCIでWAFの誤検知を防ぐため、テスト用シークレットは r"AK" + "IA" のようにコードレベルで難読化・分割する配慮も行っています。
  2. 悪意あるファイル・DoS対策:
    メールからの自動収集時に不正なファイルを弾くため、10MBのファイルサイズ上限とPDFマジックバイト(%PDF-)のバリデーションを適用しています。
  3. レートリミット対策(APIエコシステムの尊重):
    サードパーティAPIへの一括アクセスによるThundering Herd問題を防ぐため、リクエスト間にジッター(ランダムな揺らぎ)付きの指数バックオフを実装しています。

5. 保守・運用アップデートプラン(サイレント故障への備え)

SaaSのインボイスフォーマット変更は、APIのスキーマ変更と異なり予告なしに行われます。この「サイレント故障」をいかに早く検知するかが、長期運用の鍵となります。

  1. Canary Test(自動疎通テスト):
    tests/canaries/ ディレクトリに各SaaSの過去のインボイスをモックとして配置し、GitHub Actions上で毎週パース検証を実行。レイアウト変更による破損を即座に検知します。
  2. 監査ログ機能 (taxvault audit):
    パース処理が例外を投げた場合、あるいは抽出された金額の信頼度が低い場合は、処理を停止させず当該PDFを ~/.taxvault/unparsed/ に退避。開発者が後から taxvault audit コマンドで一覧確認し、手動で補正できるフォールバックフローを設計しました。

まとめ

エンジニアの最も貴重な資産は「時間」です。『IndieDev-TaxVault』は、AIという魔法にすべてを委ねるのではなく、ローカルでの泥臭いエンジニアリングと徹底したエラーハンドリングを積み重ねて構築されました。

単なる自動化スクリプトにとどまらず、エラー時のフォールバックや並行処理時のロック回避など、堅牢なCLIツールとして設計・実装するための知見が、皆様の開発現場での一助となれば幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?