[Gemini 3.0][Google Search] 使用 Google Search Grounding API 搭配 Gemini 3.0 Pro 來打造新聞與資訊助手

image-20251211093304707

前情提要

在開發 LINE Bot 時,我想改進純文字搜尋功能:讓使用者輸入任何問題後,AI 能自動搜尋網路資訊並整理回答,同時支援連續對話。傳統做法需要串接多個 API(Gemini 提取關鍵字 → Google Custom Search → Gemini 總結),不僅慢(3次API調用)而且沒有對話記憶。

但 Google 在 2024 年推出了 Grounding with Google Search 功能,這是官方的 RAG (Retrieval-Augmented Generation) 解決方案,讓 Gemini 模型可以自動搜尋網路並引用來源,還原生支援 Chat Session!這項功能透過 Vertex AI 提供,讓 AI 回應不再憑空想像,而是基於真實的網路資訊。

畫面展示

LINE 2025-12-11 09.29.52

( 使用舊有的 Google Custom Search 的成果)

會發現他是根據 Google Search 的成果出來的結果,

主要 Repo https://github.com/kkdai/linebot-helper-python

開發過程中遇到的問題

問題 1:舊版實作的瓶頸

在實作 loader/searchtool.py 時,我使用的是傳統的搜尋流程:

# ❌ 舊版的做法 - 3 次 API 調用
async def handle_text_message(event, user_id):
    msg = event.message.text

    # 第 1 次:提取關鍵字
    keywords = extract_keywords_with_gemini(msg, api_key)

    # 第 2 次:Google Custom Search
    results = search_with_google_custom_search(keywords, search_api_key, cx)

    # 第 3 次:總結結果
    summary = summarize_text(result_text, 300)

    # 回傳結果...

這個方法有幾個明顯的問題:

❌ 無對話記憶 - 每次都是新的對話,無法連續提問

用戶: "Python 是什麼?"
Bot: [搜尋結果 + 摘要]

用戶: "它有什麼優點?"  # ❌ Bot 不知道 "它" 指的是 Python

❌ 搜尋結果淺薄 - 只使用 snippet,無法深入閱讀網頁內容

❌ 速度慢且成本高 - 3 次 API 調用(~6-8秒)+ Google Custom Search 費用($0.005/次)

問題 2:Client Closed 錯誤

當我改用 Vertex AI Grounding 後,遇到了這個錯誤:

ERROR:loader.chat_session:Grounding search failed: Cannot send a request, as the client has been closed.

原因是我在函數中創建了局部的 client 變數:

# ❌ 錯誤的做法 - client 會被垃圾回收
def get_or_create_session(self, user_id):
    client = self._create_client()  # 局部變數
    chat = client.chats.create(...)
    return chat  # 函數結束後 client 被關閉!

當函數結束後,client 被垃圾回收並關閉,導致基於它創建的 chat session 無法使用。

正確的解決方案

Google Search Grounding 是 Vertex AI 提供的官方 RAG 解決方案,與舊版 Custom Search 的比較:

特性 舊版 (Custom Search) 新版 (Grounding)
API 調用次數 3 次 1 次
回應速度 ~6-8秒 ~2-3秒
對話記憶 ❌ 無 ✅ 原生支援
搜尋品質 ⭐⭐⭐ (snippet) ⭐⭐⭐⭐⭐ (完整網頁)
來源引用 僅連結 完整引用
成本 Gemini + Custom Search 僅 Vertex AI

2. 建立 Chat Session Manager

首先,我創建了 loader/chat_session.py 來管理對話 session:

from google import genai
from google.genai import types
from datetime import datetime, timedelta
from typing import Dict, Tuple, List

class ChatSessionManager:
    def __init__(self, session_timeout_minutes: int = 30):
        self.sessions: Dict[str, dict] = {}
        self.session_timeout = timedelta(minutes=session_timeout_minutes)

        # ✅ 關鍵:創建共享的 client 實例(避免 client closed 錯誤)
        self.client = self._create_client()

    def _create_client(self) -> genai.Client:
        """創建 Vertex AI client"""
        return genai.Client(
            vertexai=True,  # 啟用 Vertex AI
            project=os.getenv('GOOGLE_CLOUD_PROJECT'),
            location=os.getenv('GOOGLE_CLOUD_LOCATION', 'us-central1'),
            http_options=types.HttpOptions(api_version="v1")
        )

    def get_or_create_session(self, user_id: str) -> Tuple[object, List[dict]]:
        """獲取或創建用戶的 chat session"""
        now = datetime.now()

        # 檢查現有 session
        if user_id in self.sessions:
            session_data = self.sessions[user_id]
            if not self._is_session_expired(session_data):
                session_data['last_active'] = now
                return session_data['chat'], session_data['history']

        # 創建新 session with Google Search Grounding
        config = types.GenerateContentConfig(
            temperature=0.7,
            max_output_tokens=2048,
            # ✅ 啟用 Google Search
            tools=[types.Tool(google_search=types.GoogleSearch())],
        )

        # 使用共享的 self.client(不會被關閉)
        chat = self.client.chats.create(
            model="gemini-2.0-flash",
            config=config
        )

        self.sessions[user_id] = {
            'chat': chat,
            'last_active': now,
            'history': [],
            'created_at': now
        }

        return chat, []

修復要點:

  1. 共享 Client - self.client__init__() 中創建,生命週期與 ChatSessionManager 相同
  2. 自動過期 - 30 分鐘後 session 自動過期
  3. 對話隔離 - 每個用戶的 session 完全獨立

3. 實作搜尋和回答函數

接著實作使用 Grounding 搜尋並回答的核心函數:

async def search_and_answer_with_grounding(
    query: str,
    user_id: str,
    session_manager: ChatSessionManager
) -> dict:
    """使用 Vertex AI Grounding 搜尋並回答問題"""
    try:
        # 獲取或創建 chat session
        chat, history = session_manager.get_or_create_session(user_id)

        # 構建 prompt(繁體中文 + 不使用 markdown)
        prompt = f"""請用台灣用語的繁體中文回答以下問題。
如果需要最新資訊,請搜尋網路並提供準確的答案。
請提供詳細且有用的回答,並確保資訊來源可靠。
請不要使用 markdown 格式(不要用 **、##、- 等符號)。使用純文字回答。

問題:{query}"""

        # 發送訊息(Gemini 會自動決定是否需要搜尋)
        response = chat.send_message(prompt)

        # 記錄到歷史
        session_manager.add_to_history(user_id, "user", query)
        session_manager.add_to_history(user_id, "assistant", response.text)

        # 提取引用來源
        sources = []
        if hasattr(response, 'candidates') and response.candidates:
            candidate = response.candidates[0]
            if hasattr(candidate, 'grounding_metadata'):
                metadata = candidate.grounding_metadata
                if hasattr(metadata, 'grounding_chunks'):
                    for chunk in metadata.grounding_chunks:
                        if hasattr(chunk, 'web'):
                            sources.append({
                                'title': chunk.web.title,
                                'uri': chunk.web.uri
                            })

        return {
            'answer': response.text,
            'sources': sources,
            'has_history': len(history) > 0
        }

    except Exception as e:
        logger.error(f"Grounding search failed: {e}")
        raise

關鍵特性:

  • ✅ Gemini 自動判斷何時需要搜尋
  • ✅ 閱讀完整網頁內容(不只是 snippet)
  • ✅ 自動提取引用來源
  • ✅ 支援連續對話(記住上下文)

4. 整合到 main.py

main.py 中整合 Grounding 功能:

from loader.chat_session import (
    ChatSessionManager,
    search_and_answer_with_grounding,
    format_grounding_response,
    get_session_status_message
)

# 初始化 Session Manager
chat_session_manager = ChatSessionManager(session_timeout_minutes=30)

async def handle_text_message(event: MessageEvent, user_id: str):
    """處理純文字訊息 - 使用 Grounding"""
    msg = event.message.text.strip()

    # 特殊指令
    if msg.lower() in ['/clear', '/清除']:
        chat_session_manager.clear_session(user_id)
        reply_msg = TextSendMessage(text="✅ 對話已重置")
        await line_bot_api.reply_message(event.reply_token, [reply_msg])
        return

    if msg.lower() in ['/status', '/狀態']:
        status_text = get_session_status_message(chat_session_manager, user_id)
        reply_msg = TextSendMessage(text=status_text)
        await line_bot_api.reply_message(event.reply_token, [reply_msg])
        return

    # 使用 Grounding 搜尋和回答
    try:
        result = await search_and_answer_with_grounding(
            query=msg,
            user_id=user_id,
            session_manager=chat_session_manager
        )

        response_text = format_grounding_response(result, include_sources=True)
        reply_msg = TextSendMessage(text=response_text)
        await line_bot_api.reply_message(event.reply_token, [reply_msg])

    except Exception as e:
        logger.error(f"Error in Grounding search: {e}", exc_info=True)
        error_text = "❌ 抱歉,處理您的問題時發生錯誤。請稍後再試。"
        reply_msg = TextSendMessage(text=error_text)
        await line_bot_api.reply_message(event.reply_token, [reply_msg])

實際應用範例

實作後的功能非常強大,可以進行智能對話:

範例 1:基本問答

用戶: Python 是什麼?
Bot: Python 是一種高階、直譯式的程式語言,由 Guido van Rossum 於 1991 年創建...

     📚 參考來源:
     1. Python 官方網站
        https://www.python.org/

範例 2:連續對話(對話記憶)

用戶: Python 是什麼?
Bot: [答案...]

用戶: 它有什麼優點?  ✅ Bot 知道 "它" = Python
Bot: 💬 [對話中]

     Python 的主要優點包括:
     1. 語法簡潔易讀
     2. 豐富的標準庫
     ...

範例 3:最新資訊搜尋

用戶: 日本最新地震消息
Bot: 根據最新資訊,日本在 2025 年 12 月...
     [Gemini 自動搜尋網路並整理最新資訊]

     📚 參考來源:
     1. 中央氣象署
     2. NHK 新聞

使用情境

這些應用場景特別適合:

  • 💬 智能客服 - 自動搜尋最新產品資訊
  • 📰 新聞助手 - 追蹤最新時事
  • 🎓 學習助手 - 解答問題並提供可靠來源
  • 🔍 研究助理 - 快速搜尋和整理資訊

