n8nでZaimの自動記帳を強力化する話②-詳細設定

楽天ペイ・ANA Pay・au PAY・メルペイの自動記帳を実現した全設定を記録します。TimeZoneから正規表現、GASコード改修、HTTP Requestまで順を追って解説。

n8nのワークフロー全体図

現在、楽天ペイ・ANA Pay・au PAY・メルペイで稼働中(au PAYは2026年2月時点で公式側が故障中)

設定項目
  1. TimeZoneの設定
  2. 正規表現で各社メールを統一書式に変換
  3. GASコードを各種ペイに合わせて修正
  4. GASコードにn8n連携用コードを追加
  5. HTTP RequestでGAS関数を発動するWorkFlowを作成
  6. HTTP Requestでntfy通知を設定

Step 1

TimeZone

最初にやること:私はこれを忘れ、メールの整形まではできたのにGASのトリガーがどうしても発動しませんでした。必ず最初に設定してください。

WorkFlow右上の「・・・」からSettings画面を開き、TimeZoneを居住地に設定します。

n8nのWorkFlow右上にある「…」メニュー

WorkFlow右上の「・・・」をクリック

n8nのTimeZone設定画面

TimeZoneは必ず居住地(日本の場合はAsia/Tokyo)に設定する


Step 2

正規表現で各社メールを統一書式に変換

各社の公式メールは書式がバラバラです。n8nのコードノードでAIに生成してもらった正規表現を使い、すべてを3行の統一フォーマットに変換します。

ANA PAYのテストメールをn8nで拾った内容の画面

ANA PAYのテストメールで取得した生データ。中段が実際の設定内容。

正規表現が正しく設定された際のn8n画面(緑色になる)

正規表現が正しければ緑色になり、右にアウトプットも確認できる

ヒント:正規表現はAIに「このメール本文から日時・金額・店舗名を抽出するn8n用の正規表現を書いて」と頼めば一発で出てきます。日本語でもGemini/ChatGPTは問題なく対応してくれます。
楽天ペイ
利用日時:{{ $json.date.match(/\d{4}-\d{2}-\d{2}/)[0] }}<br>利用金額:{{parseFloat($json.text.match(/(?<=楽天キャッシュ)\s*([\d,]+)(?=\s*円)/)[0].replace(/,/g, ”))}}円<br>利用店舗:{{ $json.text.match(/(?<=ご利用店舗\s*)[\s\S]+?(?=\s*電話番号)/)[0].trim().split(‘\n’)[0] }}<br>
ANA Pay
利用日時:{{ $json.date.match(/\d{4}-\d{2}-\d{2}/)[0] }}<br>利用金額:{{parseFloat($json.text.match(/(?<=利用金額:)\s*([\d,]+)(?=\s*円)/)[0].replace(/,/g, ”))}}円<br>利用店舗:{{ $json.text.match(/(?<=ご利用店舗:\s*)[\s\S]+?(?=\s*詳細)/)[0].trim() }}<br>
メルペイ
利用日時:{{ $json.date.match(/\d{4}-\d{2}-\d{2}/)[0] }}<br>利用金額:{{parseFloat($json.text.match(/(?<=¥)\s*([\d,]+)(?=\s*\n)/)[0].replace(/,/g, ”))}}円<br>利用店舗:{{ $json.text.match(/(?<=店舗名 : \s*)[\s\S]+?(?=\s*\n)/)[0].trim() }}<br>
変換後の3行書式のメール

変換後の統一フォーマット(3行)


Step 3

GASコードを各種ペイに合わせて修正(下に完全版も貼っておきます)

元のコードの修正が必要な箇所にコメントを入れています。オレンジ色の部分が変更・確認が必要な箇所です。

// 使用する支払い方法に合わせてIDを変えてください(合計4か所)
const RAKUTEN_PAYMENT_ID = parseFloat(PropertiesService.getScriptProperties().getProperty("RAKUTEN_PAYMENT_ID"));

// 重複チェック範囲。必要に応じて変えられます。
var yesterday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);

