iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

打造專屬 AI 參謀群:以 Gemini Spark、Workspace 與 ADK 構建自動化決策雷達系列 第 24 篇

Day 24:打通最後一哩路!將高價值情資同步至 Notion 關聯資料庫(含避坑實戰指南)

  • 分享至 

  • xImage
  •  

在前面的章節中,我們完成了情報的抓取、多維度清洗、LLM 精煉評分,並成功將結構化資料儲存至 Google Sheets。

然而,Google Sheets 適合做資料流中繼站與低成本備份,若要建立一個團隊便於檢索、具備卡片視圖(Board)、排版美觀且易於閱讀「二階推演」與「技術缺陷」的個人知識庫,Notion 依然是目前最理想的展示介面。

今天我們將實作六角架構下的 NotionSyncAdapter,打通情資管線的最後一哩路,並實作冪等性(Idempotence)防護,確保重複執行時不會洗版!


一、系統架構與資料流向

在 Hexagonal Architecture 中,我們透過定義在 src/ports/knowledge_sync.py 的 Port 介面,實作出負責對接外部系統的 Adapter:

[Google Sheets Adapter] 
        │ (讀取 Score >= 4 的情報候選)
        ▼
[Sync Pipeline Script]
        │ (調用 KnowledgeSyncPort)
        ▼
[NotionSyncAdapter] 
        ├── 1. Query: 檢查 Entry ID 是否已存在 (確保冪等性)
        │      ├── 已存在 ──> Skip / Update
        │      └── 不存在 ──> Create Page
        ▼
[Notion Database API]


二、前置作業:Notion 整合與資料庫配置

在撰寫程式前,請確保以下兩項配置完成:

1. 建立 Integration Token

  1. 前往 Notion Integrations 建立內部整合(Internal Integration)。
  2. 取得一串以 secret_ 開頭的 Internal Integration Secret。

2. 建立資料庫與精確欄位類型

在 Notion 建立一個 Database(Full-page 或 Inline 皆可),必須包含以下 Property(大小寫與型別需完全一致):

欄位名稱 (Property Name) 欄位型別 (Type) 說明
Title title 論文或文章標題
Entry ID rich_text (Text) 唯一的雜湊或識別碼(查重依據)
Domain select 領域類別(如 SCIENCE, TECH 等)
Score number 綜合評分
Source URL url 來源連結
Date date 發布或抓取日期

3. 將 Integration 連線至資料庫(關鍵步驟)

進入該資料庫頁面,點選右上角 ... $\rightarrow$ 下拉至 Connections $\rightarrow$ 點擊 Search for connections... $\rightarrow$ 搜尋並加入你剛剛建立的 Integration。


三、程式碼實作

1. 定義 KnowledgeSyncPort (src/ports/knowledge_sync.py)

from abc import ABC, abstractmethod
from src.core.models import IntelligenceItem

class KnowledgeSyncPort(ABC):
    @abstractmethod
    def sync_item(self, item: IntelligenceItem, overwrite: bool = False) -> bool:
        """同步單筆情報至外部知識庫"""
        pass

    @abstractmethod
    def batch_sync(self, items: list[IntelligenceItem], overwrite: bool = False) -> dict[str, int]:
        """批次同步情報"""
        pass


2. 實作 NotionSyncAdapter (src/adapters/notion_adapter.py)

為了避免 SDK 版本改版造成 Invalid request URL,在查詢端點直接使用 Python 原生 requests 發送標準 REST 請求,寫入部分則沿用官方 notion-client:

from __future__ import annotations

import logging
import uuid
from typing import Any

import requests
from notion_client import Client
from notion_client.errors import APIResponseError

from src.core.models import IntelligenceItem
from src.ports.knowledge_sync import KnowledgeSyncPort

logger = logging.getLogger(__name__)