環境設定

必要環境變數

# Vertex AI 設定(必要)
export GOOGLE_CLOUD_PROJECT="your-project-id"
export GOOGLE_CLOUD_LOCATION="us-central1"  # 可選,預設為 us-central1

# 認證方式(擇一)
# 方式 1: 使用 ADC (開發環境)
gcloud auth application-default login

# 方式 2: 使用 Service Account (生產環境)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"

# 啟用 Vertex AI API
gcloud services enable aiplatform.googleapis.com

不再需要的環境變數

由於改用 Grounding,以下環境變數已不再需要:

# ❌ 不再需要
# SEARCH_API_KEY=...
# SEARCH_ENGINE_ID=...

這簡化了配置,也省下 Google Custom Search API 的費用!

代碼清理

移除舊版 searchtool 代碼

由於已經使用 Grounding,我進行了代碼清理:

  1. main.py - 移除 searchtool import
    # ❌ 已移除
    # from loader.searchtool import search_from_text
    # search_api_key = os.getenv('SEARCH_API_KEY')
    # search_engine_id = os.getenv('SEARCH_ENGINE_ID')
       
    # ✅ 新增
    logger.info('Text search using Vertex AI Grounding with Google Search')
    
  2. loader/searchtool.py - 標記為 DEPRECATED
    """
    ⚠️ DEPRECATED: This module is no longer used in the main application.
       
    The text search functionality has been replaced by Vertex AI Grounding
    with Google Search, which provides better quality results and native
    conversation memory.
       
    This file is kept for reference or as a fallback option.
    """
    
  3. .env.exampleREADME.md - 移除 Custom Search 環境變數說明

清理成果

項目 清理前 清理後
必要環境變數 4 個 2 個
API 調用 3 次 1 次
代碼複雜度
維護成本

支援的模型清單

目前支援 Google Search Grounding 的 Gemini 模型:

  • ✅ Gemini 3.0 Pro (Preview)(功能強大)
  • ✅ Gemini 2.5 Pro
  • ✅ Gemini 2.5 Flash
  • ✅ Gemini 2.0 Flash(推薦使用)
  • ✅ Gemini 2.5 Flash with Live API
  • ❌ Gemini 2.0 Flash-Lite(不支援 Grounding)

效能提升

速度比較

指標 舊版 新版 改善
API 調用次數 3 次 1 次 ⬇️ 66%
回應時間 ~6-8 秒 ~2-3 秒 ⬇️ 60%
搜尋品質 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⬆️ 大幅提升

成本分析

舊版成本(每次問答):

1. extract_keywords_with_gemini()  → Gemini API
2. Google Custom Search           → $0.005
3. summarize_text()               → Gemini API
                                    ─────────
                                    總計:Gemini + $0.005

新版成本(每次問答):

1. Grounding with Google Search   → Vertex AI
                                    ─────────
                                    總計:僅 Vertex AI

省下 Custom Search API 費用更快的回應速度更高的搜尋品質

目前需要注意的地方

1. 必須使用 Vertex AI

Google Search Grounding 功能不支援一般的 Gemini Developer API,必須透過 Vertex AI 存取。

2. 認證設定

  • 開發環境:使用 gcloud auth application-default login
  • 生產環境:使用 Service Account 並設定 GOOGLE_APPLICATION_CREDENTIALS

3. 支援的模型

確保使用支援 Grounding 的模型(如 gemini-2.0-flash 以上),避免使用 -lite 版本。

4. Client 生命週期

務必在 __init__() 中創建共享的 client 實例,避免 “client closed” 錯誤。

5. Prompt 優化

在 prompt 中明確指示:

  • 使用繁體中文
  • 不使用 markdown 格式(如果需要純文字)
  • 提供可靠來源

開發心得

1. Grounding 是遊戲規則改變者

從傳統的「關鍵字提取 → API 搜尋 → 結果總結」流程,到使用 Grounding 的「一次 API 調用完成所有事情」,這個轉變帶來的不只是技術上的簡化,更是用戶體驗的質變:

技術層面:

  • ✅ 代碼量減少 70%(從 3 個函數到 1 個)
  • ✅ API 調用減少 66%(從 3 次到 1 次)
  • ✅ 回應時間縮短 60%(從 6-8 秒到 2-3 秒)

用戶體驗:

  • ✅ 支援連續對話(終於能理解 “它” 指的是什麼了!)
  • ✅ 自動引用來源(增加可信度)
  • ✅ 更深入的資訊(完整網頁 vs. 簡短 snippet)

2. Client 生命週期管理很重要

最初遇到的 “client closed” 錯誤讓我學到:在使用 google-genai SDK 時,client 應該是長期存活的物件,而不是每次都創建新的。

# ❌ 錯誤:client 會被垃圾回收
def create_session():
    client = genai.Client(...)
    chat = client.chats.create(...)
    return chat  # client 被關閉,chat 無法使用

# ✅ 正確:共享 client 實例
class Manager:
    def __init__(self):
        self.client = genai.Client(...)  # 只創建一次

    def create_session(self):
        return self.client.chats.create(...)  # 重複使用

這個教訓適用於所有需要管理長連線的 SDK。

3. RAG 不一定要自己實作

過去我們需要自己實作 RAG(檢索增強生成):

  1. 使用 embedding 建立向量資料庫
  2. 實作相似度搜尋
  3. 將檢索結果注入 prompt
  4. 管理 context window

但 Google Search Grounding 已經幫我們做好這一切!它:

  • ✅ 自動判斷何時需要搜尋
  • ✅ 使用 Google 的搜尋引擎(比我們自己做的好太多)
  • ✅ 閱讀完整網頁並提取重要資訊
  • ✅ 自動引用來源

結論: 如果你的 RAG 需求是「搜尋網路資訊」,直接用 Grounding 就好,不要重新發明輪子。

4. Session 管理比想像中簡單

實作對話記憶時,我原本以為需要:

  • Redis 持久化
  • 複雜的 context 管理
  • 手動維護對話歷史

但實際上,Gemini Chat API 原生支援多輪對話!只需要:

chat = client.chats.create(...)
chat.send_message("問題 1")  # 第 1 輪
chat.send_message("問題 2")  # 第 2 輪(自動記住第 1 輪)

我只需要做:

  • 將 chat 物件存在記憶體
  • 定期清理過期 session
  • 提供 /clear 指令

簡單、高效、可靠!

5. Prompt 優化的重要性

