ActivepiecesでGASを置き換えてZaimに自動記帳する方法

PythonでZaim APIトークンをローカル取得する方法

自動化フローを自分のサーバーに移すというのは、言葉にすると簡単ですが、実際にやってみると細かいところで詰まることが多いです。この記事では、Activepieces を使って Google Apps Script(GAS)を置き換え、支払い通知メールから Zaim への自動記帳を実現した流れを記録しています。設計の考え方、フローの構成、核となるコード、そして見落とされがちな重複記帳の問題まで、一通りまとめました。

Zaim API の4つの認証情報をまだ取得していない方は、先にPythonでZaim APIトークンをローカル取得する方法を参照して前提条件を整えてから、この記事に進んでください。

なぜ GAS から Activepieces に移したのか

GAS は Google のサービスで、使い勝手は悪くありません。ただ、あくまでも他社のプラットフォームです。トリガーの挙動、実行回数の上限、アカウントのポリシー——これらは自分でコントロールできるものではありません。しばらく使い続けているうちに、「フローが他人の土地の上で動いている」という感覚がじわじわと気になってきました。何か問題が起きたとき、どこから調べればいいかもわかりにくい。

Activepieces はオープンソースの自動化プラットフォームで、自分のサーバーにデプロイして使えます。n8n と似た立ち位置ですが、Zaim のように公式コネクターが存在しないサービスを扱う場合、Activepieces の Code モジュールの方が使いやすいと感じました。n8n 側では OAuth 1.0a の署名処理がうまく通らなかったのに対し、Activepieces では Node.js で書いてすぐ動いた、というのが正直なところです。

ActivepiecesでZaim自動記帳を実現する OAuthの仕組み

移行後は、トリガーから記帳、プッシュ通知まで、フロー全体が自分のOracle Cloudサーバー上で完結しています。データが外部プラットフォームを経由することはありません。

フロー全体の構成

自動化の流れは合計8つのステップで構成されています。

1Gmail
トリガー
2Text Helper
金額抽出
3Text Helper
日付抽出
4Code
カテゴリID
5Code
ジャンルID
6Code
口座情報
7Code
Zaimへ記帳
8HTTP
ntfy通知

実際のフロー画面

ActivepiecesでZaim自動記帳 フロー全体のスクリーンショット

各ステップの役割を単一に絞ることで、デバッグがしやすくなります。運用が安定してきたら、ステップ4〜7を一つの大きな Code ブロックにまとめて、変数の受け渡しを減らすことも検討しています。

ステップ1:メール整形——生データから必要な情報だけを取り出す

支払い通知メールの原文は、HTMLタグ、定型文、広告リンクなどが混在していて、そのまま正規表現で処理しようとするとかなり煩雑になります。

そこで Gmail トリガーの前段として、n8n を使ってメール整形を行い、原文を3行のプレーンテキストに変換しています。

利用日時:2026-06-23
利用金額:3800
利用店舗:整形外科

この整形ステップ自体は n8n でなくても構いません。Gmail の内容を処理して別のメールとして送信できるツールであれば、何を使っても同じ役割を果たせます。重要なのは、下流の Activepieces が受け取るのが、フォーマットが一定のクリーンなテキストであるという点です。

整形されたメールが届くと、Activepieces の Gmail Trigger が起動してフローが始まります。

ステップ2・3:正規表現で金額と日付を抽出する

Activepieces の Text Helper ステップは正規表現での抽出に対応しています。整形済みの3行テキストを対象にするので、抽出ルールは非常にシンプルです。

  • 金額利用金額: の後ろの数字を取得し、カンマを除去して整数に変換
  • 日付利用日時: の後ろの YYYY-MM-DD 形式の文字列を取得

整形ステップでフォーマットの一貫性が保証されているため、エッジケースをほとんど考慮せずに済みます。抽出した値は Inputs の仕組みを使ってコードに渡します。

ActivepiecesでZaim自動記帳 Inputsの設定画面

ステップ4・5・6:カテゴリ / ジャンル / 口座のマッピング

現時点では、この3つのステップは固定値のハードコードで動かしています。このトリガー(整形外科クリニックの支払いメール)では、カテゴリID・ジャンルID・支払口座IDがあらかじめ決まっているので、Code ブロックで対応する数値を返すだけです。

今後の予定:対応する支払い元が増えてきたら、外部のマッピングテーブル(データベースや JSON ファイルなど)を参照して店舗名やメールの送信元から自動的に分類を決める仕組みに切り替えたいと思っています。GAS 時代は Google Sheets にマッピングを持たせていたので、Activepieces でも同じような考え方で実装できるはずです。