// メール検索クエリ(自分で設定した件名に変更する)
var query = 'subject:("自分で設定したメールのテーマ")';

// 3行フォーマットに変換済みなので正規表現はシンプルでOK
var usageDate = body.match(/\d{4}-\d{2}-\d{2}/);
var cash = body.match(/\s*([\d,]+)円/);
var shop = body.match(/利用店舗:\s*([\s\S]*?)---/)[1].trim();

// DBファイル名を変えた場合はここも修正
var files = DriveApp.getFilesByName("ZAIM_DB");

// 支払いアカウントID(対象の決済サービスに合わせる)
"from_account_id": RAKUTEN_PAYMENT_ID,
"comment": "システムから登録"  // コメントは自由に変えられます

Step 4

GASコードにn8n連携用コードを追加

GASをHTTP Requestで呼び出せるように、コードの末尾に以下を追加します。関数名を変えた場合は rakutenPayToZaim の部分も合わせて修正してください。

function doPost(e) {
  try {
    rakutenPayToZaim(); // 関数名を変えた場合はここも修正
    return ContentService.createTextOutput(JSON.stringify({
      "status": "success",
      "message": "rakutenPayToZaim executed successfully"
    })).setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({
      "status": "error",
      "message": error.toString()
    })).setMimeType(ContentService.MimeType.JSON);
  }
}

追加後はGAS右上の「デプロイ」からウェブアプリをデプロイします。

GASのデプロイ画面

「デプロイ」をクリックしてウェブアプリとして公開する

GASのデプロイ種類選択画面

「ウェブアプリ」を選択する

説明
任意の名前でOK。バージョン管理に使います。
実行ユーザー
「自分」を選択。個人用途のため自分のアカウントで実行します。
アクセス
「全員」にすると最もシンプル。URLが漏れると誰でも関数を実行できるため、URLの管理は厳重に。
GASのデプロイ管理画面とウェブアプリURL

デプロイ管理画面。ウェブアプリのURLをn8nに設定する。


Step 5

HTTP RequestでGAS関数を発動するWorkFlowを作成

n8nのHTTP Requestノードを追加し、デプロイしたGASウェブアプリのURLをPOSTで叩きます。

重要:Follow Redirects を必ずONにしてください。GASのウェブアプリはリダイレクトを返すため、OFFのままだと正常に動作しません。
n8n HTTP Requestノード of GAS関数発動設定画面

HTTP RequestノードにGASウェブアプリのURLを設定する


Step 6

HTTP Requestでntfy通知を設定

記帳完了後にntfyへ通知を送るHTTP Requestノードを追加します。ntfyにこだわらず、Telegramや他の通知サービスでも同様に設定できます。Bodyに変数を入れると金額なども通知に含められます。

n8n ntfy通知のHTTP Request設定画面

ntfyへのHTTP Request設定。Bodyに変数を入れて金額なども表示できる。


これで実現できたこと

即時記帳:メール受信とほぼ同時にZaimへ自動記帳

対応拡張:楽天モバイル・らくらく投資・マックネット注文も対応

マルチペイ:ANA Pay・メルペイなど通知メールがある決済はすべて自動記帳

即時通知:記帳完了をntfyでリアルタイムに確認

手動救済:MacroDroidのHTTP Requestアクションでスマホからワンタップ記帳

PayPayについて:残高払いはメール通知がないため今回の対象外です。ただし公式CSV方式より楽な方法があります。別記事をご参照ください。

今後したいこと

GASを経由せず、n8nだけですべて完結できる構成にしたい。
PayPayはメール通知がないため完全自動化は難しいが、できる限り便利にしたい。
更新情報:昨日実装し、今日書きました。問題があれば随時更新します。
zaim
zaim

添付:コード完全版

