iT邦幫忙

0

📨 用 n8n 自動整理信用卡帳單:從 Gmail 到 LINE 的實戰筆記

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251107/20155103qWrdMJvsk0.png

🌱 起心動念:為什麼想自動化信用卡帳單?

每個月收到「銀行信用卡」帳單時,我常常只是快速掃過繳款金額,真正的消費細節反而沒有好好看。
帳單是 PDF,又有密碼保護,要打開、搜尋、對照、算總額,整個流程其實不輕鬆。

後來開始用 n8n 做工作上的自動化,就動念把這件事也自動化:

Gmail 收到信用卡帳單 →
自動解出 PDF 文字 →
把「本月消費明細」抽出來 →
丟給 GPT 做結構化整理 →
透過 LINE 推送一份可閱讀的摘要。

https://ithelp.ithome.com.tw/upload/images/20251107/20155103SooyQNFqio.png

這篇是把這段過程中和 ChatGPT 來回討論、踩坑、修正的經驗整理成一篇實作心得。


🧭 流程總覽:從信箱到通知的資料旅程

整條流程拆開來大概是這樣:
範例:國泰世華

1️⃣ Gmail Trigger

  • 只處理主旨以「國泰世華銀行信用卡」開頭的信件
  • 勾選下載附件

2️⃣ PDF 文字抽取

  • 一開始研究 qpdf + pdftotext
  • 後來發現 n8n 裡的 Extract from File 就能直接處理有密碼的 PDF

3️⃣ Function 節點

  • 從整份文字裡,只切出「您本月消費明細如下:~-----------------END-----------------」這一小段
  • 留下原始 raw_segment 給後續 AI 使用

4️⃣ AI Agent(GPT)

  • 輸入整段明細文字
  • 讓模型:
    • 按店家合併金額
    • 找出單筆超過 1000 的交易
    • 計算總額
  • 回傳一個乾淨的 JSON

5️⃣ Function:整理成 LINE 訊息

  • 把 JSON 轉成幾則簡短的文字訊息
  • 例如:摘要、Top 店家、大額交易列表

6️⃣ HTTP Request → LINE Push API

  • 設定好 to(userId)和 messages
  • 寄出推播

這條線中間大概經歷了幾個明顯的轉折:
PDF 解密的做法、正規表示式沒抓到內容、JSON 要怎麼轉成 LINE 格式,
以及 n8n 送 HTTP 的一些小陷阱。
📎 處理加密 PDF:從 qpdf 繞了一圈


一開始的想法很工程師:
既然 PDF 有密碼,就先在主機裝工具,把它解密再處理。

我原本設計的是:

  1. 在 n8n 主機上安裝 qpdfpoppler-utils (pdftotext)

  2. 流程如下:

    • Gmail Trigger 下載附件(binary)
    • Function 節點把 binary re-map 成 inputPdf
    • Write Binary File → /tmp/input.pdf
    • Execute Command:
      qpdf --password=1234 --decrypt /tmp/input.pdf /tmp/decrypted.pdf
      
    • 再用
      pdftotext /tmp/decrypted.pdf -
      
      把文字吐到 stdout

這條路的好處是:

  • 對各種 PDF 都通用,不侷限在某個平台
  • 想加上 OCR(例如 tesseract)時比較彈性

但實作到一半,開始意識到幾個問題:

  • 需要動到主機或 Docker image 安裝工具
  • 路徑、權限、容器掛載點都要自己顧
  • 對於只是個人帳單來說,這條路顯得有點重

📄 發現 Extract from File:回頭用內建節點就好

實際把 n8n 的 Extract from File 節點打開看了一下之後,才發現它已經支援:

  • 直接讀 binary property
  • 模式選「PDF (Text)」
  • 甚至還有 Password 欄位可以填

我那時候遇到的錯誤是:

No password given