ステップ7:OAuth 1.0a で Zaim に書き込む

このフローの核心部分です。Zaim の API は OAuth 1.0a を採用しており、リクエストのたびに4つの認証情報(Consumer Key / Secret、Access Token / Secret)を使って HMAC-SHA1 署名をその場で計算する必要があります。Activepieces の Code ステップでは、Node.js の oauth パッケージを使ってこの処理を行います。

コードの構成は3つのパートに分かれています。

① 重複送信の防止

Gmail のトリガーは、同じメールに対して複数回起動することがあります。同じ支払いが二重に記帳されるのを防ぐため、実際に書き込む前にメールの message_id を使って Zaim の直近20件を照合し、すでにそのIDが comment 欄に含まれていれば処理をスキップします。

const duplicateCheck = await new Promise((resolve) => {
    consumer.get(checkUrl, ACCESS_TOKEN, ACCESS_TOKEN_SECRET, (error, data) => {
        if (error) { resolve(false); return; }
        try {
            const res = JSON.parse(data);
            const isDup = res.money.some(item =>
                item.comment && item.comment.includes(cleanMsgId)
            );
            resolve(isDup);
        } catch (e) { resolve(false); }
    });
});

② 記帳データの組み立て

前のステップから受け取った金額・日付・分類ID・口座IDを Zaim API が要求する形式に組み立てます。また、comment 欄に識別用のプレフィックスと message_id を書き込むことで、後から追跡しやすくしています。

let finalComment = "🤖Activepieces自動記帳";
if (custom_comment) finalComment += ` ${custom_comment.trim()}`;
if (message_id)     finalComment += `\n[ID: ${message_id.trim()}]`;
postData.comment = finalComment;

③ OAuth POST リクエスト

非同期の OAuth POST を Promise でラップしています。エラーが発生した場合は reject ではなく resolve で返すようにしていて、Zaim 側でエラーが起きてもフローがここで止まらず、次のステップの通知まで処理が進むようにしています。

ステップ8:ntfy でプッシュ通知を送る

記帳が完了したら、HTTP ステップで自前の ntfy サービスにプッシュ通知を送ります。ステップ7の戻り値に応じて、通知の内容を3パターンに分けています。

戻り値のステータス通知内容
success記帳した金額・日付・分類を表示し、書き込み成功を確認
skipped重複を検知してスキップした旨と、元の message_id を通知
errorZaim API のエラー詳細を通知し、原因調査を可能にする

ntfy はセルフホストでなくても問題ありません。公共サービスの ntfy.sh でも動作しますが、その場合はデータが第三者のサーバーを経由することになります。

GAS と Activepieces の比較

機能面での違いはほとんどありませんが、データの主権とプラットフォームへの依存度が根本的に異なります。

項目GAS 方式Activepieces 方式
実行環境Google のサーバー自前のサーバー(Oracle Cloud など)
トリガー方式時間トリガー(ポーリング)/ 工夫次第でイベント駆動も可能Gmail Trigger(イベント駆動)
分類マッピングGoogle Sheets による外部テーブル現状はハードコード、外部テーブル化を予定
実行履歴GAS 実行ログActivepieces の実行履歴(ステップごとに確認可能)
プラットフォーム依存Google アカウントと実行枠に依存自建サービスのみ、完全に自分の管理下
OAuth 処理GAS 組み込みの OAuth ライブラリNode.js の oauth パッケージで手動制御

気をつけておきたいポイント

メール整形は前提条件です。原文の通知メールをそのまま解析しようとすると、支払いサービスごとにフォーマットが違うため、メンテナンスコストが跳ね上がります。統一された3行フォーマットに整形しておくことで、後続の処理をシンプルに保てます。

重複チェックは省略できません。Gmail Trigger が同じメールに対して複数回起動するケースは、テスト中には再現しにくいものの、本番環境で長く動かしていると必ず遭遇します。message_id を comment に書き込んで事前照合する方法は、現時点で最もシンプルかつ確実な対策です。

エラーは reject ではなく resolve で返します。Activepieces の Code ステップで未捕捉の例外が発生すると、フローはそこで止まり、後続の ntfy 通知も届きません。エラーを resolve の戻り値として包んでおくことで、通知ステップが必ず実行されるようになります。

まとめ

フロー全体が動くようになってからは、1件の支払いがメールから Zaim への記帳、スマートフォンへの通知まで、数秒以内に人の手を介さず完了します。それ以上に大事なのは、フローの中に自分でコントロールできない外部プラットフォームが一切ない、という点です——n8n も、Activepieces も、ntfy も、すべて自前のサーバーで動いています。

