.png)
把自动化流程搬到自己服务器上这件事,说起来轻巧,真正做下来每一步都要填坑。这篇文章记录的是用 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 写完就直接过了。

迁移之后,整条流程从触发到记账到推送通知,全部跑在自己的甲骨文云上,数据不经过任何第三方平台。
整体流程一览
整条自动化链路共 8 个步骤,结构如下:
触发器
提取金额
提取日期
カテゴリID
ジャンルID
口座情報
记账到 Zaim
ntfy 通知
实际截图是这样

每个步骤职责单一,便于调试。等运行稳定之后,步骤 4~7 可以考虑合并成一个较大的 Code 块,减少变量传递的层数。
步骤一:邮件整形,从原始通知提取干净数据
支付类通知邮件的原始格式通常很乱——HTML 标签、客服套话、广告链接混在一起。直接对这种原始邮件做正则匹配,维护起来非常痛苦。
我的做法是在 Gmail 触发之前,先用 n8n 做一次邮件整形,把原始邮件转成只有三行的纯文本:
利用日時:2026-06-23
利用金額:3800
利用店舗:整形外科整形这一步本身并不一定要用 n8n,任何能处理 Gmail 内容并重新发送邮件的工具都可以承担这个角色。关键是让下游的 Activepieces 拿到的是干净的、格式固定的文本,而不是原始的富文本邮件。
整形后的邮件触发 Activepieces 的 Gmail Trigger,流程正式启动。
步骤二、三:用正则式提取金额和日期
Activepieces 的 Text Helper 步骤支持正则表达式提取。针对整形后固定格式的三行文本,提取规则非常简洁:
- 金額:匹配
利用金額:后面的数字,去掉逗号,转为整数 - 日付:匹配
利用日時:后面的YYYY-MM-DD格式日期
因为整形这一步已经保证了格式的一致性,这里的正则可以写得很简单,不需要考虑各种边界情况。提取后用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 |
| error | Zaim 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)
});
}
}
);
});
};(完)