最初的回應包含很多 markdown 格式(**粗體**## 標題),在 LINE 上顯示不美觀。只需在 prompt 中加一行:

prompt = f"""...
請不要使用 markdown 格式(不要用 **、##、- 等符號)。使用純文字回答。
問題:{query}"""

就解決了問題!這讓我體會到:好的 prompt 設計和好的代碼一樣重要。

6. 從失敗中學習

這次開發過程中,我經歷了:

  1. ❌ 使用 Custom Search → 發現太慢、太淺
  2. ✅ 改用 Grounding → 但遇到 client closed 錯誤
  3. ✅ 修復 client 生命週期 → 發現 markdown 格式問題
  4. ✅ 優化 prompt → 完美!

每個問題都是學習的機會。 如果一開始就成功,我不會學到這麼多關於 SDK 設計、生命週期管理和 prompt 工程的知識。

總結

如果你正在開發需要搜尋功能的 AI 應用:

  • 優先考慮 Grounding - 比自己實作 RAG 簡單太多
  • 注意 Client 生命週期 - 避免不必要的重複創建
  • 善用 Chat Session - 原生對話記憶很強大
  • 投資在 Prompt 優化 - 小改動帶來大改善

Google Search Grounding 絕對值得一試!

測試步驟

1. 啟動應用程式

# 確認環境變數已設定
export GOOGLE_CLOUD_PROJECT=your-project-id

# 重啟應用
uvicorn main:app --reload

2. 測試基本功能

在 LINE 中測試:

發送:Python 是什麼?
預期:✅ 收到詳細回答 + 來源

發送:它有什麼優點?
預期:✅ 看到 "💬 [對話中]" 標記,Bot 知道 "它" = Python

發送:/status
預期:✅ 顯示對話狀態

發送:/clear
預期:✅ 顯示 "對話已重置"

3. 檢查日誌

應該看到:

INFO:main:Text search using Vertex AI Grounding with Google Search
INFO:loader.chat_session:Creating new session for user ...
INFO:loader.chat_session:Sending message to Grounding API ...

不應該看到:

ERROR:loader.chat_session:Grounding search failed: Cannot send a request, as the client has been closed.

相關文檔

專案中的詳細技術文檔:

  • TEXT_SEARCH_IMPROVEMENT.md - 完整的方案分析和比較
  • GROUNDING_IMPLEMENTATION.md - 實作指南和驗收清單
  • CLIENT_CLOSED_FIX.md - Client 生命週期錯誤修復
  • SEARCHTOOL_CLEANUP.md - 代碼清理總結

參考資料

[n8n][Gemini] 打造 AI 自動摘要的 RSS 訂閱系統,每日定時推送 LINE 通知

image-20251205112721295

前情提要

身為一個資訊焦慮的工程師,我每天都會追蹤多個技術部落格和 Hacker News。但手動瀏覽實在太花時間,於是我決定用 n8n 打造一個自動化系統:RSS 更新時自動抓取網頁內容、用 Gemini AI 產生摘要、存入 Google Sheets,然後每天早上 6 點推送精選文章到 LINE

這個專案整合了多個服務:

  • 📡 RSS Feed:訂閱多個資訊來源
  • 🕷️ Firecrawl:抓取網頁完整內容
  • 🤖 Gemini 2.5 Flash:AI 自動摘要
  • 📊 Google Sheets:儲存文章資料
  • 📱 LINE Messaging API:Flex Message 推送通知

聽起來很美好,但實作過程中踩了不少坑,這篇文章記錄我遇到的問題和解決方案。

系統架構

整個系統分成兩個獨立的 n8n Workflow:

Workflow 1:RSS 即時處理

Google Chrome 2025-12-05 11.27.59

RSS 觸發 → 格式化資料 → Firecrawl 抓取網頁 → 內容預處理 → Gemini 摘要 → 寫入 Google Sheets

Workflow 2:每日定時發送

image-20251205112906919

每日 6:00 觸發 → 讀取 Google Sheets → 篩選未發送 → 取 10 筆 → 組合 Flex Message → LINE 推送 → 更新狀態

開發過程中遇到的問題

問題 1:n8n Code Node 語法錯誤

我一開始在 Code Node 使用 ES Module 語法:

// ❌ 錯誤的做法
export default async function () {
  const items = this.getInputData();
  // ...
}

結果 n8n 一直報錯,執行失敗。

解決方案: 改用 n8n 標準的寫法,直接使用 $input.all()

// ✅ 正確的做法
const items = $input.all();

const newItems = items.map(item => {
  // 處理邏輯
  return {
    json: {
      ...item.json,
      // 新增欄位
    }
  };
});

return newItems;

問題 2:Gemini API 回傳 MAX_TOKENS 錯誤

送出請求後,Gemini 回傳了這個結果:

{
  "candidates": [
    {
      "content": { "role": "model" },
      "finishReason": "MAX_TOKENS",
      "index": 0
    }
  ],
  "usageMetadata": {
    "promptTokenCount": 568,
    "totalTokenCount": 867,
    "thoughtsTokenCount": 299
  }
}

一開始我以為是輸入太長,但仔細看 promptTokenCount 只有 568,問題出在 輸出 token 限制

原來 Gemini 2.5 Flash 有 Thinking 功能,會消耗一部分 output token 做內部思考。我設定 maxOutputTokens: 300,但 thinking 就用掉了 299,實際輸出只剩 1 個 token!

解決方案: 提高 maxOutputTokens 或關閉 Thinking 功能:

// 方案 1:提高 output token 限制
{
  "generationConfig": {
    "temperature": 0.7,
    "maxOutputTokens": 1024  // 從 300 提高到 1024
  }
}

// 方案 2:關閉 Thinking 功能
{
  "generationConfig": {
    "temperature": 0.7,
    "maxOutputTokens": 512,
    "thinkingConfig": {
      "thinkingBudget": 0  // 關閉 thinking
    }
  }
}

問題 3:Firecrawl 抓取的內容太雜

Firecrawl 會抓取整個網頁,包含導覽列、側欄、留言區等雜訊。直接送給 Gemini 會浪費 token,也影響摘要品質。

解決方案: 在送給 Gemini 之前,先用 Code Node 清理內容:

const items = $input.all();
const maxLen = 1500;  // 限制最大字數

const newItems = items.map(item => {
  const title = item.json.title || '';
  const raw = item.json.content || '';

  // 1. 移除雜訊
  let text = raw
    .replace(/```[\s\S]*?```/g, '')              // 移除程式碼區塊
    .replace(/`[^`]+`/g, '')                     // 移除行內程式碼
    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')        // 移除 markdown 圖片
    .replace(/<[^>]+>/g, '')                     // 移除 HTML 標籤
    .replace(/https?:\/\/\S+/g, '')              // 移除 URL
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')     // 保留連結文字
    .replace(/[#>*`|_~]/g, '')                   // 移除 markdown 符號
    .replace(/\n{3,}/g, '\n\n')                  // 壓縮換行
    .replace(/\s{2,}/g, ' ')                     // 壓縮空白
    .trim();

  // 2. 切掉無關內容
  const cutPatterns = [
    'Leave a Reply', 'Recent Comments', 'Related Posts',
    'Share this', 'Subscribe', 'Newsletter', 'Copyright',
    '關於作者', '延伸閱讀', '相關文章', '留言'
  ];
  
  for (const pattern of cutPatterns) {
    const idx = text.indexOf(pattern);
    if (idx > 200) {
      text = text.slice(0, idx);
    }
  }

  // 3. 限制長度,保留完整句子
  text = text.slice(0, maxLen);
  if (text.length === maxLen) {
    const lastPeriod = Math.max(
      text.lastIndexOf(''),
      text.lastIndexOf(''),
      text.lastIndexOf(''),
      text.lastIndexOf('. ')
    );
    if (lastPeriod > maxLen * 0.5) {
      text = text.slice(0, lastPeriod + 1);
    }
  }

  // 4. 組成精簡的 prompt
  const prompt = `用繁體中文寫100字以內摘要,只輸出摘要正文:

標題:${title}

內容:
${text}`;

  return {
    json: {
      ...item.json,
      prompt: prompt
    }
  };
});

return newItems;

問題 4:LINE Flex Message 報錯 “message is invalid”

LINE Push Message 回傳錯誤:

A message (messages[0]) in the request body is invalid

檢查 Flex Message JSON 後發現,有些文章的 title 欄位是空的,導致 "text": undefined。LINE API 不接受空的 text 欄位。

問題根源: Google Sheets 讀出來的欄位名稱不是 title,而是 col_1(因為標題列設定問題)。

解決方案: 在 Build Flex Message 時加上 fallback:

const items = $input.first().json.data || [];

const bubbles = items.map((item) => {
  // 修正:檢查多個可能的欄位名稱,並提供預設值
  const title = item.title || item.col_1 || item.link || '無標題';
  const summary = item.summary || '無摘要內容';
  const link = item.link || 'https://example.com';
  const source = item.source || 'Unknown';
  
  return {
    "type": "bubble",
    "size": "kilo",
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": title,  // 確保永遠有值
          "weight": "bold",
          "wrap": true
        },
        {
          "type": "text",
          "text": summary,  // 確保永遠有值
          "size": "sm",
          "wrap": true
        }
      ]
    },
    // ...
  };
});

API Credential 設定

Firecrawl API Key

n8n 中選擇 Header Auth

欄位
Name Authorization
Value Bearer fc-your-api-key

Gemini API Key

n8n 中選擇 Header Auth

欄位
Name x-goog-api-key
Value your-gemini-api-key

⚠️ 注意: Gemini 用的是 x-goog-api-key header,不是 Bearer token!

LINE Channel Access Token

n8n 中選擇 Header Auth

欄位
Name Authorization
Value Bearer your-channel-access-token

Google Sheets 欄位設計

title link summary source created_at sent
文章標題 網址 AI 摘要 來源 發布時間 FALSE

⚠️ 重要: 確保第一行的標題列正確設定,否則 n8n 讀出來的 key 會變成 col_1, col_2 這種格式!

LINE Flex Message 效果

最終的 Flex Message 是 Carousel 格式,每篇文章一張卡片:

┌─────────────────────────┐
│ 📝 DK                   │  ← 來源標籤 + emoji
├─────────────────────────┤
│ 文章標題                  │  ← 粗體標題
│                         │
│ 摘要內容摘要內容摘要       │  ← 100 字摘要
│ 內容摘要內容...           │
├─────────────────────────┤
│    [閱讀原文]            │  ← 按鈕連結
└─────────────────────────┘