意思很單純:PDF 是加密的,但節點沒拿到密碼。
後來調整為:

  • Operation:Extract → PDF (Text)
  • Binary Property:指向 Gmail 節點輸出的附件 key(例如 attachment_0inputPdf
  • Password:先直接填 1234 測試(之後再改成環境變數)

這樣就直接拿到:

{
  "text": "整份帳單解出來的文字……"
}

比起前面那串 qpdf 指令,這個方案簡單很多,也不需要動 server。


📊 小小比較表:兩種 PDF 解析方式

面向 qpdf + pdftotext Extract from File 節點
需要安裝系統工具 需要 不需要
密碼處理 command line 引數或 env 節點內直接填 Password 欄位
對掃描型 PDF 的支援 要再接 OCR(tesseract 等) 一樣要額外處理
適合大量、客製化流程 還可以,但彈性略低
適合個人帳單小專案 顯得有點重 比較輕量、易維護

實務上,我最後採用的是「先用 Extract from File 拿文字」,真的遇到掃描型帳單再考慮加 OCR。


🔍 用 Function 節點切出「本月消費明細」

Extract from File 把整份帳單都轉成文字,但我只在乎中間那一段:

您本月消費明細如下:

-----------------END-----------------

原本的正則沒有寫對,抓不到內容。
後來改成這種寫法(支援全形/半形冒號、跨行):

// 1) 取得文字
const text = ($input.first().json.text || "").toString();

// 2) 正則:抓取起迄(冒號可能是全形「:」或半形「:」)
// [\s\S]*? = 非貪婪跨行
const re = /您本月消費明細如下[::]\s*([\s\S]*?)\n\s*-{5,}\s*END\s*-{5,}/;
const m = text.match(re);

if (!m) {
  return [{
    json: {
      ok: false,
      reason: "未找到起迄標記",
      preview_head: text.slice(0, 200),
      preview_tail: text.slice(-200),
    }
  }];
}

// 4) 原始擷取片段
let segment = m[1];

// 5) 清理:移除「-----續下頁-----」、頁碼 1/4 之類
const lines = segment
  .split(/\r?\n/)
  .map(s => s.trimEnd())
  .filter(s => {
    if (/^-{2,}\s*續下頁\s*-{2,}$/u.test(s)) return false;
    if (/^\d+\s*\/\s*\d+$/.test(s)) return false; // 1/4, 2/4...
    return true;
  });

const cleaned = lines
  .map(s => s.replace(/[ \t]{3,}/g, ' '))
  .join('\n')
  .trim();

return [{
  json: {
    ok: true,
    raw_segment: segment.trim(),
    clean_segment: cleaned,
    original_length: text.length,
  }
}];

在這個節點我刻意同時保留:

  • raw_segment:給 AI 處理用
  • clean_segment:如果要直接寫 Google Sheet,可以用這段

這一步解決之後,整份明細就變成一塊「乾淨文字」,可以交給 GPT 解析。


🤖 與 GPT 設計「帳單解析 Agent」

接下來就是這次專案裡最 AI 的部分:
raw_segment 丟給 GPT,希望它幫我做三件事:

  1. 將同一家店的消費合併
  2. 列出單筆金額 ≥ 1000 的交易
  3. 算出所有交易總額

我寫了一個偏完整的 Prompt,交代:

  • 帳單裡會有雜訊行(表頭、頁碼、續下頁)要排除
  • 「樂購蝦皮-某某」這種字串要抽出店家名稱
  • 負號代表退款或點數折抵,計算總額時照實相加
  • 請回傳一個固定格式的 JSON,包含:
    • summary
    • by_merchant
    • large_transactions
    • skipped

實際跑完之後,得到一份類似這樣的結構(節錄):

{
  "summary": {
    "currency": "TWD",
    "total_amount": 10574,
    "transaction_count": 86
  },
  "by_merchant": [
    {
      "merchant": "CURSOR, AI POWERED IDE",
      "normalized_merchant": "CURSOR,AI POWERED IDE",
      "count": 1,
      "amount": 5879,
      "first_date": "08/27",
      "last_date": "08/27",
      "items": [ ... ]
    }
  ],
  "large_transactions": [
    {
      "date": "08/27",
      "merchant": "CURSOR, AI POWERED IDE",
      "amount": 5879
    },
    {
      "date": "10/05",
      "merchant": "連加*FOCASA馬戲",
      "amount": 1200
    }
  ],
  "skipped": [
    {
      "line": "新臺幣",
      "reason": "Excluded by keyword/pattern"
    }
  ]
}

這個 JSON 變成後面所有步驟的基礎。

✉️ 用 Function 節點把 JSON 轉成 LINE 訊息

有了 JSON 之後,我想在 LINE 上看到的不是原始資料,而是:

  • 一則「總覽」
  • 一則「大額交易列表」
  • 一則「Top 店家」

所以又加了一個 Function 節點,專門整理成 LINE 所需的 messages 陣列:

const data = $input.first().json;

if (!data || !data.summary || !Array.isArray(data.by_merchant)) {
  return [{
    json: {
      messages: [{ type: 'text', text: '解析失敗:輸入資料缺少 summary 或 by_merchant。' }]
    }
  }];
}

const fmt = (n) => {
  const s = (n || 0).toString();
  return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};

// 1) 摘要
const summaryLines = [];
summaryLines.push('📊 信用卡摘要');
summaryLines.push(`• 幣別:${data.summary.currency}`);
summaryLines.push(`• 總額:$${fmt(data.summary.total_amount)}`);
summaryLines.push(`• 筆數:${data.summary.transaction_count}`);
const msgSummary = summaryLines.join('\n');

// 2) 大額交易
const bigs = Array.isArray(data.large_transactions) ? data.large_transactions : [];
let msgBigs;
if (bigs.length === 0) {
  msgBigs = '💥 大額交易 (≥ 1,000)\n(無)';
} else {
  const topBigs = bigs.slice().sort((a, b) => b.amount - a.amount).slice(0, 10);
  const lines = topBigs.map(t => `• ${t.date} ${t.merchant} $${fmt(t.amount)}`);
  msgBigs = '💥 大額交易 (≥ 1,000)\n' + lines.join('\n');
}

// 3) Top 店家
const merchants = data.by_merchant.slice().sort((a, b) => (b.amount || 0) - (a.amount || 0));
const topCount = Math.min(5, merchants.length);
const topLines = [];
for (let i = 0; i < topCount; i++) {
  const m = merchants[i];
  const name = m.normalized_merchant || m.merchant || '(未命名)';
  topLines.push(`${i + 1}. ${name} $${fmt(m.amount || 0)}(${m.count || 0}筆)`);
}
const msgTop = '🏪 Top 店家(合併後)\n' + (topLines.join('\n') || '(無)');

// 4) chunk 保護
const MAX_LEN = 4500;
const chunkText = (text) => {
  if (text.length <= MAX_LEN) return [text];
  const chunks = [];
  let start = 0;
  while (start < text.length) {
    chunks.push(text.slice(start, start + MAX_LEN));
    start += MAX_LEN;
  }
  return chunks;
};

const messages = []
  .concat(chunkText(msgSummary).map(t => ({ type: 'text', text: t })))
  .concat(chunkText(msgBigs).map(t => ({ type: 'text', text: t })))
  .concat(chunkText(msgTop).map(t => ({ type: 'text', text: t })));

return [{ json: { messages } }];

這樣下一個節點只要把 $json.messages 塞進 HTTP Request 的 body 就好。

📲 HTTP Request + LINE Push API:解決 [object Object] 踩雷

一開始我在 HTTP Request 的 result 裡看到:

{ "to": "Uxxxx", "messages": [object Object],[object Object],[object Object] }

這代表我在 n8n 裡把 messages 當成「字串」送出,陣列被隱形轉成了 [object Object]


✅ 解法是兩個關鍵點:

1️⃣ Body 要用真正的 JSON,而不是字串

在 HTTP Request 節點中:

  • Method:POST

  • URLhttps://api.line.me/v2/bot/message/push

  • Send:選 JSON

  • Headers

    Authorization: Bearer {{$env.LINE_CHANNEL_TOKEN}}
    Content-Type: application/json
    
  • Body 設定

    {
      "to": "U861cc0a2877fb37e1f8b4f55ee8d54a0",
      "messages": {{$json.messages}}
    }
    

⚠️ 注意 {{$json.messages}} 外面 不要再包引號


2️⃣ 或者改用 Raw + JSON.stringify

另一種做法是開啟 JSON/RAW:

{{ JSON.stringify({
  to: "U861...",
  messages: $json.messages
}) }}

這樣可以確定送出去的是合法 JSON 字串。


🎉 結果

調整完之後,LINE 就順利收到三則訊息:

  1. 信用卡摘要
  2. 大額交易列表
  3. Top 店家合併資訊

對個人理財來說,這個資訊密度剛好,不會被塞滿一堆細碎的品項。

🔧 實作過程中的幾個小重點

回頭看整個流程,大概有幾個心得:

  1. 先用內建節點
    一開始直接往 qpdf 等系統工具衝,但對這種規模的需求,其實 Extract from File 就很夠用了。

  2. 正則寫法要考慮真實文本的雜訊
    像全形冒號、換頁標示、頁碼行都是很真實的坑,光看 PDF 視覺畫面其實不容易注意。

  3. AI 的角色是「解析 + 聚合」,不是單純問答
    我給 GPT 的任務是把原始文字變成結構化 JSON,之後所有動作(算總額、排行)都在 n8n 裡完成,這樣比較穩定。

  4. n8n 的表達式和 JSON 型別要小心
    [object Object] 幾乎就是「物件被當字串」的代名詞。
    只要看到這個,多半是少了 JSON.stringify 或是外面多包了一層引號。


🔮 後續可以加的東西

目前這條線已經可以自動送出摘要,不過還有一些想做但還沒做的:

  • 加上 IF 條件:例如只在 total_amount 超過某個門檻時才推播
  • 接一個 Google Sheets Append 節點,把 by_merchant 或 raw items 長期累計
  • 依照月份加上簡單的趨勢比較,例如本月 vs 上月總額差異
  • 把通知改成 LINE Flex Message,做成比較圖或卡片式排版

這些留給之後有空再慢慢疊上去。


💬 給 GPT 用的 Prompt 範例

下面整理幾個在這個專案中實際用到、或現在回頭看覺得實用的 Prompt,可以直接複製調整:

1️⃣ 把帳單明細文字解析成 JSON

你是一個嚴謹的帳單文字解析器。
下面是國泰世華信用卡「本期消費明細文字」,請依規則解析並只輸出 JSON,不要額外說明。

  • 忽略表頭、頁碼(例如 1/4)、「-----續下頁-----」等非交易行
  • 每行若包含日期、交易說明、新臺幣金額,就視為一筆交易
  • 新臺幣金額為整數,負號代表退款或點數折抵
  • 合併同一店家的金額,並統計筆數與最早/最晚日期
  • 列出所有單筆金額 ≥ 1000 的正向交易
  • 計算所有交易的總額(包含負數)

請以以下 JSON 結構輸出:
{ "summary": {...}, "by_merchant": [...], "large_transactions": [...], "skipped": [...] }
其中 summary.total_amount 為整數(TWD),large_transactions 僅包含金額 ≥ 1000 的交易。


2️⃣ 幫忙寫 n8n Function 節點的 RegExp

我有一段帳單文字,存在 $input.first().json.text
請幫我寫一段 n8n Function 節點用的 JavaScript,從這段文字中取出
「您本月消費明細如下:」到「-----------------END-----------------」之間的所有內容:

  • 支援全形或半形冒號
  • 可以跨多行
  • 回傳 { json: { ok: true, raw_segment, clean_segment } }
    其中 clean_segment 請幫我移除「-----續下頁-----」和純頁碼(例如 1/4、2/4)。

3️⃣ 幫忙把 JSON 摘要轉成 LINE 訊息文字

我會提供一個 JSON,格式包含 summary、by_merchant、large_transactions。
請幫我用這些資料組成三段適合 LINE 推播的文字訊息:

  • 信用卡摘要(幣別、總額、筆數)
  • 大額交易列表(只顯示金額 ≥ 1000 的交易)
  • 依合併金額排序的 Top 5 店家(顯示店名、總金額、筆數)

請輸出一個陣列,每個元素是 { "type": "text", "text": "..." } 的物件。


4️⃣ 請 GPT 幫忙檢查 n8n HTTP Request 設定

下面是我在 n8n HTTP Request 節點打 LINE Push API 的 body 與 headers。
請幫我檢查:

  • 是否為合法的 JSON
  • messages 欄位是否為陣列,而不是字串
  • 有沒有可能造成 [object Object] 被送出去

並給我一個正確示範。


5️⃣ 將 workflow 寫成文件說明

我有一條 n8n workflow:
Gmail → Extract from File → Function → AI → Function → HTTP Request

請幫我整理成一篇教學文件的大綱,用條列方式描述每一個節點的目的、關鍵設定、可能踩到的坑。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言