🌊

LangGraphでNotion Botを完全自動化する方法

に公開

はじめに

ルミナイR&Dチームの宮脇彰梧です。
現在はマルチモーダルAIの研究を行う大学院生として、
生成AIやAIエージェントの技術を実践的に探求しています。

本記事では、

LangGraph × OpenRouter(gpt-oss:20b) × Notion APIによる「自動分類・DB移動 Notion Bot」

を構築します。

特に今回は、実用レベルで便利な

  • Notion DB のページ一覧を CLI で表示
  • 番号で選ぶだけで処理開始
  • 分類 → 自動 DB 移動 がワンステップ

という「実務向け UI」を備えている点が特徴です。

概要

LangGraph の状態管理 × OpenRouter LLM を活用し、
Notion ページを分類 → DB に自動移動するBotを構築する。
CLIでページ選択後、分類と整理が完全自動で進む。

1. なぜこのテーマを選んだのか

Notion は強力ですが、「情報整理」を人間がやる必要があります。

  • 会議メモ
  • TODO
  • 研究ノート
  • アイデア
  • 日報

これらが“未分類DB”に溜まり、
あとで分類するのが面倒…という問題は多くの現場で起きています。

そこで今回のテーマは:

Notion に書く → Bot が分類 → 自動で適切なDBへ移動
という「整理不要のワークフロー」を作る。

LangGraph はこの用途に最適で、

  • Node:LLM分類 / Notion更新
  • State:page_id, content, category
  • Graph構造:分類 → 更新
  • エラー時の再実行も容易

というメリットがあります。

2. 関連調査

LangGraph が効く理由

概念 内容
State Bot の内部記憶(page_id / content / category)
Node LLMまたはAPIを呼ぶ処理単位
Edge 「分類 → 更新」のような順序定義
Pregelモデル エラーに強く、ステップ実行が明確

今回のような「処理フローがはっきりしているタスク」では強力です。

OpenRouter × gpt-oss:20b を採用した理由

  • 高速 & 安価
  • テキスト分類には20Bで十分
  • Function Calling不要
  • LangChain 互換で LangGraph に統合しやすい

今回のタスク(分類)には 最適なモデル選択です。

Notion API の役割

  • DB内のページ一覧取得
  • ページ本文の抽出
  • ページの親DBを切り替える(実質的に移動)
  • タイトルなどプロパティ更新

Bot が Notion 上で“手作業”を代行する仕組みです。

3. 実装(やってみた)

今回実装した構成:

CLI で Notion DB のページを選択
→ ページ本文を API で抽出
→ LLM がカテゴリ分類
→ Notion API で DB 移動

3.1 実装した完全版コード

(説明が長いので、記事本編では GitHub へ掲載する想定)

👇 このコードが Bot の全機能を実装した最新版です:

import os
from typing import Optional, TypedDict, Dict, List

from dotenv import load_dotenv
from fastapi import FastAPI
from pydantic import BaseModel
import requests

from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph

# =========================
# 0. 環境変数ロード
# =========================

load_dotenv()

OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
NOTION_TOKEN = os.getenv("NOTION_TOKEN")

if not OPENROUTER_API_KEY:
    raise RuntimeError("OPENROUTER_API_KEY が .env に設定されていません。")
if not NOTION_TOKEN:
    raise RuntimeError("NOTION_TOKEN が .env に設定されていません。")

# Notion DB ID(.env)
NOTION_DB_MEETING = os.getenv("NOTION_DB_MEETING")
NOTION_DB_WORK = os.getenv("NOTION_DB_WORK")

# =========================
# 1. LangGraph State 定義
# =========================

class BotState(TypedDict):
    content: Optional[str]
    category: Optional[str]
    page_id: Optional[str]
    result_message: Optional[str]

# =========================
# 2. OpenRouter (gpt-oss:20b) 設定
# =========================

llm = ChatOpenAI(
    model="openai/gpt-oss-20b:free",
    api_key=OPENROUTER_API_KEY,
    base_url="https://openrouter.ai/api/v1",
    temperature=0.7,
)

# =========================
# 3. Notion API Utility 関数
# =========================