不同來源有不同的顏色和 emoji:

  • 📝 DK (藍色 #4A90A4)
  • 🔥 HN (橘色 #FF6600)
  • 🎮 Steam (深藍 #1B2838)
  • 🇯🇵 LY Blog (綠色 #00C300)

踩坑總結

問題 原因 解決方案
Code Node 執行失敗 ES Module 語法不相容 使用 $input.all() 標準寫法
Gemini MAX_TOKENS Thinking 功能消耗 output token 提高 maxOutputTokens 到 1024
摘要品質差 網頁雜訊太多 預處理移除無關內容
LINE message invalid Flex Message 有空值 加上 fallback 預設值
Google Sheets 欄位名稱錯誤 標題列未正確設定 確保第一行有正確的欄位名稱

開發心得

這次專案讓我學到幾個重要的經驗:

  1. Gemini 2.5 的 Thinking 功能會消耗 output token:如果你的輸出被截斷,先檢查 thoughtsTokenCount,可能需要提高 maxOutputTokens 或關閉 thinking。

  2. n8n Code Node 要用標準寫法:避免使用 export defaultthis.getInputData(),直接用 $input.all() 最穩定。

  3. 永遠要處理空值:API 回傳的資料可能缺少欄位,在組合輸出時一定要加上 fallback。

  4. 預處理很重要:送給 AI 的內容越乾淨,摘要品質越好,也越省 token。

  5. Google Sheets 的欄位名稱取決於標題列:如果讀出來的 key 是 col_1,代表標題列有問題。

這個系統現在每天早上 6 點會自動推送 10 篇精選文章到我的 LINE,終於可以在通勤時快速掌握技術動態了!🎉

參考資料

[Gemini][Google Maps] 使用 Google Maps Grounding API 打造位置感知的 AI 應用

image-20251202231128366

前情提要

在開發 LINE Bot 時,我想加入一個功能:讓使用者分享位置後,AI 可以智慧推薦附近的餐廳、加油站或停車場。傳統做法需要串接 Google Places API,處理複雜的搜尋邏輯和結果排序。但 Google 在 2024 年推出了 Grounding with Google Maps 功能,可以讓 Gemini 模型直接存取 Google Maps 的 2.5 億個地點資訊,讓 AI 回應自動帶有地理位置脈絡!

這項功能透過 Vertex AI 提供,可以讓 Gemini 模型「接地氣」(grounded)地回答位置相關問題,不再只是憑空想像。

開發過程中遇到的問題

在實作 maps_grounding.py 時,我最初使用 Gemini Developer API 搭配 API Key 的方式:

# ❌ 錯誤的做法
client = genai.Client(
    api_key=api_key,
    http_options=HttpOptions(api_version="v1")
)

response = client.models.generate_content(
    model="gemini-2.0-flash-lite",  # 不支援 Maps Grounding
    contents=query,
    config=GenerateContentConfig(
        tools=[Tool(google_maps=GoogleMaps())],
        tool_config=ToolConfig(...)
    ),
)

結果出現了這個錯誤:

google.genai.errors.ClientError: 400 INVALID_ARGUMENT.
{'error': {'code': 400, 'message': 'Invalid JSON payload received.
Unknown name "tools": Cannot find field.
Invalid JSON payload received. Unknown name "toolConfig": Cannot find field.'}}

經過查閱文件後才發現,Google Maps Grounding 只支援 Vertex AI,無法使用 Gemini Developer API

正確的解決方案

1. 理解 API 差異

Google 提供兩種不同的 Gemini API 存取方式:

特性 Gemini Developer API Vertex AI API
認證方式 API Key ADC / Service Account
Maps Grounding ❌ 不支援 ✅ 支援
企業級功能 有限 完整
適用場景 快速原型開發 生產環境

2. 修正程式碼

以下是正確的實作方式:

from google import genai
from google.genai import types

# ✅ 正確的做法:使用 Vertex AI
client = genai.Client(
    vertexai=True,  # 啟用 Vertex AI 模式
    project=project_id,  # GCP 專案 ID
    location=location,  # 建議使用 'global'
    http_options=types.HttpOptions(api_version="v1")
)

# 使用支援 Maps Grounding 的模型
response = client.models.generate_content(
    model="gemini-2.0-flash",  # ✅ 支援的模型
    contents=query,
    config=types.GenerateContentConfig(
        tools=[
            types.Tool(google_maps=types.GoogleMaps(
                enable_widget=False
            ))
        ],
        tool_config=types.ToolConfig(
            retrieval_config=types.RetrievalConfig(
                lat_lng=types.LatLng(
                    latitude=latitude,
                    longitude=longitude
                ),
                language_code="zh-TW",  # 支援繁體中文
            ),
        ),
    ),
)

3. 環境設定

要使用 Maps Grounding,需要設定以下環境變數:

# 必要的環境變數
export GOOGLE_CLOUD_PROJECT="your-project-id"
export GOOGLE_CLOUD_LOCATION="global"
export GOOGLE_GENAI_USE_VERTEXAI="True"

# 認證方式(擇一)
# 方式 1: 使用 ADC (開發環境)
gcloud auth application-default login

# 方式 2: 使用 Service Account (生產環境)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"

# 啟用 Vertex AI API
gcloud services enable aiplatform.googleapis.com

實際應用範例

image-20251202231340480

實作後的功能非常強大,可以用自然語言查詢附近地點:

async def search_nearby_places(
    latitude: float,
    longitude: float,
    place_type: str = "restaurant",
    custom_query: Optional[str] = None,
    language_code: str = "zh-TW"
) -> str:
    """
    使用 Google Maps Grounding API 搜尋附近地點

    範例查詢:
    - "請幫我找出附近的加油站,並列出名稱、距離和地址。"
    - "請幫我找出附近評價不錯的餐廳,並列出名稱、類型和地址。"
    """

使用情境

  1. 對話式助理:「幫我找附近好喝的義式濃縮咖啡店」
  2. 個人化推薦:「有哪些適合親子、步行可達的餐廳?」
  3. 地區總結:「這個飯店附近有什麼特色?」

這些應用場景特別適合:

  • 🏠 房地產平台
  • ✈️ 旅遊規劃
  • 🚗 移動出行
  • 📱 社交媒體

支援的模型清單

目前支援 Google Maps Grounding 的 Gemini 模型:

  • ✅ Gemini 2.5 Pro
  • ✅ Gemini 2.5 Flash
  • ✅ Gemini 2.0 Flash
  • ✅ Gemini 2.5 Flash with Live API
  • ❌ Gemini 2.0 Flash-Lite(不支援)

Google Maps Platform Code Assist (MCP)

Code Assist Toolkit header

在開發過程中,我也發現 Google 推出了 Google Maps Platform Code Assist toolkit,這是一個基於 Model Context Protocol (MCP) 的工具,可以:

  • 🔍 即時文件檢索:透過 RAG 技術搜尋最新的官方文件和程式碼範例
  • 🤖 AI 助手整合:支援 Gemini CLI、Claude Code、Cursor 等多種開發環境
  • 📚 豐富的資源:涵蓋官方文件、教學、GitHub 範例和安全資源

如何使用 MCP

# 使用 Node.js 安裝
npm install -g @googlemaps/code-assist-mcp

# 在 Claude Code 或 Cursor 中設定 MCP 伺服器
# 之後就能直接在 AI 助手中查詢最新的 Google Maps 文件
gemini extensions install https://github.com/googlemaps/platform-ai.git

#or

claude mcp add google-maps-platform-code-assist -- npx -y @googlemaps/code-assist-mcp@latest

這個工具特別適合在開發時快速查詢 API 用法,不用在瀏覽器和編輯器之間切換!

使用後的成果

iTerm2 2025-12-02 22.38.11

可以看到透過使用 Google Maps Platform Code Assist 之後,他們能找到完整的範例程式碼,並且知道要設定哪些相關參數。可以一次就將所有的功能都修復完成。

我原本有使用 Context7 但是對於 Google Map 相關的設定還是有錯誤,並且也使用錯的 API 。這部分還是需要找到相關的 MCP 來使用才會正確。

以下就是一段範例程式碼來使用 Google Map Grounding API

prompt = "What are the best Italian restaurants within a 15-minute walk from here?"

response = client.models.generate_content(
    model='gemini-2.5-flash',
    contents=prompt,
    config=types.GenerateContentConfig(
        # Turn on grounding with Google Maps
        tools=[types.Tool(google_maps=types.GoogleMaps())],
        # Optionally provide the relevant location context (this is in Los Angeles)
        tool_config=types.ToolConfig(retrieval_config=types.RetrievalConfig(
            lat_lng=types.LatLng(
                latitude=34.050481, longitude=-118.248526))),
    ),
)

目前需要注意的地方

1. 必須使用 Vertex AI

Maps Grounding 功能不支援一般的 Gemini Developer API,必須透過 Vertex AI 存取。

2. 認證設定

  • 開發環境:使用 gcloud auth application-default login
  • 生產環境:使用 Service Account 並設定 GOOGLE_APPLICATION_CREDENTIALS

3. 支援的模型

確保使用支援的模型(如 gemini-2.0-flash),避免使用 -lite 版本。

4. 區域選擇

建議將 GOOGLE_CLOUD_LOCATION 設為 global 以獲得最佳可用性。

5. 成本考量

Vertex AI 的計費方式與 Developer API 不同,建議先在定價頁面了解費用結構。

開發心得

這次從錯誤中學到的最大收穫是:並非所有 Gemini 功能都能透過 Developer API 存取。企業級功能如 Maps Grounding、進階安全過濾器等,都需要透過 Vertex AI。

雖然設定 Vertex AI 比單純使用 API Key 複雜一些,但換來的是:

  • ✅ 更強大的功能(Maps Grounding、Search Grounding)
  • ✅ 更完整的企業級支援
  • ✅ 更靈活的部署選項
  • ✅ 更細緻的存取控制

如果你正在開發需要位置感知的 AI 應用,Google Maps Grounding 絕對值得一試!

參考資料

[VS Code][Colab] Google 正式釋出 Colab VS Code Plugin

Connecting to a new Colab server and executing a code cell

前情提要

Google Colab 是一個我很喜歡的服務,你可以在線上透過 JupyterNotebook 的介面,快速使用到 GPU (甚至是 TPU)。有許多需要大量運算資源的東西,都可以很快速的在遠端的機器上面執行。

我自己很常在上面去嘗試一些模型,雖然常常排隊排不到機器。

image-20251114155345861

使用 Colab 可能有的痛點

雖然使用Google Colab 機器非常的方便,但是由於在線上編輯有一些比較麻煩的地方:

  • 無法使用 Copilot 這類型的 Code Assist Tool 來幫我 Auto-Complete 一些程式碼
  • 無法跑 Gemini CLI Code Assist 來幫我寫出一些更多的測試或是幫忙想應用。

Colab for VS Code Plugin

但是現在 Colab VS Code Extension 終於可以在 VS Code Plugin 上面使用了。你可以透過 “Colab” 直接找到官方釋出的 Plugin 。

Code 2025-11-14 15.55.03

安裝過程相當的簡單又快速。

連線到 Colab

Code 2025-11-14 15.23.10

如果要連線,在選擇 Kernel 的時候,就可以選擇 Colab 來遠端連線。

Code 2025-11-14 15.23.16

這裡還可以快速連線,或是找你上次連線過的伺服器。

Code 2025-11-14 15.23.22

這裡就是讓人興奮的地方,可以找找 TPU (不保證排得到隊伍)來用用看。

這樣就可以了。

實際應用:

Code 2025-11-14 15.54.52

這樣比較對味啦!! Vibe Coding 出現之後,我們越來越習慣 Vibe Coding 了。但是如果需要 Step by Step 的去偵錯,或是想要跑一些大型機器才能運行的運算。真的還是需要透過 Colab 來幫忙,但是如果又希望可以有 Gemini CLI 的輔助的話,或許 Colab VS Code Extension 就是你不可或缺的好夥伴。

目前一些需要注意的地方

由於 Colab VS Code Plugin 還在持續開發中,有一些原本在 Colab Web UI 上可以使用的 google.colab 功能目前還無法在 VS Code 中使用。以下是一些主要的限制:

  • auth.authenticate_user(): 認證 URL 會出現在選單中,無法直接點擊。建議改用 Python Cloud Client Library。
  • drive.mount(): 目前無法掛載 Google Drive,可以改用 Drive Python API 來存取檔案。
  • files.download() / files.upload(): 原生的檔案上傳下載功能無法使用,但可以透過 IPyWidget 來達成相同效果。
  • userdata.get(): 目前會回傳錯誤,暫時需要從 Colab Web UI 複製 secret 值到 notebook 中。

雖然有這些限制,但整體來說 Colab VS Code Plugin 還是大幅提升了開發體驗,特別是對於習慣使用 VS Code 和各種 AI Coding Assistant 的開發者來說,絕對是值得一試的好工具!

參考資料

[Python] LINE Bot 名片管家進化:一鍵生成 vCard QR Code,讓名片直接加入手機通訊錄

image-20251114103158657

前情提要

在先前的 LINE Bot 智慧名片管家 專案中,我們已經實作了使用 Gemini Pro Vision API 自動辨識名片的功能。使用者只要拍照上傳名片,AI 就能自動解析姓名、職稱、公司、電話、Email 等資訊,並儲存到 Firebase Realtime Database 中。

但在實際使用時,我發現了一個痛點:

📱 我已經有數位化的名片資料了,但要加入手機通訊錄還是得手動一個一個欄位輸入…

想像這些情境:

  • 📇 參加研討會:收集了 20 張名片,辨識完成後還要手動加入通訊錄
  • 💼 業務拜訪:拿到客戶名片,想快速加入手機聯絡人
  • 🤝 社交場合:認識新朋友,希望立即儲存聯絡方式

於是我想:既然資料已經數位化了,為什麼不能一鍵加入通訊錄呢?

最理想的方式就是:生成 vCard QR Code,讓使用者掃描後直接加入通訊錄

專案程式碼

https://github.com/kkdai/linebot-namecard-python

(透過這個程式碼,可以快速部署到 GCP Cloud Run,享受無伺服器的便利)

📚 關於 vCard 與 QR Code

vCard 格式介紹

vCard(Virtual Contact File)是一種電子名片的標準格式,副檔名為 .vcf。幾乎所有智慧型手機和郵件客戶端都原生支援 vCard,包括:

  • 📱 iPhone:自動識別並提示「加入聯絡人」
  • 🤖 Android:透過聯絡人 App 匯入
  • 💻 電腦:Outlook、Apple Mail、Gmail 等都支援

vCard 3.0 格式範例

BEGIN:VCARD
VERSION:3.0
FN:Kevin Dai
N:Dai;Kevin;;;
ORG:LINE Taiwan
TITLE:Software Engineer
TEL;TYPE=WORK,VOICE:+886-123-456-789
EMAIL;TYPE=WORK:[email protected]
ADR;TYPE=WORK:;;Taipei, Taiwan;;;;
NOTE:Met at DevFest 2025
END:VCARD

QR Code + vCard 的優勢

將 vCard 編碼成 QR Code 有幾個好處:

  1. 一掃即加:相機 App 掃描後自動識別
  2. 跨平台:iPhone/Android 都支援
  3. 無需下載:不用儲存檔案再匯入
  4. 資料完整:包含所有聯絡資訊和備註

✨ 專案功能介紹

核心功能流程

使用者上傳名片圖片
    ↓
Gemini Vision API 辨識
    ↓
儲存到 Firebase Realtime Database
    ↓
顯示名片 Flex Message
    ↓
【新功能】點擊「📥 加入通訊錄」按鈕
    ↓
生成 vCard QR Code
    ↓
上傳到 Firebase Storage
    ↓
回傳 QR Code 圖片給使用者
    ↓
使用者掃描 → 加入通訊錄 ✅

新增功能亮點

  1. 📥 一鍵生成 QR Code
    • 點擊名片上的「加入通訊錄」按鈕
    • 自動生成包含完整資訊的 vCard QR Code
    • 包含姓名、職稱、公司、電話、Email、地址、備註
  2. ☁️ Firebase Storage 整合
    • QR Code 圖片上傳到 Firebase Storage
    • 自動設為公開可讀取
    • 透過 LINE ImageMessage 發送給使用者
  3. 🤖 Gemini Vision 協作
    • 原有的名片辨識功能(Gemini Vision API)
    • 辨識結果 → Firebase Database → QR Code
    • AI 辨識 + 雲端儲存 + 行動應用的完整整合
  4. 📱 使用者友善
    • 自動產生使用說明
    • 支援 iPhone/Android
    • 掃描即可加入通訊錄

💻 核心功能實作

1. vCard 格式生成

首先實作 vCard 格式字串的生成,這是整個功能的基礎。

檔案位置: app/qrcode_utils.py

def generate_vcard_string(namecard_data: Dict[str, str]) -> str:
    """
    Generate vCard 3.0 format string from namecard data.

    Args:
        namecard_data: Dictionary containing namecard fields

    Returns:
        vCard formatted string
    """
    name = namecard_data.get('name', '')
    title = namecard_data.get('title', '')
    company = namecard_data.get('company', '')
    phone = namecard_data.get('phone', '')
    email = namecard_data.get('email', '')
    address = namecard_data.get('address', '')
    memo = namecard_data.get('memo', '')

    # Build vCard 3.0 format
    vcard_lines = [
        'BEGIN:VCARD',
        'VERSION:3.0',
        f'FN:{name}',
        f'N:{name};;;',  # Family Name; Given Name; Additional Names; Honorific Prefixes; Honorific Suffixes
    ]

    if company:
        vcard_lines.append(f'ORG:{company}')

    if title:
        vcard_lines.append(f'TITLE:{title}')

    if phone:
        # Clean phone number format for vCard
        clean_phone = phone.replace('-', '').replace(' ', '')
        vcard_lines.append(f'TEL;TYPE=WORK,VOICE:{clean_phone}')

    if email:
        vcard_lines.append(f'EMAIL;TYPE=WORK:{email}')

    if address:
        # vCard address format: PO Box;Extended Address;Street;City;Region;Postal Code;Country
        vcard_lines.append(f'ADR;TYPE=WORK:;;{address};;;;')

    if memo:
        # Escape special characters in memo
        escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
        vcard_lines.append(f'NOTE:{escaped_memo}')

    vcard_lines.append('END:VCARD')

    return '\n'.join(vcard_lines)

設計要點

  • ✅ 使用 vCard 3.0 格式(相容性最好)
  • 處理空欄位:只在有資料時才加入對應欄位
  • 電話號碼清理:移除 - 和空格,確保格式正確
  • 特殊字元轉義:備註中的換行、逗號、分號需要轉義
  • 完整資訊:包含備註欄位,保留 AI 辨識時的額外資訊

2. QR Code 圖片生成

使用 qrcode 套件將 vCard 字串編碼成 QR Code 圖片。

def generate_vcard_qrcode(namecard_data: Dict[str, str],
                          box_size: int = 10,
                          border: int = 2) -> BytesIO:
    """
    Generate QR Code image containing vCard data.

    Args:
        namecard_data: Dictionary containing namecard fields
        box_size: Size of each box in pixels (default: 10)
        border: Border size in boxes (default: 2)

    Returns:
        BytesIO object containing PNG image data
    """
    # Generate vCard string
    vcard_string = generate_vcard_string(namecard_data)

    # Create QR Code instance
    qr = qrcode.QRCode(
        version=None,  # Auto-determine version based on data size
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=box_size,
        border=border,
    )

    # Add vCard data
    qr.add_data(vcard_string)
    qr.make(fit=True)

    # Generate image
    img = qr.make_image(fill_color="black", back_color="white")

    # Save to BytesIO
    img_bytes = BytesIO()
    img.save(img_bytes, format='PNG')
    img_bytes.seek(0)  # Reset pointer to beginning

    return img_bytes

關鍵參數說明

參數 說明 選擇理由
version=None 自動決定 QR Code 大小 根據資料量自動調整,確保可掃描
error_correction=L 錯誤修正等級(Low) vCard 資料不會頻繁損壞,選擇最小等級以減少 QR Code 大小
box_size=10 每個方塊 10 像素 在手機螢幕上有良好的掃描性
border=2 邊框 2 個方塊寬 符合 QR Code 標準的最小邊框

為什麼使用 BytesIO?

  • ✅ 不需要寫入實體檔案系統
  • ✅ 直接在記憶體中處理圖片
  • ✅ 方便後續上傳到 Firebase Storage
  • ✅ 減少 I/O 操作,提升效能

3. Firebase Storage 整合

這是整個功能的核心:將 QR Code 圖片上傳到 Firebase Storage 並取得公開 URL。

檔案位置: app/firebase_utils.py

from firebase_admin import storage
from io import BytesIO

def upload_qrcode_to_storage(
        image_bytes: BytesIO, user_id: str, card_id: str) -> str:
    """
    上傳 QR Code 圖片到 Firebase Storage 並回傳公開 URL

    Args:
        image_bytes: QR Code 圖片的 BytesIO 物件
        user_id: 使用者 ID
        card_id: 名片 ID

    Returns:
        圖片的公開 URL,若失敗則回傳 None
    """
    try:
        bucket = storage.bucket()
        blob_name = f"qrcodes/{user_id}/{card_id}.png"
        blob = bucket.blob(blob_name)

        # 上傳圖片
        image_bytes.seek(0)  # 重置指標到開頭
        blob.upload_from_file(image_bytes, content_type='image/png')

        # 設定為公開可讀取
        blob.make_public()

        # 回傳公開 URL
        return blob.public_url
    except Exception as e:
        print(f"Error uploading QR code to storage: {e}")
        return None

設計考量

  1. 檔案路徑結構qrcodes/{user_id}/{card_id}.png
    • 按使用者分類,方便管理
    • 使用 card_id 確保檔名唯一
    • 同一張名片重複生成會覆蓋舊檔案
  2. 公開權限blob.make_public()
    • QR Code 需要被 LINE Bot 透過 URL 存取
    • Firebase Storage Rules 設為 allow read: if true
    • 寫入權限只給 Admin SDK(Cloud Run)
  3. Content-Type 設定content_type='image/png'
    • 確保瀏覽器正確顯示圖片
    • LINE ImageMessage 需要正確的 MIME type

4. Firebase 初始化配置

app/main.py 中正確設定 Firebase Storage Bucket:

import firebase_admin
from firebase_admin import credentials

# Firebase 初始化
firebase_config = {
    "databaseURL": config.FIREBASE_URL,
}
# 如果設定了 Storage Bucket,則加入配置
if config.FIREBASE_STORAGE_BUCKET:
    firebase_config["storageBucket"] = config.FIREBASE_STORAGE_BUCKET

try:
    cred = credentials.ApplicationDefault()
    firebase_admin.initialize_app(cred, firebase_config)
    print("Firebase Admin SDK initialized successfully.")
except Exception as e:
    # 從環境變數解析 JSON
    gac_str = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS_JSON")
    if gac_str:
        cred_json = json.loads(gac_str)
        cred = credentials.Certificate(cred_json)
        firebase_admin.initialize_app(cred, firebase_config)
        print("Firebase Admin SDK initialized successfully from ENV VAR.")

環境變數設定

# 部署到 Cloud Run 時需要設定
FIREBASE_STORAGE_BUCKET=your-project-id.firebasestorage.app

# 或舊格式
FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com

為什麼需要明確設定 Storage Bucket?

  • Firebase Admin SDK 預設只初始化 Database
  • 如果不指定 storageBucket,呼叫 storage.bucket() 會失敗
  • 明確設定可避免執行時錯誤

5. LINE Bot Postback 處理

當使用者點擊「加入通訊錄」按鈕時,處理完整流程。

檔案位置: app/line_handlers.py

from linebot.models import ImageSendMessage, TextSendMessage

async def handle_download_contact(
        event: PostbackEvent, user_id: str, card_id: str, card_name: str):
    """處理下載聯絡人 QR Code 的請求"""
    try:
        # 1. 從 Firebase 取得完整的名片資料
        card_data = firebase_utils.get_card_by_id(user_id, card_id)
        if not card_data:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='找不到該名片資料。'))
            return

        # 2. 生成 vCard QR Code
        qrcode_image = qrcode_utils.generate_vcard_qrcode(card_data)

        # 3. 上傳到 Firebase Storage 並取得 URL
        image_url = firebase_utils.upload_qrcode_to_storage(
            qrcode_image, user_id, card_id)

        if not image_url:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='生成 QR Code 時發生錯誤,請稍後再試。'))
            return

        # 4. 生成使用說明
        instruction_text = qrcode_utils.get_qrcode_usage_instruction(card_name)

        # 5. 回傳 QR Code 圖片和使用說明
        image_message = ImageSendMessage(
            original_content_url=image_url,
            preview_image_url=image_url
        )
        text_message = TextSendMessage(text=instruction_text)

        await line_bot_api.reply_message(
            event.reply_token,
            [image_message, text_message])

    except Exception as e:
        print(f"Error in handle_download_contact: {e}")
        await line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text='處理您的請求時發生錯誤,請稍後再試。'))