const CONSUMER_KEY = PropertiesService.getScriptProperties().getProperty("ZAIM_CONSUMER_ID");
const CONSUMER_SECRET = PropertiesService.getScriptProperties().getProperty("ZAIM_CONSUMER_SECRET")
// 使用する支払い方法に合わせてIDを変えてください。合計4か所あります。(変えなくても作動はしますが、複数ペイが存在すると管理がややこしくなります)
const RAKUTEN_PAYMENT_ID = parseFloat(PropertiesService.getScriptProperties().getProperty("RAKUTEN_PAYMENT_ID"));
const DEFAULT_CATEGORY_ID = parseFloat(PropertiesService.getScriptProperties().getProperty("DEFAULT_CATEGORY_ID"));
const DEFAULT_GENRE_ID = parseFloat(PropertiesService.getScriptProperties().getProperty("DEFAULT_GENRE_ID"));

// 今日の日付を取得
var date = new Date(); // 現在の日付と時刻を取得
var today = Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd");

// 1日前の日付を取得(ここは必要に応じて変えられます。)
var yesterday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);
yesterday = Utilities.formatDate(yesterday, Session.getScriptTimeZone(), "yyyy-MM-dd");

// 認証のリセット
function reset() {
  var service = getService();
  service.reset();
}

// 認証サービスの設定
function getService() {
  return OAuth1.createService("Zaim")
    // Set the endpoint URLs.
    .setAccessTokenUrl("https://api.zaim.net/v2/auth/access")
    .setRequestTokenUrl("https://api.zaim.net/v2/auth/request")
    .setAuthorizationUrl("https://auth.zaim.net/users/auth")

    // Set the consumer key and secret.
    .setConsumerKey(CONSUMER_KEY)
    .setConsumerSecret(CONSUMER_SECRET)

    // Set the name of the callback function in the script referenced
    // above that should be invoked to complete the OAuth flow.
    .setCallbackFunction("authCallback")

    // Set the property store where authorized tokens should be persisted.
    .setPropertyStore(PropertiesService.getUserProperties());
}

// OAuth Callbackの設定
function authCallback(request) {
  var service = getService();
  var authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput("認証できました!このページを閉じて再びスクリプトを実行してください。");
  } else {
    return HtmlService.createHtmlOutput("認証に失敗");
  }
}

// GETパラメーターを作成
function encodeParams(params) {
  var encodedParams = [];
  for (var key in params) {
    encodedParams.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key]));
  }
  return encodedParams.join("&");
}

// 過去の支払いデータを取得
function getPastData(service) {
  var url = "https://api.zaim.net/v2/home/money";

  // 日付で検索(ここは必要に応じて変えられます。)
  var params = {
    "start_date": yesterday,
    "end_date": today,
  }
  // 日付指定したい場合は日付を指定してコメントアウトを外す(ここは必要に応じて変えられます。)
  // params = {
  //   "start_date": "2024-04-20",
  //   "end_date": "2024-04-23",
  // }

  // データの取得
  var response = service.fetch(url + "?" + encodeParams(params));
  var result = JSON.parse(response.getContentText());
  // Logger.log(result); // 取得したデータを見たい場合コメントアウトを外す

  // 楽天ペイのみの支払いでフィルタリング
  var rakutenPayData = result.money.filter(function(item) {
    return item.from_account_id === RAKUTEN_PAYMENT_ID;
  });

  return rakutenPayData
}