class NotionSyncAdapter(KnowledgeSyncPort):
    def __init__(self, auth_token: str, database_id: str):
        self.auth_token = auth_token
        self.client = Client(auth=auth_token)
        # 清理並轉為標準 UUID 格式
        raw_id = database_id.strip().replace("-", "")
        self.database_id = str(uuid.UUID(raw_id))

    def _find_page_by_entry_id(self, entry_id: str) -> str | None:
        """依據 Entry ID 查詢是否已存在該筆情資(確保冪等性)"""
        url = f"https://api.notion.com/v1/databases/{self.database_id}/query"
        headers = {
            "Authorization": f"Bearer {self.auth_token}",
            "Notion-Version": "2022-06-28",
            "Content-Type": "application/json",
        }
        payload = {
            "filter": {
                "property": "Entry ID",
                "rich_text": {
                    "equals": str(entry_id).strip()
                }
            }
        }

        try:
            res = requests.post(url, headers=headers, json=payload, timeout=10)
            if res.status_code == 200:
                results = res.json().get("results", [])
                return results[0]["id"] if results else None
            else:
                logger.error(f"查詢 Notion 資料庫失敗 [{res.status_code}]: {res.text}")
                return None
        except Exception as e:
            logger.error(f"查詢連線失敗: {e}")
            return None

    def _build_properties(self, item: IntelligenceItem) -> dict[str, Any]:
        """將領域模型映射為 Notion Database 欄位屬性"""
        date_str = None
        if item.timestamp:
            date_str = item.timestamp.strftime("%Y-%m-%d") if hasattr(item.timestamp, "strftime") else str(item.timestamp)[:10]

        properties: dict[str, Any] = {
            "Title": {
                "title": [{"text": {"content": item.source_title or "Untitled"}}]
            },
            "Entry ID": {
                "rich_text": [{"text": {"content": str(item.entry_id)}}]
            },
            "Domain": {
                "select": {"name": item.domain or "GENERAL"}
            },
            "Score": {
                "number": float(item.verified_score or item.raw_score or 0)
            }
        }

        if item.source_url:
            properties["Source URL"] = {"url": item.source_url}

        if date_str:
            properties["Date"] = {"date": {"start": date_str}}

        return properties

    def _build_children_blocks(self, item: IntelligenceItem) -> list[dict[str, Any]]:
        """建立頁面內的 Rich Text 區塊結構(包含 Callout 與各級標題)"""
        blocks = []

        # 核心提煉 Callout
        summary_text = item.editorial_summary or "暫無編輯精煉摘要"
        blocks.append({
            "object": "block",
            "type": "callout",
            "callout": {
                "rich_text": [{"text": {"content": f"核心提煉:\n{summary_text}"}}],
                "icon": {"emoji": "💡"}
            }
        })

        # 量化對比與效能指標
        blocks.append({
            "object": "block",
            "type": "heading_2",
            "heading_2": {
                "rich_text": [{"text": {"content": "📊 量化對比與效能指標"}}]
            }
        })
        blocks.append({
            "object": "block",
            "type": "paragraph",
            "paragraph": {
                "rich_text": [{"text": {"content": item.metrics or "無顯著量化指標揭露。"}}]
            }
        })

        # 技術邊界與缺陷
        blocks.append({
            "object": "block",
            "type": "heading_2",
            "heading_2": {
                "rich_text": [{"text": {"content": "⚠️ 技術邊界與潛在缺陷"}}]
            }
        })
        blocks.append({
            "object": "block",
            "type": "paragraph",
            "paragraph": {
                "rich_text": [{"text": {"content": item.limitations or "作者未於論文中強調顯著邊界。"}}]
            }
        })

        # 二階效應
        blocks.append({
            "object": "block",
            "type": "heading_2",
            "heading_2": {
                "rich_text": [{"text": {"content": "🔮 二階效應與跨領域衝擊"}}]
            }
        })
        blocks.append({
            "object": "block",
            "type": "callout",
            "callout": {
                "rich_text": [{"text": {"content": item.cross_impact_notes or "暫無二階推演。" }}],
                "icon": {"emoji": "🚀"}
            }
        })

        return blocks

    def sync_item(self, item: IntelligenceItem, overwrite: bool = False) -> bool:
        """同步單筆情報至 Notion,若已存在則檢查 overwrite 決定是否更新"""
        existing_page_id = self._find_page_by_entry_id(str(item.entry_id))
        properties = self._build_properties(item)

        try:
            if existing_page_id:
                if not overwrite:
                    logger.info(f"Notion 頁面已存在,略過更新: [{item.entry_id}]")
                    return False
                
                # 覆寫既有頁面屬性
                self.client.pages.update(page_id=existing_page_id, properties=properties)
                logger.info(f"成功更新 Notion 頁面: [{item.entry_id}]")
                return True
            else:
                # 建立新頁面
                children = self._build_children_blocks(item)
                self.client.pages.create(
                    parent={"database_id": self.database_id},
                    properties=properties,
                    children=children
                )
                logger.info(f"成功建立 Notion 新頁面: [{item.entry_id}]")
                return True
        except APIResponseError as err:
            logger.error(f"寫入 Notion API 失敗 [{item.entry_id}]: {err}")
            return False

    def batch_sync(self, items: list[IntelligenceItem], overwrite: bool = False) -> dict[str, int]:
        stats = {"synced": 0, "skipped": 0}
        for item in items:
            if self.sync_item(item, overwrite=overwrite):
                stats["synced"] += 1
            else:
                stats["skipped"] += 1
        return stats


3. 同步主入口腳本 (scripts/sync_to_notion.py)

from __future__ import annotations

import logging
import os
from dotenv import load_dotenv