def fetch_page_list(database_id: str) -> List[Dict]:
    """特定DB内のページ一覧を取得して返す"""
    url = f"https://api.notion.com/v1/databases/{database_id}/query"
    headers = {
        "Authorization": f"Bearer {NOTION_TOKEN}",
        "Notion-Version": "2022-06-28",
        "Content-Type": "application/json",
    }
    resp = requests.post(url, json={}, headers=headers)
    results = resp.json().get("results", [])

    pages = []
    for page in results:
        title_prop = page["properties"].get("Name", {}).get("title", [])
        title = title_prop[0]["plain_text"] if title_prop else "(無題)"
        pages.append({
            "title": title,
            "page_id": page["id"],
        })
    return pages


def fetch_page_content(page_id: str) -> str:
    """ページ本文を抽出してテキスト化"""
    url = f"https://api.notion.com/v1/blocks/{page_id}/children"
    headers = {
        "Authorization": f"Bearer {NOTION_TOKEN}",
        "Notion-Version": "2022-06-28",
    }
    resp = requests.get(url, headers=headers)
    results = resp.json().get("results", [])

    texts = []
    for blk in results:
        if blk["type"] == "paragraph":
            for t in blk["paragraph"]["rich_text"]:
                texts.append(t.get("plain_text", ""))

    return "\n".join(texts)


# =========================
# 4. CLI:ページ選択機能
# =========================

def select_page_interactively(database_id: str) -> Dict:
    """DB内ページ一覧を CLI で選択 → page_id + content を返す"""
    print("\n=== Notion ページ一覧 ===")
    pages = fetch_page_list(database_id)

    if not pages:
        print("⚠️ ページがありません")
        return None

    for i, p in enumerate(pages):
        print(f"{i}: {p['title']}")

    idx = int(input("\n処理したいページ番号を入力してください: "))
    selected = pages[idx]

    page_id = selected["page_id"]
    content = fetch_page_content(page_id)

    print(f"\n▶ 選択ページ: {selected['title']} ({page_id})")
    print(f"内容:\n{content}")

    return {"page_id": page_id, "content": content}


# =========================
# 5. LangGraph ノード定義
# =========================

def classify_node(state: BotState) -> Dict:
    content = state.get("content", "")

    prompt = f"""
あなたは日本語テキストの分類器です。
次のメモを、以下のカテゴリのいずれか1つに分類してください。

候補カテゴリ:
- 会議メモ
- 仕事

出力はカテゴリ名のみ。


メモ本文:
{content}

"""
    res = llm.invoke(prompt)
    category = res.content.strip()

    allowed = ["会議メモ", "仕事"]
    if category not in allowed:
        category = "アイデア"

    print(f"[classify_node] → {category}")
    return {"category": category}


def update_notion_node(state: BotState) -> Dict:
    page_id = state.get("page_id")
    category = state.get("category", "アイデア")

    database_map = {
        "会議メモ": NOTION_DB_MEETING,
        "仕事": NOTION_DB_WORK
    }

    db_id = database_map.get(category)

    payload = {
        "parent": {"database_id": db_id},
        "properties": {
            "Name": {"title": [{"text": {"content": f"{category}|Auto-Sorted"}}]}
        }
    }

    headers = {
        "Authorization": f"Bearer {NOTION_TOKEN}",
        "Notion-Version": "2022-06-28",
        "Content-Type": "application/json",
    }

    resp = requests.patch(
        f"https://api.notion.com/v1/pages/{page_id}",
        json=payload,
        headers=headers,
    )

    if not resp.ok:
        raise RuntimeError(f"Notion API Error: {resp.text}")

    msg = f"Notion page {page_id} updated to {category}"
    print(f"[update_notion_node] {msg}")
    return {"result_message": msg}


# =========================
# 6. LangGraph グラフ構築
# =========================

workflow = StateGraph(BotState)
workflow.add_node("classify", classify_node)
workflow.add_node("update_notion", update_notion_node)
workflow.add_edge("classify", "update_notion")
workflow.set_entry_point("classify")
agent = workflow.compile()


# =========================
# 7. FastAPI Webhook
# =========================

app = FastAPI(title="LangGraph Notion Bot")