// 楽天ペイ情報をZaimに登録(メイン関数、名前も変えられるはず、私はしていませんでした。)
function rakutenPayToZaim() {
  // Gmailで該当メールを検索(本来のコードは公式メールを識別するものだが、ここは自分が設定したメールのテーマにします)
  var start = 0;
  var max = 2; // 過去いくつまでメールを遡るか
  var query = 'subject: ("自分で設定したメールのテーマ") ';
  var threads = GmailApp.search(query, start, max);

  // 既存のデータを取得
  var service = getService();
  if (service.hasAccess()) {
    var existingData = getPastData(service)
  } else {
    var authorizationUrl = service.authorize();
    Logger.log("次のURLを開いてZaimで認証したあと、再度スクリプトを実行してください。: %s",
      authorizationUrl);
  }

  // 各楽天ペイの支払いについて登録
  for (var i = 0; i < threads.length; i++) {
    var messages = threads[i].getMessages();
    for (var j = 0; j < messages.length; j++) {
      var message = messages[j];
      var subject = message.getSubject();
      var body = message.getPlainBody();

      // ご利用日時、ご利用店舗、楽天キャッシュの金額を抽出(本来のコードは複雑ですが、GASでスクリプトを発動させる時点でもうメールが3行になったので、正規表現は下記にします。
      if (subject == "自分で設定したメールのテーマ") {
        var usageDate = body.match(/\d{4}-\d{2}-\d{2}/);
        var cash = body.match(/\s*([0-9,]+)円/);
        var shop = body.match(/利用店舗:\s*([\s\S]*?)---/)[1].trim();
        cash = parseInt(cash, 10)
      }
      var isExisting = false;
      Logger.log("日付:" + usageDate + ", 支払金額:" + cash + ", お店:" + shop)

      // ポイント払いの場合はスキップ(ここは保留、0円スキップは必要)
      if (cash == 0) {
        Logger.log("ポイント払いのためスキップ");
        continue;
      }

      // 指定の日付範囲に入っていなければスキップ
      if (usageDate != yesterday & usageDate != today) continue;

      // 既存のデータと比較して、新しいデータのみを追加
      for (var k = 0; k < existingData.length; k++) {
        if (existingData[k]["date"] == usageDate && existingData[k]["amount"] == cash && existingData[k]["place"] == shop) {
          isExisting = true;
          Logger.log("既に入力済み")
          break;
        }
      }

      if (!isExisting) {
        // 登録カテゴリーシートからデータを取得(下記DBの名前も変えてください。複数種類を同時に自動記帳させたいのならこれが必須です。)
        var files = DriveApp.getFilesByName("ZAIM_DB");
        var originalData = []
        if (files.hasNext()) {
          spreadsheet = SpreadsheetApp.open(files.next());
          var originalSheet = spreadsheet.getSheetByName("登録カテゴリー");
          originalData = originalSheet.getRange(2, 1, originalSheet.getLastRow() - 1, 4).getValues();
        }

        // 登録カテゴリーシートを検索して適切なカテゴリとジャンルを設定
        var category_id = DEFAULT_CATEGORY_ID;
        var genre_id = DEFAULT_GENRE_ID;
        for (var k = 0; k < originalData.length; k++) {
          var [storeName, exactMatch, categoryId, genreId] = originalData[k];
          if ((exactMatch && storeName === shop) || (!exactMatch && shop.includes(storeName))) {
            category_id = categoryId;
            genre_id = genreId;
            break;
          }
        }

        // 支払い情報の登録、コメントは好みで変えられます
        var url = "https://api.zaim.net/v2/home/money/payment";
        var payload = {
          "category_id": category_id,
          "genre_id": genre_id,
          "amount": cash,
          "date": usageDate,
          "place": shop,
          "from_account_id": RAKUTEN_PAYMENT_ID,
          "comment": "システムから登録"
        };
        var options = {
          "method": "post",
          "payload": payload
        };
        service.fetch(url, options);
        Logger.log("支払い入力完了")
      }
    }
  }
}