次にやりたいのは、分類マッピングをハードコードから動的なテーブル参照に切り替えることです。これが実現すれば、新しい支払い元を追加するたびにコードを変更する必要がなくなります。うまくいったら、また別の記事として書く予定です。

現在の記帳コード(参考)

import { createAction } from "@activepieces/pieces-framework";
import OAuth from "oauth";

export const code = async (inputs) => {
    // 1. 上流から渡された動的パラメータを取得
    const amount = inputs.amount || 100;
    const category_id = inputs.category_id || "101";
    const genre_id = inputs.genre_id || "10101";
    const date = inputs.date || "2026-06-23";
    const place = inputs.place || "";
    const custom_comment = inputs.comment || "";
    const PAYMENT_ID = inputs.PAYMENT_ID || "";
    const message_id = inputs.message_id || ""; 

    // 2. Zaim Developers で取得した4つの永続キーを入力
    const CONSUMER_KEY = "xxxxxxxxxxxxxxxxxxx";
    const CONSUMER_SECRET = "xxxxxxxxxxxxxxxxxxx";
    const ACCESS_TOKEN = "xxxxxxxxxxxxxxxxxxx";
    const ACCESS_TOKEN_SECRET = "xxxxxxxxxxxxxxxxxxx";

    // 3. OAuth 1.0 クライアントのインスタンスを作成
    const consumer = new OAuth.OAuth(
        "https://api.zaim.net/v2/auth/request",
        "https://api.zaim.net/v2/auth/access",
        CONSUMER_KEY,
        CONSUMER_SECRET,
        "1.0",
        null,
        "HMAC-SHA1"
    );

    const zaimUrl = "https://api.zaim.net/v2/home/money/payment";

    // 重複防止:message_id が渡されている場合、Zaim の直近明細を照合する
    if (message_id && message_id.trim() !== "") {
        const cleanMsgId = message_id.trim();
        const checkUrl = "https://api.zaim.net/v2/home/money?limit=20";

        const duplicateCheck = await new Promise((resolve) => {
            consumer.get(checkUrl, ACCESS_TOKEN, ACCESS_TOKEN_SECRET, (error, data) => {
                if (error) {
                    resolve(false);
                    return;
                }
                try {
                    const res = JSON.parse(data);
                    const isDup = res.money.some(item => 
                        item.comment && item.comment.includes(cleanMsgId)
                    );
                    resolve(isDup);
                } catch (e) {
                    resolve(false);
                }
            });
        });

        if (duplicateCheck) {
            return {
                status: "skipped",
                message: "⚠️ 重複トリガーを検出。このメールはすでに Zaim に記帳済みのため、自動でスキップしました。",
                message_id: cleanMsgId
            };
        }
    }

    // 4. Zaim へ送信するデータを組み立てる
    const postData: Record<string, string> = {
        category_id: String(category_id),
        genre_id: String(genre_id),
        amount: String(amount),
        date: String(date)
    };

    if (PAYMENT_ID && String(PAYMENT_ID).trim() !== "") {
        const cleanId = String(PAYMENT_ID).trim();
        postData.payment_id = cleanId;
        postData.from_account_id = cleanId;
        postData.to_account_id = cleanId;
    }

    if (place && String(place).trim() !== "") {
        postData.place = String(place).trim();
    }

    let finalComment = "🤖Activepieces自動記帳";
    if (custom_comment && String(custom_comment).trim() !== "") {
        finalComment += ` ${String(custom_comment).trim()}`;
    }
    if (message_id && message_id.trim() !== "") {
        finalComment += `\n[ID: ${message_id.trim()}]`;
    }
    postData.comment = finalComment;

    // 5. 非同期の OAuth POST リクエストを Promise でラップ
    return new Promise((resolve, reject) => {
        consumer.post(
            zaimUrl,
            ACCESS_TOKEN,
            ACCESS_TOKEN_SECRET,
            postData,
            "application/x-www-form-urlencoded",
            (error, data, response) => {
                if (error) {
                    resolve({
                        status: "error",
                        message: "Zaim への記帳に失敗しました",
                        detail: error
                    });
                } else {
                    resolve({
                        status: "success",
                        message: "家計簿と口座情報の同期が完了しました",
                        zaim_response: JSON.parse(data)
                    });
                }
            }
        );
    });
};
シェア・購読:
🧡 この記事が気に入ったら、RSSフィードを購読して最新の更新を受け取りましょう。