from src.adapters.notion_adapter import NotionSyncAdapter
from src.adapters.sheets_adapter import SheetsAdapter

load_dotenv()

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - [%(levelname)s] - %(message)s"
)
logger = logging.getLogger(__name__)

def main():
    token = os.getenv("NOTION_API_TOKEN")
    database_id = os.getenv("NOTION_DATABASE_ID")

    if not token or not database_id:
        logger.error("缺少 NOTION_API_TOKEN 或 NOTION_DATABASE_ID 環境變數!")
        return

    logger.info("連線至 Google Sheets 讀取已處理情報...")
    sheets_adapter = SheetsAdapter()
    candidates = sheets_adapter.fetch_processed_items(min_score=4)
    logger.info(f"[*] 找到 {len(candidates)} 筆高價值情報候選項目...")

    notion_adapter = NotionSyncAdapter(auth_token=token, database_id=database_id)
    results = notion_adapter.batch_sync(candidates)
    logger.info(f"[+] Notion 同步完成!新增/更新: {results['synced']} 篇,略過: {results['skipped']} 篇。")

if __name__ == "__main__":
    main()


四、實戰成果驗證

執行同步腳本:

python -m scripts.sync_to_notion

初次執行輸出:

2026-10-21 18:30:10 - [INFO] - 連線至 Google Sheets 讀取已處理情報...
2026-10-21 18:30:13 - [INFO] - [*] 找到 8 筆高價值情報候選項目...
2026-10-21 18:30:14 - [INFO] - 成功建立 Notion 新頁面: [dfd9abe0]
...
2026-10-21 18:30:18 - [INFO] - [+] Notion 同步完成!新增/更新: 8 篇,略過: 0 篇。

重複執行(驗證冪等性):

2026-10-21 18:38:10 - [INFO] - 連線至 Google Sheets 讀取已處理情報...
2026-10-21 18:38:13 - [INFO] - [*] 找到 8 筆高價值情報候選項目...
2026-10-21 18:38:14 - [INFO] - Notion 頁面已存在,略過更新: [dfd9abe0]
...
2026-10-21 18:38:18 - [INFO] - [+] Notion 同步完成!新增/更新: 0 篇,略過: 8 篇。


五、💡 實戰避坑指南 (Troubleshooting FAQ)

Q1: 遇到 404 ObjectNotFound: Could not find database with ID...?

  • 原因:Notion 的權限採嚴格的頁面級別授權(Page-level Access)。在全域設定頁看到 Integration 呈現 Connected,並不代表該 Database 具備讀寫權限。
  • 解法:在目標資料庫的右上角點擊 ... $\rightarrow$ Connections $\rightarrow$ 搜尋你的 Integration 名稱(如 Intelligence-Radar-Sync)並點擊授權。

Q2: 網址是 [app.notion.com/p/](https://app.notion.com/p/)...,哪一段才是真正 Database ID?

  • 無論是 Full-page 還是 Inline Database,複製出來的網址通常為:
    [https://app.notion.com/p/](https://app.notion.com/p/){workspace_slug}/{database_id}?v={view_id}
  • 請取網址中 ?v= 前方那一串 32 碼英數字元。在呼叫 API 前,建議用 uuid.UUID(raw_id) 轉為標準 UUID 格式。?v= 後面是視圖(View)的 ID,不可作為 Database ID。

Q3: 出現 400 Bad Request: Invalid request URL 卻依然新增成功?

  • 假性成功現象:在原版程式邏輯中,若 _find_page_by_entry_id() 查詢報錯,except 攔截後會回傳 None。這會讓系統誤以為「此資料庫還沒有這筆資料」,進而執行 pages.create,導致重複新增。
  • 原因:新版 notion-client SDK 在直接使用 client.request 時,容易與伺服器路由校驗衝突。
  • 解法:直接使用 Python 原生 requests 發送 POST [https://api.notion.com/v1/databases/](https://api.notion.com/v1/databases/){id}/query,並確保資料庫中用於過濾的 Entry ID 欄位型態為 Text(rich_text)。

Q4: 跑這個同步腳本會消耗 LLM API 的 Tokens 嗎?

  • 完全不會。這個同步腳本純粹屬於資料管線的搬運工(ETL / Data Pipeline),只涉及 Google Sheets API 與 Notion REST API 的讀寫傳輸,不會消耗任何模型 Token。

上一篇
Day 23:全自動化雲端閉環:GitHub Actions 透過 SSH Deploy Key 自動 Push 至私有 Obsidian Vault
下一篇
Day 25:透明化監控!設計 Agent Trace 日誌與結構化思考過程追蹤
系列文
打造專屬 AI 參謀群:以 Gemini Spark、Workspace 與 ADK 構建自動化決策雷達 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言