流程設計亮點

  1. 完整錯誤處理:每個步驟都有錯誤檢查
  2. 友善提示:失敗時給予明確的錯誤訊息
  3. 一次回傳兩則訊息:圖片 + 說明文字
  4. 非同步處理:使用 async/await 避免阻塞

6. Flex Message 按鈕配置

在名片的 Flex Message 中新增「加入通訊錄」按鈕。

檔案位置: app/flex_messages.py

"footer": {
    "type": "box",
    "layout": "vertical",
    "spacing": "sm",
    "contents": [
        {
            "type": "box",
            "layout": "horizontal",
            "spacing": "sm",
            "contents": [
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "新增/修改記事",
                        "data": f"action=add_memo&card_id={card_id}",
                        "displayText": f"我想為 {name} 新增記事"
                    },
                    "flex": 1
                },
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "編輯資料",
                        "data": f"action=edit_card&card_id={card_id}",
                        "displayText": f"我想編輯 {name} 的名片"
                    },
                    "flex": 1
                }
            ]
        },
        {
            "type": "button",
            "style": "primary",
            "height": "sm",
            "action": {
                "type": "postback",
                "label": "📥 加入通訊錄",
                "data": f"action=download_contact&card_id={card_id}",
                "displayText": f"下載 {name} 的聯絡人資訊"
            },
            "margin": "sm"
        }
    ]
}

