
🌱 起心動念:為什麼想自動化信用卡帳單?
每個月收到「銀行信用卡」帳單時,我常常只是快速掃過繳款金額,真正的消費細節反而沒有好好看。
帳單是 PDF,又有密碼保護,要打開、搜尋、對照、算總額,整個流程其實不輕鬆。
後來開始用 n8n 做工作上的自動化,就動念把這件事也自動化:
Gmail 收到信用卡帳單 →
自動解出 PDF 文字 →
把「本月消費明細」抽出來 →
丟給 GPT 做結構化整理 →
透過 LINE 推送一份可閱讀的摘要。

這篇是把這段過程中和 ChatGPT 來回討論、踩坑、修正的經驗整理成一篇實作心得。
🧭 流程總覽:從信箱到通知的資料旅程
整條流程拆開來大概是這樣:
範例:國泰世華
qpdf + pdftotext
Extract from File 就能直接處理有密碼的 PDFraw_segment 給後續 AI 使用to(userId)和 messages
這條線中間大概經歷了幾個明顯的轉折:
PDF 解密的做法、正規表示式沒抓到內容、JSON 要怎麼轉成 LINE 格式,
以及 n8n 送 HTTP 的一些小陷阱。
📎 處理加密 PDF:從 qpdf 繞了一圈
一開始的想法很工程師:
既然 PDF 有密碼,就先在主機裝工具,把它解密再處理。
我原本設計的是:
在 n8n 主機上安裝 qpdf、poppler-utils (pdftotext)
流程如下:
inputPdf
/tmp/input.pdf
qpdf --password=1234 --decrypt /tmp/input.pdf /tmp/decrypted.pdf
pdftotext /tmp/decrypted.pdf -
把文字吐到 stdout這條路的好處是:
tesseract)時比較彈性但實作到一半,開始意識到幾個問題:
📄 發現 Extract from File:回頭用內建節點就好
實際把 n8n 的 Extract from File 節點打開看了一下之後,才發現它已經支援:
我那時候遇到的錯誤是:
No password given
意思很單純:PDF 是加密的,但節點沒拿到密碼。
後來調整為:
attachment_0 或 inputPdf)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,
}
}];
在這個節點我刻意同時保留:
這一步解決之後,整份明細就變成一塊「乾淨文字」,可以交給 GPT 解析。
🤖 與 GPT 設計「帳單解析 Agent」
接下來就是這次專案裡最 AI 的部分:
把 raw_segment 丟給 GPT,希望它幫我做三件事:
我寫了一個偏完整的 Prompt,交代:
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"
}
]
}
✉️ 用 Function 節點把 JSON 轉成 LINE 訊息
有了 JSON 之後,我想在 LINE 上看到的不是原始資料,而是:
所以又加了一個 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 } }];
一開始我在 HTTP Request 的 result 裡看到:
{ "to": "Uxxxx", "messages": [object Object],[object Object],[object Object] }
這代表我在 n8n 裡把 messages 當成「字串」送出,陣列被隱形轉成了 [object Object]。
在 HTTP Request 節點中:
Method:POST
URL:https://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}}外面 不要再包引號。
另一種做法是開啟 JSON/RAW:
{{ JSON.stringify({
to: "U861...",
messages: $json.messages
}) }}
這樣可以確定送出去的是合法 JSON 字串。
調整完之後,LINE 就順利收到三則訊息:
🔧 實作過程中的幾個小重點
回頭看整個流程,大概有幾個心得:
先用內建節點
一開始直接往 qpdf 等系統工具衝,但對這種規模的需求,其實 Extract from File 就很夠用了。
正則寫法要考慮真實文本的雜訊
像全形冒號、換頁標示、頁碼行都是很真實的坑,光看 PDF 視覺畫面其實不容易注意。
AI 的角色是「解析 + 聚合」,不是單純問答
我給 GPT 的任務是把原始文字變成結構化 JSON,之後所有動作(算總額、排行)都在 n8n 裡完成,這樣比較穩定。
n8n 的表達式和 JSON 型別要小心
[object Object] 幾乎就是「物件被當字串」的代名詞。
只要看到這個,多半是少了 JSON.stringify 或是外面多包了一層引號。
🔮 後續可以加的東西
目前這條線已經可以自動送出摘要,不過還有一些想做但還沒做的:
這些留給之後有空再慢慢疊上去。
💬 給 GPT 用的 Prompt 範例
下面整理幾個在這個專案中實際用到、或現在回頭看覺得實用的 Prompt,可以直接複製調整:
1️⃣ 把帳單明細文字解析成 JSON
你是一個嚴謹的帳單文字解析器。
下面是國泰世華信用卡「本期消費明細文字」,請依規則解析並只輸出 JSON,不要額外說明。
請以以下 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 推播的文字訊息:
請輸出一個陣列,每個元素是 { "type": "text", "text": "..." } 的物件。
4️⃣ 請 GPT 幫忙檢查 n8n HTTP Request 設定
下面是我在 n8n HTTP Request 節點打 LINE Push API 的 body 與 headers。
請幫我檢查:
並給我一個正確示範。
5️⃣ 將 workflow 寫成文件說明
我有一條 n8n workflow:
Gmail → Extract from File → Function → AI → Function → HTTP Request。
請幫我整理成一篇教學文件的大綱,用條列方式描述每一個節點的目的、關鍵設定、可能踩到的坑。