// Spreadsheetに書き込み
function writeCategoriesToSpreadsheet(categories, genres, accounts) {
  const SPREADSHEET_NAME = "ZAIM_DB";
  const CATEGORY_SHEET_NAME = "カテゴリと内訳";
  const ACCOUNT_SHEET_NAME = "支払方法";
  const ORIGINAL_SHEET_NAME = "登録カテゴリー";

  // スプレッドシートを取得または作成
  let spreadsheet;
  var files = DriveApp.getFilesByName(SPREADSHEET_NAME);
  if (files.hasNext()) {
    spreadsheet = SpreadsheetApp.open(files.next());
  } else {
    spreadsheet = SpreadsheetApp.create(SPREADSHEET_NAME);
  }

  // カテゴリとジャンルのシートを作成
  let categorySheet = spreadsheet.getSheetByName(CATEGORY_SHEET_NAME);
  if (categorySheet) {
    categorySheet.clear(); // 既存のシートをクリア
  } else {
    categorySheet = spreadsheet.insertSheet(CATEGORY_SHEET_NAME);
  }

  // アカウントのシートを作成
  let accountSheet = spreadsheet.getSheetByName(ACCOUNT_SHEET_NAME);
  if (accountSheet) {
    accountSheet.clear(); // 既存のシートをクリア
  } else {
    accountSheet = spreadsheet.insertSheet(ACCOUNT_SHEET_NAME);
  }

  // オリジナルカテゴリーシートの作成
  let originalSheet = spreadsheet.getSheetByName(ORIGINAL_SHEET_NAME);
  if (!originalSheet) {
    originalSheet = spreadsheet.insertSheet(ORIGINAL_SHEET_NAME);
    var originalHeaders = ["店舗名", "完全一致", "カテゴリーID", "内訳ID"];
    originalSheet.getRange(1, 1, 1, originalHeaders.length).setValues([originalHeaders]);
  }

  // 最初のシートが存在する場合、削除する
  let defaultSheet = spreadsheet.getSheetByName("シート1");
  if (defaultSheet) {
    spreadsheet.deleteSheet(defaultSheet);
  }

  // カテゴリとジャンルのヘッダーを作成
  var categoryHeaders = ["カテゴリーID", "カテゴリー名", "内訳ID", "内訳名"];
  categorySheet.appendRow(categoryHeaders);

  // アカウントのヘッダーを作成
  var accountHeaders = ["支払ID", "支払方法"];
  accountSheet.appendRow(accountHeaders);

  // データ行を作成
  var categoryRows = [];
  categories.forEach(category => {
    var categoryGenres = genres.filter(genre => genre.category_id === category.id);
    if (categoryGenres.length > 0) {
      categoryGenres.forEach(genre => {
        categoryRows.push([
          category.id,
          category.name,
          genre.id,
          genre.name,
        ]);
      });
    } else {
      categoryRows.push([
        category.id,
        category.name,
        "",
        "",
      ]);
    }
  });
  var accountRows = accounts.map(account => [account.id, account.name]);

  // 一括で書き込み
  categorySheet.getRange(2, 1, categoryRows.length, categoryHeaders.length).setValues(categoryRows);
  accountSheet.getRange(2, 1, accountRows.length, accountHeaders.length).setValues(accountRows);

  Logger.log("カテゴリ情報、内訳、支払い方法一覧を取得しました: " + spreadsheet.getUrl());
}

// カテゴリと内訳の取得(スプレッドシート作成のメイン関数)
function getInfo() {
  var service = getService();
  if (!service.hasAccess()) {
    var authorizationUrl = service.authorize();
    Logger.log("次のURLを開いてZaimで認証したあと、再度スクリプトを実行してください。: %s",
      authorizationUrl);
  }

  // カテゴリの取得
  var categoryUrl = "https://api.zaim.net/v2/home/category";
  var response = service.fetch(categoryUrl)
  var category = JSON.parse(response.getContentText()).categories;
  var filterCategory = category.filter(item => item.mode === "payment" && item.active === 1.0)
    .sort((a, b) => a.sort - b.sort);

  // ジャンルの取得
  var genreUrl = "https://api.zaim.net/v2/home/genre";
  response = service.fetch(genreUrl)
  var genre = JSON.parse(response.getContentText()).genres;
  var filterGenre = genre.filter(item => item.active === 1.0)
    .sort((a, b) => a.sort - b.sort);

  // アカウント一覧の取得
  var accountUrl = "https://api.zaim.net/v2/home/account";
  var response = service.fetch(accountUrl);
  var account = JSON.parse(response.getContentText()).accounts;
  account = account.filter(item => item.active === 1.0)
    .sort((a, b) => a.sort - b.sort);

  // スプレッドシートに書き込み
  writeCategoriesToSpreadsheet(filterCategory, filterGenre, account);
}
シェア・購読:
🧡 この記事が気に入ったら、RSSフィードを購読して最新の更新を受け取りましょう。

コメントは受け付けていません。