UI 設計考量

┌────────────────────────────────────┐
│  [新增/修改記事]  [編輯資料]       │  ← 第一排並排(link style)
│  [📥 加入通訊錄]                   │  ← 第二排獨立(primary style)
└────────────────────────────────────┘
  • 第一排並排:常用的編輯功能,使用 link 樣式
  • 第二排獨立:下載功能,使用 primary 樣式突出顯示
  • Emoji 視覺化:📥 圖示讓使用者一眼識別下載功能

7. 使用說明生成

提供清楚的使用指引,讓使用者知道如何使用 QR Code。

def get_qrcode_usage_instruction(name: str) -> str:
    """
    Get user instruction message for using the QR Code.

    Args:
        name: Name of the person on the namecard

    Returns:
        Instruction message string
    """
    return f"""已為「{name}」生成聯絡人 QR Code!

📱 使用方式:
1. 用手機相機 App 掃描上方的 QR Code
2. 系統會自動識別聯絡人資訊
3. 點擊「加入聯絡人」即可匯入

✅ 支援 iPhone 和 Android 所有智慧型手機"""

設計理念

  • 個人化訊息:包含名片主人的姓名
  • 步驟清楚:1-2-3 簡單明瞭
  • 跨平台說明:強調 iPhone/Android 都支援
  • Emoji 視覺化:📱 和 ✅ 讓訊息更友善

🤖 Gemini Vision API 在整體架構中的角色

雖然這次的 QR Code 功能本身沒有用到 Gemini,但整個名片管家系統是以 Gemini Vision API 為核心的完整應用。

Gemini + Firebase Storage 的協作流程

┌─────────────────────────────────────────────┐
│  使用者上傳名片照片                          │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  LINE Bot 接收圖片                          │
│  (app/line_handlers.py)                    │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  Gemini Pro Vision API 辨識                │
│  - 姓名、職稱、公司                         │
│  - 電話、Email、地址                        │
│  (app/gemini_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  儲存到 Firebase Realtime Database         │
│  /namecard/{user_id}/{card_id}/            │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  使用者點擊「📥 加入通訊錄」                 │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  生成 vCard QR Code                        │
│  (app/qrcode_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  上傳到 Firebase Storage                   │
│  qrcodes/{user_id}/{card_id}.png           │
│  (app/firebase_utils.py)                   │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  回傳 QR Code 給使用者                      │
│  使用者掃描 → 加入通訊錄 ✅                  │
└─────────────────────────────────────────────┘

Gemini Vision API 的關鍵作用

app/gemini_utils.py 中,我們使用 Gemini Pro Vision 解析名片圖片:

def generate_json_from_image(img: PIL.Image, prompt: str):
    """
    Use Gemini Pro Vision to extract structured data from image.
    """
    model = genai.GenerativeModel('gemini-1.5-pro')
    response = model.generate_content([prompt, img])
    return response

Prompt 設計 (app/config.py):

IMGAGE_PROMPT = """
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
其中 phone 的內容格式為 #886-0123-456-789,1234. 沒有分機就忽略 ,1234
"""

為什麼選擇 Gemini Vision?

  1. 中文辨識能力強:台灣名片常有中文,Gemini 處理效果好
  2. 結構化輸出:直接生成 JSON 格式,方便解析
  3. 容錯能力:無法辨識時自動填 “N/A”
  4. 彈性格式:支援各種名片版型

Firebase 雙服務整合

這個專案同時使用了 Firebase 的兩大服務:

服務 用途 資料類型 存取方式
Realtime Database 儲存名片結構化資料 JSON firebase_admin.db
Storage 儲存 QR Code 圖片 Binary (PNG) firebase_admin.storage

為什麼需要兩個服務?

  • Database:適合結構化資料,支援即時查詢和更新
  • Storage:適合大型二進位檔案,提供 CDN 加速

資料流向

Gemini Vision → Database (結構化資料)
                    ↓
                QR Code 生成
                    ↓
                Storage (圖片檔案)
                    ↓
                LINE Bot (圖片 URL)

🔧 遇到的挑戰與解決方案

1. Firebase Storage Bucket 配置問題

問題:初始化 Firebase Admin SDK 時,沒有設定 Storage Bucket 導致錯誤。

錯誤訊息

ValueError: Invalid None value for Firebase Storage bucket.

原因分析

  • Firebase Admin SDK 預設只初始化 Realtime Database
  • 必須在 initialize_app() 時明確指定 storageBucket
  • 環境變數未正確設定

解決方案

  1. config.py 新增配置
    FIREBASE_STORAGE_BUCKET = os.environ.get("FIREBASE_STORAGE_BUCKET")
    
  2. main.py 初始化時加入: ```python firebase_config = { “databaseURL”: config.FIREBASE_URL, } if config.FIREBASE_STORAGE_BUCKET: firebase_config[“storageBucket”] = config.FIREBASE_STORAGE_BUCKET

firebase_admin.initialize_app(cred, firebase_config)


3. **部署時設定環境變數**:
```bash
--set-env-vars "...,FIREBASE_STORAGE_BUCKET=line-vertex.firebasestorage.app,..."

學到的經驗

  • Firebase 不同服務需要不同的配置參數
  • 環境變數要完整檢查,避免 runtime 錯誤
  • 新格式 .firebasestorage.app 和舊格式 .appspot.com 都支援

2. Storage Rules 的權限設定

問題:如何設定 Firebase Storage Rules,讓 Cloud Run 能寫入,但 QR Code 圖片可以公開讀取?

初始想法

// ❌ 這樣會讓任何人都能寫入
allow read, write: if true;

正確方案

利用 Firebase Admin SDK 會繞過 Rules 的特性:

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read: if true;   // 任何人都可以讀取
      allow write: if false; // 禁止客戶端寫入
    }
  }
}

為什麼這樣可行?

  1. ✅ Cloud Run 使用 Admin SDK,有完整權限(繞過 Rules)
  2. allow read: if true 讓 LINE Bot 能透過 URL 存取圖片
  3. allow write: if false 阻止惡意客戶端上傳檔案
  4. blob.make_public() 設定的公開權限仍然有效

關鍵學習

  • Admin SDK vs 客戶端 SDK 的權限差異
  • Storage Rules 只影響客戶端存取
  • 雲端服務使用 Admin SDK 是最佳實踐

3. QR Code 大小與掃描性優化

問題:生成的 QR Code 太小或太大都不好掃描。

實驗過程

參數組合 結果 問題
box_size=5, border=1 圖片太小 手機掃描困難
box_size=15, border=4 圖片太大 LINE 壓縮後失真
box_size=10, border=2 ✅ 適中 掃描順暢

最終方案

qr = qrcode.QRCode(
    version=None,         # 自動調整大小
    error_correction=qrcode.constants.ERROR_CORRECT_L,  # 低錯誤修正
    box_size=10,          # 每個方塊 10px
    border=2,             # 邊框 2 個方塊
)

為什麼選擇 ERROR_CORRECT_L(低錯誤修正)?

  • vCard 資料相對穩定,不會損壞
  • 低錯誤修正 = QR Code 更簡單 = 掃描更快
  • 如果用高錯誤修正(H),QR Code 會變得很複雜

實測結果

  • ✅ iPhone 相機:秒掃
  • ✅ Android 相機:秒掃
  • ✅ LINE 內建掃描器:正常

