iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

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

Day 24:跨平台延伸:實作 Notion API Adapter 將情報同步至 Notion 關聯資料庫

  • 分享至 

  • xImage
  •  

在 Day 21 到 Day 23 中,我們建構了 Markdown 導向的沉澱管線,實現將高價值情報自動同步至 Obsidian 本地 Vault 與 GitHub 私有儲存庫。

然而,在現代團隊協作與跨裝置瀏覽中,許多人偏好使用具備豐富關聯資料庫(Relational Database)、過濾檢視(Views)、看板模式(Board View)與官方行動端支援的 Notion。

今天我們將貫徹六角架構(Hexagonal Architecture)的威力:核心領域模型與評估管線完全不需變動,只需實現 KnowledgeSyncPort 輸出端口的另一個轉接器——NotionSyncAdapter,即可將結構化情報(包含標籤、評分、二階推演與 Callout 區塊)寫入 Notion Database。


一、架構擴展:多輸出端口無縫抽換

在六角架構中,輸出適配器(Driven Adapters)是可插拔的組件。核心業務邏輯僅依賴於 KnowledgeSyncPort 介面:

                              ┌────────────────────────┐
                              │  IntelligenceItem      │
                              │  (領域模型 Domain)      │
                              └───────────┬────────────┘
                                          │
                                          ▼
                              ┌────────────────────────┐
                              │  KnowledgeSyncPort     │
                              │  (輸出端口 Driven Port) │
                              └─────┬────────────┬─────┘
                                    │            │
            ┌───────────────────────┘            └───────────────────────┐
            ▼                                                            ▼
┌────────────────────────┐                                  ┌────────────────────────┐
│  ObsidianVaultAdapter  │                                  │  NotionSyncAdapter     │
│  (Markdown / Git Push) │                                  │  (REST API / Blocks)   │
└────────────────────────┘                                  └────────────────────────┘

系統可以同時配置兩個轉接器,達成「本地 Markdown 備份 + 雲端 Notion 協作」的雙軌同步機制。


二、Notion 前置整合設定

使用 Notion API 寫入 Database 前,需完成以下三個設定:

  1. 建立 Internal Integration Token:
  • 前往 Notion 整合管理中心。
  • 點擊「+ New integration」,取名為 Intelligence-Radar-Sync。
  • 權限勾選 Read content、Update content 與 Insert content。
  • 複製取得的 Internal Integration Secret(格式通常為 ntn_... 或 secret_...)。
  1. 建立目標 Database 並取得 Database ID:
  • 在 Notion 中新增一個資料表(Database - Full page)。

  • 資料庫欄位需涵蓋以下屬性:

  • Title (title 類型):名稱設為 Title

  • Domain (select 類型):名稱設為 Domain

  • Score (number 類型):名稱設為 Score

  • Entry ID (rich_text 類型):名稱設為 Entry ID

  • Source URL (url 類型):名稱設為 Source URL

  • Date (date 類型):名稱設為 Date

  • 複製 Database 頁面網址,從網址中擷取 Database ID(格式為 32 碼英數組合,位於 Workspace 名稱之後、問號 ? 之前)。

  1. 將 Integration 連線至 Database:
  • 打開該 Notion Database 頁面,點擊右上角 ...。
  • 點選 Connections ➔ 搜尋並加入剛剛建立的 Intelligence-Radar-Sync(未加入連線會收到 404 Object not found 錯誤)。

三、適配器實作:NotionSyncAdapter

Notion API 的資料模型分為兩層:

  1. Properties(屬性層):定義在資料表欄位中的結構化資料(標題、評分、分類標籤、URL)。
  2. Children Blocks(內容區塊層):定義在該頁面內的富文本排版(Callout、標題、引言、項目符號)。

我們使用官方維護的 Python 客戶端 notion-client 進行封裝:

1. 安裝依賴

在 requirements.txt 與 pyproject.toml 中加入:

notion-client>=2.2.1

2. 實作完整轉接器

在 src/adapters/notion_adapter.py 中實現:

# src/adapters/notion_adapter.py
from __future__ import annotations

import logging
from typing import Any
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.client = Client(auth=auth_token)
        self.database_id = database_id

    def _find_page_by_entry_id(self, entry_id: str) -> str | None:
        """依據 Entry ID 查詢是否已存在該筆情資(確保冪等性)"""
        try:
            response = self.client.databases.query(
                database_id=self.database_id,
                filter={
                    "property": "Entry ID",
                    "rich_text": {
                        "equals": str(entry_id)
                    }
                }
            )
            results = response.get("results", [])
            return results[0]["id"] if results else None
        except APIResponseError as e:
            logger.error(f"查詢 Notion 資料庫失敗: {e}")
            return None

    def _build_properties(self, item: IntelligenceItem) -> dict[str, Any]:
        """將領域模型映射為 Notion Database Properties"""
        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


四、實戰同步腳本與 CLI 整合

在 scripts/sync_to_notion.py 中建立可獨立運行的同步工具:

# scripts/sync_to_notion.py
from __future__ import annotations

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

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()

終端機執行與驗證

設定好環境變數後執行:

export NOTION_API_TOKEN="ntn_xxxxxx..."
export NOTION_DATABASE_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
python -m scripts.sync_to_notion

終端機將輸出:

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

打開 Notion Database,每筆高分情報已自動轉為具備屬性欄位、評分星星與 Rich Text Callout 的精美卡片。


五、Notion 整合實戰避坑指南

在整合 Notion REST API 時,有三個常見的陷阱需要防範:

  1. Rich Text 2,000 字元長度限制:
  • Notion API 限制單個 text.content 區塊字串長度不可超過 2,000 字元。
  • 若長篇論文的 raw_content 或詳細摘要超出限制,API 會直接噴出 validation_error。生產環境中需將超長文字切片為多個 Rich Text 物件。
  1. Select 屬性選項動態建立:
  • 當傳入不存在的 Domain 選項(例如首次出現的 ROBOTICS),Notion API 會自動新增該選項,但呼叫帳號必須具備該資料庫的編輯權限。
  1. API Rate Limiting 頻率控制:
  • Notion 限制平均每秒 3 次請求(Requests per second)。
  • 當大量匯入情資時,應在每次請求之間加入輕微間隔(如 time.sleep(0.35)),避免觸發 HTTP 429 錯誤。

總結與下一步

今天我們成功擴展了情報雷達的沉澱層:

  • 依循六角架構,在不修改任何既有核心管線與領域模型的前提下,無痛接入了 NotionSyncAdapter。
  • 建立了從 Google Sheets PROCESSED 到 Notion 結構化關聯資料庫的映射邏輯。

目前系統的前哨採集、核心研判、監控通知與知識庫沉澱(Obsidian / Notion)已全數就緒。然而在自動巡航中,四大代理人(Reviewer、Domain Expert、Synthesizer、Editor)在後台思考的推理細節仍處於「黑盒子」狀態。

在 Day 25,我們將進入可觀測性章節:「可觀測性黑盒子:設計 Agent Trace 日誌與結構化思考過程追蹤」,透過結構化追蹤揭露代理人完整的推理鏈與評分依據!


上一篇
Day 23:全自動化雲端閉環:GitHub Actions 透過 SSH Deploy Key 自動 Push 至私有 Obsidian Vault
系列文
打造專屬 AI 參謀群:以 Gemini Spark、Workspace 與 ADK 構建自動化決策雷達 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言