class WebhookPayload(BaseModel):
    page_id: str
    content: str

@app.post("/webhook")
def handle_webhook(payload: WebhookPayload):
    initial_state: BotState = {
        "page_id": payload.page_id,
        "content": payload.content,
        "category": None,
        "result_message": None,
    }
    result = agent.invoke(initial_state)
    return {"status": "ok", "result": result}


# =========================
# 8. CLI 実行
# =========================

def debug_run():

    print("\n=== DB を選択 ===")
    print("0: 会議メモDB")
    print("1: 仕事DB")

    choice = int(input("番号を入力: "))

    DB_MAP = {
        0: NOTION_DB_MEETING,
        1: NOTION_DB_WORK,
    }

    database_id = DB_MAP.get(choice)

    selected = select_page_interactively(database_id)

    # 🆕 ここが重要!!
    if not selected:
        print("❌ 選択されたDBにページがないため、処理を終了します。")
        return

    initial_state: BotState = {
        "page_id": selected["page_id"],
        "content": selected["content"],
        "category": None,
        "result_message": None,
    }

    print("\n=== LangGraph 実行 ===")
    result_state = agent.invoke(initial_state)

    print("\n=== 結果 ===")
    for k, v in result_state.items():
        print(f"{k}: {v}")



if __name__ == "__main__":
    debug_run()

4. 動かしてみた(実行ログ付き)

ここでは、実際にあなたの環境で動作した
“リアルなログ” をそのまま載せて解説します。

4.1 DB を選択

=== DB を選択 ===
0: 会議メモDB
1: 仕事DB
番号を入力: 1

「仕事DB」 内のページを対象とします。

4.2 ページ一覧の表示

=== Notion ページ一覧 ===
0: 出張
1: コード編集

Notion API から DB 内のページ名が一覧で表示されました。

今回は「出張」を選択。

4.3 ページ内容の取得

▶ 選択ページ: 出張 (...)
内容:
来週、カナダに出張.

fetch_page_content() により paragraph ブロックを抽出し、
テキストとして整形されていることがわかります。

4.4 LangGraph の分類処理

=== LangGraph 実行 ===
[classify_node] → 仕事

LLM が文章を読んで判断:

「来週、カナダに出張 → 仕事カテゴリ」

非常に自然な分類結果です。

4.5 Notion ページの自動移動

[update_notion_node] Notion page ... updated to 仕事

Notion API による DB 移動も成功。

タイトルも自動的に:

仕事|Auto-Sorted

という形式に変更されます。

4.6 最終 State の確認

=== 結果 ===
content: 来週、カナダに出張.
category: 仕事
page_id: <略>
result_message: Notion page ... updated to 仕事

Bot の処理のすべてが明示的に記録されています。

5. 考察

今回の実装は、LangGraph の “状態管理 × ノード指向設計” が最も活きる例です。

特に効果的だった点:

  • ページ選択UI(CLI)
    → 開発者が即テストできる
    → 外部サービス不要

  • LLM分類のシンプルさ
    → gpt-oss-20b で十分な精度
    → コストを抑えて実用化しやすい

  • Notion API の DB移動操作が自動化できる点
    → Notion 内の情報整理が「書けば終わり」になる

また、分類対象の文章が自然言語であれば
高確率で正しいカテゴリを選択してくれる。

今回の出張メモも、モデルは正しく “仕事” と判断した。

6. まとめ

  • LangGraph により「分類 → 更新」が堅牢に自動化できる
  • OpenRouter の 20B モデルがコスパよく実用的
  • Notion API の組み合わせで業務整理の大部分が自動化可能
  • CLI 選択方式が開発・個人利用に最適
  • 実行ログから確認できる通り、分類精度も実用レベル

https://github.com/LoNebula/Lluminai/tree/main/5_2025_11_21_langgraph

執筆:宮脇 彰梧(ルミナイ株式会社 / Lluminai)


【現在採用強化中です!】

  • AIエンジニア
  • PM/PdM
  • 戦略投資コンサルタント

▼代表とのカジュアル面談URL
https://pitta.me/matches/VCmKMuMvfBEk

ルミナイ - 産業データをLLM Readyにするための技術ブログ

Discussion