用 Activepieces 替代 GAS 实现 Zaim 自动记账

用 Activepieces 替代 GAS 实现 Zaim 自动记账

把自动化流程搬到自己服务器上这件事,说起来轻巧,真正做下来每一步都要填坑。这篇文章记录的是用 Activepieces 替代 Google Apps Script(GAS),实现支付通知邮件自动记账到 Zaim 的完整方案——包括思路、流程设计、核心代码,以及那个最容易被忽略的重复记账问题。

如果你还没有拿到 Zaim API 的四个凭证,请先参照用 Python 本地获取 Zaim API Token 全流程完成前置步骤,再来看这篇。

为什么要从 GAS 迁移到 Activepieces

GAS 是 Google 的,用起来方便,但它终究是别人家的平台。触发器的行为、执行限额、账号策略,这些都不是自己能控制的。用了一段时间之后,一直有一种感觉:流程跑在别人地盘上,哪天出问题也不知道从哪查起。

Activepieces 是开源的自动化平台,可以部署在自己的服务器上。和 n8n 类似,但我在对接 Zaim 这类没有官方连接器的服务时,发现 Activepieces 的 Code 模块更顺手——在 n8n 那边没跑通的 OAuth 1.0a 签名逻辑,在 Activepieces 里用 Node.js 写完就直接过了。

用 Activepieces 替代 GAS 实现 Zaim 自动记账 oauth

迁移之后,整条流程从触发到记账到推送通知,全部跑在自己的甲骨文云上,数据不经过任何第三方平台。

整体流程一览

整条自动化链路共 8 个步骤,结构如下:

1Gmail
触发器
2Text Helper
提取金额
3Text Helper
提取日期
4Code
カテゴリID
5Code
ジャンルID
6Code
口座情報
7Code
记账到 Zaim
8HTTP
ntfy 通知

实际截图是这样

用 Activepieces 替代 GAS 实现 Zaim 自动记账 目前的总流程

每个步骤职责单一,便于调试。等运行稳定之后,步骤 4~7 可以考虑合并成一个较大的 Code 块,减少变量传递的层数。

步骤一:邮件整形,从原始通知提取干净数据

支付类通知邮件的原始格式通常很乱——HTML 标签、客服套话、广告链接混在一起。直接对这种原始邮件做正则匹配,维护起来非常痛苦。

我的做法是在 Gmail 触发之前,先用 n8n 做一次邮件整形,把原始邮件转成只有三行的纯文本:

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

整形这一步本身并不一定要用 n8n,任何能处理 Gmail 内容并重新发送邮件的工具都可以承担这个角色。关键是让下游的 Activepieces 拿到的是干净的、格式固定的文本,而不是原始的富文本邮件。

整形后的邮件触发 Activepieces 的 Gmail Trigger,流程正式启动。

步骤二、三:用正则式提取金额和日期

Activepieces 的 Text Helper 步骤支持正则表达式提取。针对整形后固定格式的三行文本,提取规则非常简洁:

  • 金額:匹配 利用金額: 后面的数字,去掉逗号,转为整数
  • 日付:匹配 利用日時: 后面的 YYYY-MM-DD 格式日期

因为整形这一步已经保证了格式的一致性,这里的正则可以写得很简单,不需要考虑各种边界情况。提取后用Inputs的方式把信息插入代码即可。

用 Activepieces 替代 GAS 实现 Zaim 自动记账 inputs

步骤四、五、六:カテゴリ / ジャンル / 口座的映射

这三个步骤目前用的是写死的固定值——针对这个特定触发器,カテゴリ(大分类)ID、ジャンル(小分类)ID、支払口座(支付账户) ID 都是确定的,直接在 Code 块里返回对应的数字即可。

后续计划:随着接入的支付来源越来越多,打算改成参照一个外部映射表(比如存在数据库或 JSON 文件里),根据店铺名称或邮件来源自动匹配分类,而不是每增加一个场景就手动改一次代码。GAS 时代的做法是把映射关系放在 Google Sheets 里,Activepieces 这边可以用类似的思路实现。

步骤七:通过 OAuth 1.0a 写入 Zaim

这是整个流程最核心的部分。Zaim 的 API 走的是 OAuth 1.0a,每次请求都需要用四个凭证(Consumer Key / Secret、Access Token / Secret)现场计算 HMAC-SHA1 签名。在 Activepieces 的 Code 步骤里,用 Node.js 的 oauth 包来处理这件事。

代码结构分三个部分:

① 防重复触发

Gmail 的触发器偶尔会对同一封邮件触发多次。为了避免同一笔消费被记两遍,在真正写入之前,先用邮件的 message_id 去查 Zaim 最近 20 条记录,如果 comment 字段里已经包含了这个 ID,直接返回跳过:

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 请求

用 Promise 封装异步的 OAuth POST,错误时用 resolve 而非 reject,确保即使 Zaim 返回错误,流程也不会在这一步静默中断,而是把错误信息带到下一步的通知里。

步骤八:ntfy 推送通知收尾

记账完成后,通过 HTTP 步骤向自建的 ntfy 服务发送推送,手机上会实时收到通知。通知内容根据步骤七的返回值区分三种状态:

返回状态通知内容
success记账金额、日期、分类,确认写入成功
skipped检测到重复,已拦截,附原始 message_id
errorZaim API 错误详情,方便排查

ntfy 自建与否都不影响这一步的使用,用公共服务 ntfy.sh 也完全可以,只是数据会经过第三方服务器。

GAS 方案与 Activepieces 方案的对比

两套方案在功能上基本等价,核心区别在于数据主权和平台依赖:

维度GAS 方案Activepieces 方案
部署位置Google 服务器自有服务器(甲骨文云等)
触发方式时间触发器(轮询)/加工后也可实现事件驱动Gmail Trigger(事件驱动)
分类映射Google Sheets 外部表目前写死,计划改为外部映射表
执行记录GAS 执行日志Activepieces 执行历史,可逐步查看
平台依赖依赖 Google 账号和配额依赖自建服务,完全自控
OAuth 处理GAS 内置 OAuth 库Node.js oauth 包,手动控制

几个值得注意的细节

邮件整形是关键前置步骤。如果直接对原始支付通知做解析,每家支付机构的邮件格式不同,维护成本会很高。先整形成统一的三行格式,后面的提取和处理才能保持简单。

防重复机制不能省。Gmail Trigger 在某些情况下会对同一封邮件触发多次,测试阶段不一定能复现,但生产环境跑久了总会遇到。用 message_id 写入 comment 并在写入前做查重,是目前最直接可靠的做法。

错误用 resolve 而非 reject。Activepieces 的 Code 步骤如果抛出未捕获的异常,整个流程会在这里中断,后续的 ntfy 通知也不会发出。把错误包成 resolve 的返回值,确保通知步骤始终能执行,是更稳健的写法。

小结

整条流程跑通之后,一笔支付从邮件到 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();
    }

    // 🌟【已修改备注前缀】换成了 🤖Activepieces自動記帳
    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. 使用 Promise 封装异步 OAuth POST 请求
    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 Feed 获取最新文章更新。