4. vCard 特殊字元處理

問題:備註中如果有換行、逗號、分號等特殊字元,會導致 vCard 格式錯誤。

錯誤範例

NOTE:這個人很重要,記得要聯絡;下次見面時間: 2025/11/15

vCard 解析器會把逗號和分號當作分隔符,導致資料錯亂。

解決方案

if memo:
    # Escape special characters in memo
    escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
    vcard_lines.append(f'NOTE:{escaped_memo}')

vCard 轉義規則

字元 轉義後 說明
換行 \n \\n 文字中的換行
逗號 , \\, 避免當作分隔符
分號 ; \\; 避免當作分隔符

學到的經驗

  • vCard 有自己的轉義規則,不能直接照搬 JSON
  • 使用者輸入的備註可能包含任何字元
  • 完整測試各種特殊字元情況

5. BytesIO 指標重置問題

問題:上傳圖片到 Firebase Storage 時,有時會上傳空檔案。

錯誤原因

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
# ❌ 此時指標在檔案末端

blob.upload_from_file(img_bytes, content_type='image/png')
# ❌ 從末端開始讀取 = 讀到空內容

解決方案

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)  # ✅ 重置指標到開頭

blob.upload_from_file(img_bytes, content_type='image/png')

為什麼需要 seek(0)?

  1. img.save() 會移動指標到檔案末端
  2. upload_from_file() 從當前位置開始讀取
  3. 如果不重置,會讀取 0 bytes

學到的經驗

  • 使用 BytesIO 要注意指標位置
  • 寫入後要記得 seek(0) 再讀取
  • 這是常見的新手陷阱

6. LINE ImageMessage 的 URL 要求

問題:有時候 QR Code 無法在 LINE 中顯示。

原因分析

LINE Bot 的 ImageSendMessage 對 URL 有嚴格要求:

  1. ✅ 必須是 HTTPS
  2. ✅ 圖片必須是 JPEG 或 PNG
  3. ✅ URL 必須公開可存取
  4. original_content_urlpreview_image_url 可以相同

正確用法

image_message = ImageSendMessage(
    original_content_url=image_url,  # Firebase Storage 的 public URL
    preview_image_url=image_url      # 可以用同一個 URL
)

Firebase Storage 的優勢

  • ✅ 自動提供 HTTPS URL
  • blob.make_public() 確保公開存取
  • ✅ CDN 加速,載入快速
  • blob.public_url 直接取得完整 URL

🎯 總結與未來改進

專案亮點

  1. 🤖 AI 驅動的名片辨識
    • Gemini Pro Vision API 自動解析名片
    • 支援中文名片,辨識準確率高
    • 結構化資料儲存,方便後續處理
  2. 📥 一鍵加入通訊錄
    • vCard QR Code 標準格式
    • iPhone/Android 原生支援
    • 掃描即加,無需手動輸入
  3. ☁️ Firebase 雙服務整合
    • Realtime Database 儲存結構化資料
    • Storage 儲存 QR Code 圖片
    • Admin SDK 確保安全性
  4. 🚀 無伺服器架構
    • 部署到 Google Cloud Run
    • 自動擴展,按需付費
    • 冷啟動優化,回應快速
  5. 🎨 使用者體驗優化
    • LINE Flex Message 精美介面
    • Postback 按鈕互動流暢
    • 清楚的使用說明

架構優勢

┌────────────────────────────────────────┐
│        Google Cloud Platform           │
│  ┌──────────────────────────────────┐  │
│  │      Cloud Run (無伺服器)        │  │
│  │  - FastAPI                       │  │
│  │  - LINE Bot SDK                  │  │
│  │  - Firebase Admin SDK            │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │   Gemini Pro Vision API         │  │
│  │  - 名片圖片辨識                  │  │
│  │  - 結構化資料提取                │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │      Firebase Services           │  │
│  │  - Realtime Database (名片資料)  │  │
│  │  - Storage (QR Code 圖片)        │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

實戰經驗分享

1. Firebase 服務的選擇

何時用 Realtime Database?

  • ✅ 結構化資料(JSON)
  • ✅ 需要即時查詢和更新
  • ✅ 資料量不大(名片資訊)
  • ✅ 需要簡單的查詢邏輯

何時用 Firebase Storage?

  • ✅ 二進位檔案(圖片、影片、PDF)
  • ✅ 需要公開存取 URL
  • ✅ 需要 CDN 加速
  • ✅ 檔案大小較大

這個專案的最佳組合

名片文字資料 → Realtime Database
QR Code 圖片 → Storage

2. vCard 標準的實用性

vCard 是個被低估的標準:

  • 跨平台:所有裝置都支援
  • 無需 APP:不用安裝額外軟體
  • 標準化:30 年歷史的成熟標準
  • 可擴展:支援照片、社群媒體等

使用情境遠超名片

  • 電子郵件簽名檔
  • 網站「聯絡我們」頁面
  • 會議報到系統
  • 社群媒體個人檔案

3. QR Code 的設計哲學

好的 QR Code 設計

  • ✅ 大小適中(10-15 px per module)
  • ✅ 最小邊框(2 modules)
  • ✅ 低錯誤修正(如果內容穩定)
  • ✅ 高對比度(黑白最佳)

避免過度設計

  • ❌ 加入 Logo(增加掃描難度)
  • ❌ 使用彩色(容易失真)
  • ❌ 過度藝術化(降低可讀性)

4. Firebase Admin SDK vs 客戶端 SDK

特性 Admin SDK 客戶端 SDK
執行環境 伺服器端 瀏覽器/手機
權限 完整權限(繞過 Rules) 受 Rules 限制
認證 Service Account 使用者認證
適用場景 Cloud Run, Cloud Functions Web App, Mobile App
安全性 高(不暴露憑證) 需要 Rules 保護

這個專案的選擇

  • ✅ 使用 Admin SDK(Cloud Run 環境)
  • ✅ Storage Rules 設為 write: false
  • ✅ Admin SDK 仍可寫入(繞過 Rules)

5. Gemini API 的最佳實踐

Prompt 設計技巧

# ✅ 好的 Prompt
"""
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
"""

# ❌ 不好的 Prompt
"Extract name, title, company from this business card"

為什麼第一個更好?

  1. 角色設定:「你是名片秘書」讓 AI 理解任務
  2. 明確格式:要求 JSON,不要其他說明
  3. 容錯處理:無法辨識時填 N/A
  4. 中文指令:處理中文名片時更準確

未來改進方向

1. 功能擴展

短期(1-2 週)

  • QR Code 加入公司 Logo(提升品牌識別)
  • 支援多種 QR Code 樣式選擇
  • QR Code 下載為檔案(不只圖片連結)
  • 批次生成多張名片的 QR Code

中期(1-2 個月)

  • 整合 NFC 虛擬名片(iPhone Wallet)
  • 支援 vCard 4.0 格式(更多欄位)
  • 名片分享統計(誰掃描了 QR Code)
  • 自訂 QR Code 設計(顏色、形狀)

長期(3-6 個月)

  • AI 名片管理助手(自動分類、提醒聯絡)
  • 與 Google Contacts / iCloud 同步
  • 名片交換記錄(何時何地交換)
  • 社群媒體整合(LinkedIn, Facebook)

2. 效能優化

QR Code 快取機制

# 目前:每次都重新生成並上傳
# 改進:檢查名片資料是否變更
if card_data_hash == cached_hash:
    return cached_qrcode_url  # 直接回傳快取的 URL

Storage 成本優化

  • 設定 QR Code 過期時間(7 天後自動刪除)
  • 使用 Cloud Storage Lifecycle Management
  • 壓縮圖片大小(目前約 5KB,可降至 2KB)

Cloud Run 冷啟動優化

  • 使用最小化的 Docker Image
  • Pre-import 常用套件
  • 設定最小實例數(避免冷啟動)

3. 安全性強化

當前挑戰

  • QR Code 圖片是公開的(任何人有 URL 都能存取)
  • 沒有使用者配額限制(惡意使用者可大量生成)
  • 沒有 Rate Limiting(防止 DoS)

改進方案

  1. Signed URL(簽名 URL)
    # 使用時效性 URL,1 小時後失效
    blob.generate_signed_url(expiration=timedelta(hours=1))
    
  2. 使用者配額管理
    # Firebase Database 記錄每個使用者的 QR Code 生成次數
    qrcode_count = db.reference(f"qrcode_quota/{user_id}").get()
    if qrcode_count > 100:  # 每日上限 100 次
     return "您已達到今日生成上限"
    
  3. Rate Limiting: ```python from slowapi import Limiter limiter = Limiter(key_func=get_remote_address)

@app.post(“/webhook”) @limiter.limit(“100/minute”) # 每分鐘最多 100 次請求 async def webhook(request: Request): …


#### 4. 使用體驗提升

**Rich Menu 設計**:

┌────────────┬────────────┬────────────┐ │ 📸 拍攝名片 │ 📇 我的名片 │ ⚙️ 設定 │ ├────────────┼────────────┼────────────┤ │ 📥 匯入名片 │ 🔍 搜尋名片 │ 💡 使用教學 │ └────────────┴────────────┴────────────┘


**名片分享功能**:
- 使用者可以分享自己的名片 QR Code
- 類似 LINE 的「我的 QR Code」
- 對方掃描後自動加入聯絡人

**智能提醒**:
```python
# 使用 Gemini 分析備註,自動設定提醒
if "下週要聯絡" in memo:
    # 設定 7 天後的提醒
    send_reminder(user_id, card_id, days=7)

關鍵學習

透過這個專案,我深入學習了:

  1. Firebase 生態系統
    • Realtime Database vs Firestore vs Storage 的選擇
    • Admin SDK 的權限模型與 Rules 的關係
    • 多服務整合的最佳實踐
  2. vCard 與 QR Code 標準
    • vCard 3.0 的格式規範與轉義規則
    • QR Code 參數優化(大小、錯誤修正)
    • 跨平台相容性測試
  3. Gemini Vision API
    • Prompt Engineering 技巧
    • 結構化資料提取
    • 中文處理的最佳實踐
  4. LINE Bot 開發
    • Flex Message 進階排版
    • Postback 互動設計
    • ImageMessage 的 URL 要求
  5. 雲端原生架構
    • 無伺服器設計模式
    • 環境變數管理
    • Storage 與 Database 的分工

最重要的體悟:

AI + 雲端 + 即時通訊 = 無限可能

這個專案展示了如何將三大技術結合:

  • 🤖 AI:Gemini Vision 自動辨識名片
  • ☁️ 雲端:Firebase 資料儲存 + Cloud Run 部署
  • 💬 即時通訊:LINE Bot 作為使用者介面

關鍵成功因素

  1. ✅ 選對工具(Gemini for OCR, Firebase for Storage)
  2. ✅ 標準化格式(vCard 確保相容性)
  3. ✅ 使用者體驗(一鍵加入通訊錄,無需學習)
  4. ✅ 安全設計(Admin SDK + Storage Rules)

希望這個經驗分享能幫助到正在探索 AI 應用開發的朋友們!

相關資源


如果你覺得這個專案有幫助,歡迎給個 Star ⭐,或是分享給需要的朋友!

LINE Messaging API 新功能介紹: Mark as Read API 讓你的聊天機器人標記訊息已讀

image-20251112102510088

在 2025 年 11 月 5 日,LINE Messaging API 推出了新的功能,讓聊天機器人可以將用戶發送的訊息標記為已讀。這項功能的推出,讓開發者能夠為用戶提供更好的互動體驗,用戶可以清楚知道機器人是否已經「看過」他們的訊息。

前言

image-20251112103925983

以往在回應設定中,如果開啟了聊天,希望可以用真人來回覆客戶的話。這個時候,由於系統是允許「真人聊天」跟「聊天機器人」是共存的。但是如果這個聊天選項打開的話,直到真人打開聊天視窗之前,即便客戶的訊息已經被聊天機器人處理了,他也不會標示成「已讀」。

這一篇文章就要跟大家分享,這一個新功能開放後。該如何應用這個新的 API 。

新 API 功能介紹

已讀標記功能

當用戶發送訊息給 LINE 官方帳號時,機器人現在可以主動將訊息標記為已讀狀態。這讓用戶在聊天介面上可以看到「已讀」的指示,就像一般的 1 對 1 聊天一樣。這項功能特別適合用於:

  • 客服機器人:讓用戶知道他們的問題已經被機器人接收並處理
  • 訂單通知機器人:確認用戶的訂單查詢已被讀取
  • 互動式問答機器人:提供更自然的對話體驗

SDK 版本需求

  • line-bot-sdk-go/v8: v8.18.0 或更新版本
  • Go: 1.24 或更新版本

新增 API 規格

LINE Messaging API 新增了兩個標記已讀的 API:

  1. MarkMessagesAsRead

    使用 userId 來標記特定用戶的所有未讀訊息為已讀。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead
    • 請求參數:
      {
        "chat": {
          "userId": "U1234567890abcdef1234567890abcdef"
        }
      }
      
  2. MarkMessagesAsReadByToken (本文重點)

    使用訊息專屬的 markAsReadToken 來標記特定訊息為已讀,更精確也更安全。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead/token
    • 請求參數:
      {
        "markAsReadToken": "abc123def456..."
      }
      

新 API 欄位介紹

markAsReadToken 欄位

LINE Messaging API v8.18.0 在各種訊息內容中新增了 markAsReadToken 欄位:

  • TextMessageContent.markAsReadToken: 文字訊息的已讀標記 token
  • StickerMessageContent.markAsReadToken: 貼圖訊息的已讀標記 token
  • ImageMessageContent.markAsReadToken: 圖片訊息的已讀標記 token
  • VideoMessageContent.markAsReadToken: 影片訊息的已讀標記 token
  • AudioMessageContent.markAsReadToken: 音訊訊息的已讀標記 token
  • FileMessageContent.markAsReadToken: 檔案訊息的已讀標記 token
  • LocationMessageContent.markAsReadToken: 位置訊息的已讀標記 token

每個訊息都會有一個唯一的 markAsReadToken,機器人可以使用這個 token 來標記該訊息為已讀。

如何使用 Golang 來開發相關部分

以下是使用 Golang 實作 Mark as Read 功能的完整範例程式碼: (請注意 github.com/line/line-bot-sdk-go/v8 需要更新到 8.18.0 之後)

範例程式碼在:https://github.com/kkdai/linebot-mark-as-read

實作方式:使用 Quick Reply + Postback

本範例採用使用者友善的互動方式:在每則回覆訊息上加上「Mark as Read」快速回覆按鈕,讓使用者可以主動選擇要將哪些訊息標記為已讀。

步驟 1: 接收訊息並提取 markAsReadToken

case webhook.TextMessageContent:
    // 從訊息內容中取得 markAsReadToken
    markAsReadToken := message.MarkAsReadToken
    log.Printf("Received text message with markAsReadToken: %s\n", markAsReadToken)

    // 建立 Quick Reply,將 token 儲存在 postback data 中
    quickReply := &messaging_api.QuickReply{
        Items: []messaging_api.QuickReplyItem{
            {
                Type: "action",
                Action: &messaging_api.PostbackAction{
                    Label:       "Mark as Read",
                    Data:        fmt.Sprintf("action=markasread&token=%s", markAsReadToken),
                    DisplayText: "Marked as read",
                },
            },
        },
    }

    // 回覆訊息,附帶 Quick Reply 按鈕
    if _, err = bot.ReplyMessage(
        &messaging_api.ReplyMessageRequest{
            ReplyToken: e.ReplyToken,
            Messages: []messaging_api.MessageInterface{
                messaging_api.TextMessage{
                    Text:       message.Text,
                    QuickReply: quickReply,
                },
            },
        },
    ); err != nil {
        log.Print(err)
    } else {
        log.Println("Sent text reply with Quick Reply button.")
    }

步驟 2: 處理 Postback 事件並呼叫 Mark as Read API

case webhook.PostbackEvent:
    // 當使用者點擊 "Mark as Read" 按鈕時觸發
    log.Printf("Postback event: data=%s\n", e.Postback.Data)

    // 解析 postback data 取得 action 和 token
    // 格式: "action=markasread&token=xxxxx"
    values, err := url.ParseQuery(e.Postback.Data)
    if err != nil {
        log.Printf("Failed to parse postback data: %v\n", err)
    } else {
        action := values.Get("action")
        markAsReadToken := values.Get("token")

        if action == "markasread" && markAsReadToken != "" {
            log.Printf("Marking messages as read with token: %s\n", markAsReadToken)

            // 呼叫 Mark as Read By Token API
            _, err := bot.MarkMessagesAsReadByToken(
                &messaging_api.MarkMessagesAsReadByTokenRequest{
                    MarkAsReadToken: markAsReadToken,
                },
            )
            if err != nil {
                log.Printf("Failed to mark messages as read: %v\n", err)
            } else {
                log.Println("Successfully marked messages as read using token")
            }
        }
    }

說明

本實作的流程如下:

  1. 接收訊息:當使用者發送訊息時,從 webhook event 的訊息內容中提取 markAsReadToken
  2. 儲存 token:將 token 編碼在 Quick Reply 按鈕的 postback data 中(格式:action=markasread&token={token}
  3. 回覆訊息:機器人回覆訊息,並附帶 “Mark as Read” 快速回覆按鈕
  4. 使用者互動:使用者看到按鈕並可以選擇點擊
  5. 觸發 Postback:點擊按鈕後觸發 PostbackEvent
  6. 解析 token:使用 url.ParseQuery() 解析 postback data 取得 token
  7. 呼叫 API:使用 bot.MarkMessagesAsReadByToken() 標記訊息為已讀
  8. 顯示已讀:使用者在 LINE 聊天介面看到訊息被標記為已讀

關鍵技術點

  • Quick Reply: 提供使用者友善的互動介面
  • PostbackAction: 可以攜帶資料(data)的按鈕動作
  • url.ParseQuery: 安全地解析查詢字串格式的 postback data
  • MarkMessagesAsReadByToken: 使用 token 精確標記特定訊息

這樣的設計讓使用者可以自主選擇要標記哪些訊息為已讀,提供更好的使用體驗。

未來展望

隨著 Mark as Read API 的推出,開發者可以探索更多創新的應用場景:

  1. 智能客服系統:當客服機器人處理完用戶問題後,自動將訊息標記為已讀,讓用戶清楚知道問題已被處理。搭配自動回覆和人工介入,提供更完整的客服體驗。

  2. 訂單追蹤機器人:用戶查詢訂單狀態時,機器人可以在查詢完成後標記訊息為已讀,給予用戶即時的回饋。這對電商平台的客戶體驗提升特別有幫助。

  3. 互動式教學機器人:在線上教學場景中,當學生提交作業或問題時,機器人可以在檢查或回答後標記已讀,讓學生知道老師(或 AI)已經看過他們的訊息。

  4. 任務管理機器人:企業內部使用的任務管理機器人,可以在接收到任務指派或狀態更新時,標記訊息為已讀,確保團隊成員知道訊息已被系統記錄。

  5. 條件式已讀標記:開發者可以設計更複雜的邏輯,例如:
    • 只在成功處理後才標記已讀
    • 根據訊息類型決定是否標記
    • 延遲一段時間後才標記(模擬真人閱讀)
    • 搭配其他 API(如 Typing indicator)提供更自然的互動
  6. 數據分析與優化:追蹤哪些訊息被標記為已讀,分析用戶行為模式,了解用戶與機器人的互動習慣,進一步優化回應策略和使用者體驗。

技術延伸

開發者還可以考慮:

  • 結合 AI 決策:使用 Gemini 或其他 AI 來決定何時應該標記訊息為已讀
  • 批次處理:在處理大量訊息時,批次標記已讀狀態
  • 狀態管理:在資料庫中記錄已讀狀態,避免重複標記
  • 錯誤處理:當 API 呼叫失敗時,實作重試機制

這些應用場景不僅能提升用戶體驗,也能為企業帶來更多的商業價值。Mark as Read API 雖然是一個簡單的功能,但配合創意的使用方式,可以大幅提升聊天機器人的互動品質,讓機器人的行為更接近真人,提供更好的使用者體驗。