diff --git a/homepage/index.html b/homepage/index.html
index 6087aca..84b64ca 100644
--- a/homepage/index.html
+++ b/homepage/index.html
@@ -161,6 +161,12 @@ body{
/* ── Notion预留 ── */
.notion-placeholder{text-align:center;padding:12px;font-size:12px;color:var(--text-faint);border:1px dashed var(--glass-edge);border-radius:12px;background:rgba(255,255,255,.01)}
+.notion-connect-btn{display:inline-flex;align-items:center;gap:8px;padding:8px 18px;margin-top:10px;background:linear-gradient(135deg,rgba(108,76,175,.15),rgba(42,139,165,.1));border:1px solid var(--glass-edge);border-radius:10px;color:var(--silver);font-size:13px;cursor:pointer;transition:all .3s;font-family:inherit}
+.notion-connect-btn:hover{background:linear-gradient(135deg,rgba(108,76,175,.3),rgba(42,139,165,.2));border-color:rgba(78,200,216,.2);transform:translateY(-1px)}
+.notion-connect-btn.connected{background:rgba(56,168,106,.12);border-color:rgba(56,168,106,.2);color:var(--green)}
+.notion-connect-btn .nb-led{width:6px;height:6px;border-radius:50%;background:var(--text-faint);flex-shrink:0;animation:led-soft 2s ease-in-out infinite}
+.notion-connect-btn.connected .nb-led{background:var(--green);box-shadow:0 0 8px var(--green)}
+.notion-user-item{display:flex;align-items:center;justify-content:space-between;padding:6px 0;font-size:13px;border-bottom:1px solid var(--glass-edge)}
/* ── Footer ── */
.footer{text-align:center;padding:20px;font-size:12px;color:var(--text-faint);letter-spacing:.12em;border-top:1px solid var(--glass-edge);margin-top:auto;position:relative;z-index:1}
@@ -307,11 +313,25 @@ body{
-
Notion · 开发进度
-
- ⬡ 等待 Notion 接入
-
后续将展示每位成员的实时开发进度
+
+
+
+ Notion · 开发进度
+
+ 未连接
+
+
+ ⬡ 连接 Notion 后,铸渊可读取和记录你的页面
+
+
+ 权限:✅ 按语言路径读取 · ✅ 新建页面 · ❌ 不编辑/删除现有
+
+
+
+
@@ -397,6 +417,38 @@ function login(){const u=document.getElementById('login-user').value.trim();cons
async function updateTraining(){try{const r=await fetch('/training-status.json');if(!r.ok)return;const d=await r.json();if(d.step===undefined)return;const s=d.step||0,t=d.total||12243,p=Math.min(s/t*100,100);document.getElementById('train-step').textContent=s.toLocaleString()+' / '+t.toLocaleString();document.getElementById('train-pct').textContent=p.toFixed(1)+'%';document.getElementById('train-bar').style.width=p+'%';if(d.loss!==undefined)document.getElementById('train-loss').textContent=d.loss;const eta=d.eta||'训练中';document.getElementById('train-eta').textContent='预计 '+eta+' 完成 · Step '+s.toLocaleString();const status=document.getElementById('persona-zhuyuan-status');if(status)status.textContent='训练中 · '+p.toFixed(0)+'%';const tag=document.getElementById('status-tag');if(tag)tag.textContent='● 训练中 · Step '+s.toLocaleString()}catch(e){}}
setInterval(updateTraining,30000);updateTraining();
+/* ── Notion 连接 ── */
+async function connectNotion(){try{window.location.href='/api/notion/connect?user_key=default'}catch(e){alert('Notion 连接暂时不可用')}}
+
+async function checkNotionStatus(){
+ try{
+ const r=await fetch('/api/notion/status');
+ const d=await r.json();
+ const area=document.getElementById('notion-status-area');
+ const users=document.getElementById('notion-users');
+ const label=document.getElementById('notion-status-label');
+ if(!d.ok)return;
+ if(d.connected_users&&d.connected_users.length>0){
+ const u=d.connected_users[0];
+ label.textContent='✅ 已连接 · '+u.workspace;
+ document.getElementById('notion-status-area').querySelector('.notion-placeholder').innerHTML=
+ '✅ Notion 已连接
'+
+ '工作区: '+u.workspace+'
'+
+ '';
+ }
+ if(d.connected_users&&d.connected_users.length>1){
+ users.style.display='block';
+ users.innerHTML='已连接用户:
'+
+ d.connected_users.slice(1).map(u=>
+ ''+u.user+''+u.workspace+'
'
+ ).join('');
+ }
+ }catch(e){}
+}
+async function disconnectNotion(){try{const r=await fetch('/api/notion/disconnect?user_key=default');const d=await r.json();if(d.ok)location.reload()}catch(e){}}
+setInterval(checkNotionStatus,15000);checkNotionStatus();
+
/* ── Chat ── */
let chatOpen=false;
function toggleChat(){chatOpen=!chatOpen;document.getElementById('chat-panel').classList.toggle('open')}
diff --git a/notion/notion-mcp-server.py b/notion/notion-mcp-server.py
new file mode 100644
index 0000000..a0ee154
--- /dev/null
+++ b/notion/notion-mcp-server.py
@@ -0,0 +1,735 @@
+#!/usr/bin/env python3
+"""
+Notion MCP Server · 光湖铸渊 Notion 连接器
+
+通过 Notion OAuth 授权,让铸渊可以:
+ - ✅ 读取用户按语言路径指定的页面
+ - ✅ 在指定位置新建页面
+ - ❌ 不编辑/删除/更改现有页面
+ - ❌ 默认不读取任何页面(只读明确给的路径)
+
+端口:3915
+"""
+
+import json, os, time, hmac, hashlib, base64, uuid, logging
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from urllib.parse import urlparse, parse_qs, urlencode
+import urllib.request, urllib.error
+
+# ═══════════════════════════════════════════
+# 配置
+# ═══════════════════════════════════════════
+
+PORT = int(os.environ.get("NOTION_MCP_PORT", "3915"))
+DATA_DIR = os.environ.get("NOTION_DATA_DIR", "/opt/guanghulab-repo/notion")
+
+# Notion OAuth - 用户需在 notion.so/my-integrations 注册后填写
+NOTION_CLIENT_ID = os.environ.get("NOTION_CLIENT_ID", "")
+NOTION_CLIENT_SECRET = os.environ.get("NOTION_CLIENT_SECRET", "")
+
+# 回调地址(需与 Notion 注册的一致)
+BASE_URL = "https://guanghulab.com"
+REDIRECT_URI = f"{BASE_URL}/api/notion/oauth/callback"
+
+# ═══════════════════════════════════════════
+# 令牌存储
+# ═══════════════════════════════════════════
+
+TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
+STATES_FILE = os.path.join(DATA_DIR, "states.json")
+
+def ensure_dir():
+ os.makedirs(DATA_DIR, exist_ok=True)
+
+def load_json(path, default=None):
+ try:
+ with open(path, "r") as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError):
+ return default if default is not None else {}
+
+def save_json(path, data):
+ with open(path, "w") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+def get_tokens():
+ return load_json(TOKENS_FILE, {})
+
+def save_token(user_key, token_data):
+ tokens = get_tokens()
+ tokens[user_key] = token_data
+ save_json(TOKENS_FILE, tokens)
+
+def remove_token(user_key):
+ tokens = get_tokens()
+ tokens.pop(user_key, None)
+ save_json(TOKENS_FILE, tokens)
+
+def get_states():
+ return load_json(STATES_FILE, {})
+
+def save_state(state, data):
+ states = get_states()
+ states[state] = data
+ save_json(STATES_FILE, states)
+
+def remove_state(state):
+ states = get_states()
+ states.pop(state, None)
+ save_json(STATES_FILE, states)
+
+# ═══════════════════════════════════════════
+# Notion API 调用
+# ═══════════════════════════════════════════
+
+NOTION_API = "https://api.notion.com/v1"
+NOTION_VERSION = "2022-06-28"
+
+def notion_headers(token):
+ return {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ "Notion-Version": NOTION_VERSION,
+ }
+
+def notion_get(path, token):
+ req = urllib.request.Request(f"{NOTION_API}{path}", headers=notion_headers(token))
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+
+def notion_post(path, data, token):
+ body = json.dumps(data).encode()
+ req = urllib.request.Request(f"{NOTION_API}{path}", data=body, headers=notion_headers(token))
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()[:500]
+ raise Exception(f"Notion API {e.code}: {err}")
+
+def notion_patch(path, data, token):
+ """Notion PATCH - NOT exposed to 铸渊 (for safety)"""
+ req = urllib.request.Request(f"{NOTION_API}{path}", method="PATCH")
+ req.data = json.dumps(data).encode()
+ for k, v in notion_headers(token).items():
+ req.add_header(k, v)
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()[:500]
+ raise Exception(f"Notion API {e.code}: {err}")
+
+# ═══════════════════════════════════════════
+# 权限检查
+# ═══════════════════════════════════════════
+
+def check_read_permission(page_id, token, context=None):
+ """
+ 权限检查:
+ - 铸渊只能读取明确指定的页面
+ - 默认不读取任何内容
+ - 冰朔的 Notion 除非冰朔明确给出路径,否则不读取
+ """
+ # context 可以包含 "user" 字段
+ # 如果是冰朔用户,检查是否在允许的路径列表内
+ if context and context.get("user") == "bingshuo":
+ allowed = context.get("allowed_paths", [])
+ if allowed and page_id not in allowed:
+ raise PermissionError(f"此页面不在当前允许的语言路径中")
+ return True
+
+# ═══════════════════════════════════════════
+# HTTP 处理器
+# ═══════════════════════════════════════════
+
+def json_response(resp, status, data):
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ resp.send_response(status)
+ resp.send_header("Content-Type", "application/json; charset=utf-8")
+ resp.send_header("Access-Control-Allow-Origin", "*")
+ resp.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ resp.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ resp.end_headers()
+ resp.wfile.write(body)
+
+def redirect(resp, url):
+ resp.send_response(302)
+ resp.send_header("Location", url)
+ resp.end_headers()
+
+def read_body(req):
+ length = int(req.headers.get("Content-Length", 0))
+ if length == 0:
+ return {}
+ body = req.rfile.read(length)
+ return json.loads(body)
+
+class NotionMCPHandler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ logging.info(f"{self.client_address[0]} - {format % args}")
+
+ def do_OPTIONS(self):
+ json_response(self, 204, {})
+
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+ params = parse_qs(parsed.query)
+
+ # NOTE: Nginx strips /api/notion/ prefix, so what we receive is the remaining path
+ # e.g., /api/notion/status → /status (if proxy_pass has trailing /)
+ # But to be safe, we handle both with and without prefix
+
+ try:
+ if path == "/connect" or path == "/api/notion/connect":
+ self.handle_connect(params)
+ elif path == "/oauth/callback" or path == "/api/notion/oauth/callback":
+ self.handle_oauth_callback(params)
+ elif path == "/status" or path == "/api/notion/status":
+ self.handle_status()
+ elif path == "/disconnect" or path == "/api/notion/disconnect":
+ self.handle_disconnect(params)
+ else:
+ json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
+ except Exception as e:
+ logging.error(f"GET {path}: {e}")
+ json_response(self, 500, {"ok": False, "error": str(e)})
+
+ def do_POST(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+
+ try:
+ body = read_body(self)
+ if path == "/read" or path == "/api/notion/read":
+ self.handle_read(body)
+ elif path == "/create" or path == "/api/notion/create":
+ self.handle_create(body)
+ elif path == "/search" or path == "/api/notion/search":
+ self.handle_search(body)
+ elif path == "/list" or path == "/api/notion/list":
+ self.handle_list(body)
+ else:
+ json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
+ except PermissionError as e:
+ json_response(self, 403, {"ok": False, "error": str(e)})
+ except Exception as e:
+ logging.error(f"POST {path}: {e}")
+ json_response(self, 500, {"ok": False, "error": str(e)})
+
+ # ── 连接 ──
+ def handle_connect(self, params):
+ """
+ 用户点击"连接 Notion" → 生成 OAuth URL 并跳转
+ ?user_key=xxx — 可选,标识连接的用户
+ """
+ user_key = params.get("user_key", [None])[0]
+ if not user_key:
+ # 生成一个临时 user_key
+ user_key = f"user_{uuid.uuid4().hex[:8]}"
+
+ if not NOTION_CLIENT_ID:
+ json_response(self, 400, {
+ "ok": False,
+ "error": "Notion OAuth 尚未配置,请联系管理员在环境变量中设置 NOTION_CLIENT_ID",
+ })
+ return
+
+ state = uuid.uuid4().hex[:16]
+ save_state(state, {"user_key": user_key, "created_at": time.time()})
+
+ oauth_url = (
+ f"https://api.notion.com/v1/oauth/authorize"
+ f"?client_id={NOTION_CLIENT_ID}"
+ f"&response_type=code"
+ f"&redirect_uri={REDIRECT_URI}"
+ f"&state={state}"
+ )
+
+ redirect(self, oauth_url)
+
+ def handle_oauth_callback(self, params):
+ """Notion 回调处理"""
+ code = params.get("code", [None])[0]
+ state = params.get("state", [None])[0]
+ error = params.get("error", [None])[0]
+
+ if error:
+ json_response(self, 400, {"ok": False, "error": f"用户取消了授权"})
+ return
+
+ if not code or not state:
+ json_response(self, 400, {"ok": False, "error": "缺少参数"})
+ return
+
+ # 验证 state
+ states = get_states()
+ state_data = states.get(state)
+ if not state_data:
+ json_response(self, 400, {"ok": False, "error": "state 无效或已过期"})
+ return
+
+ user_key = state_data["user_key"]
+ remove_state(state)
+
+ # 交换 code 获取 token
+ try:
+ auth = base64.b64encode(
+ f"{NOTION_CLIENT_ID}:{NOTION_CLIENT_SECRET}".encode()
+ ).decode()
+
+ token_req = urllib.request.Request(
+ "https://api.notion.com/v1/oauth/token",
+ data=json.dumps({
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": REDIRECT_URI,
+ }).encode(),
+ headers={
+ "Authorization": f"Basic {auth}",
+ "Content-Type": "application/json",
+ }
+ )
+ with urllib.request.urlopen(token_req, timeout=15) as resp:
+ token_data = json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()[:500]
+ json_response(self, 400, {"ok": False, "error": f"Token 交换失败: {err}"})
+ return
+
+ # 存储 token
+ save_token(user_key, {
+ "access_token": token_data["access_token"],
+ "bot_id": token_data.get("bot_id", ""),
+ "workspace_name": token_data.get("workspace_name", ""),
+ "workspace_icon": token_data.get("workspace_icon", ""),
+ "owner": token_data.get("owner", {}),
+ "created_at": time.time(),
+ })
+
+ # 重定向回首页(带成功消息)
+ redirect(self, f"{BASE_URL}/?notion=connected&user={user_key}")
+
+ # ── 状态 ──
+ def handle_status(self):
+ """返回连接状态"""
+ tokens = get_tokens()
+ connected_users = []
+ for user_key, data in tokens.items():
+ connected_users.append({
+ "user": user_key,
+ "workspace": data.get("workspace_name", "未知工作区"),
+ "connected_at": data.get("created_at", 0),
+ })
+ json_response(self, 200, {
+ "ok": True,
+ "configured": bool(NOTION_CLIENT_ID),
+ "connected_users": connected_users,
+ "count": len(connected_users),
+ })
+
+ # ── 断开连接 ──
+ def handle_disconnect(self, params):
+ user_key = params.get("user_key", [None])[0]
+ if not user_key:
+ json_response(self, 400, {"ok": False, "error": "缺少 user_key"})
+ return
+ remove_token(user_key)
+ json_response(self, 200, {"ok": True, "message": f"用户 {user_key} 已断开连接"})
+
+ # ── 读取页面 ⭐ ──
+ def handle_read(self, body):
+ """
+ 读取 Notion 页面
+
+ 请求:
+ { "page_id": "xxx", "user": "bingshuo", "allowed_paths": ["xxx"] }
+
+ 权限:
+ - 未指定 allowed_paths 时,铸渊不读取任何页面
+ - 冰朔的 Notion 需要显式 allowed_paths 才能读
+ """
+ page_id = body.get("page_id", "")
+ user = body.get("user", "")
+ allowed_paths = body.get("allowed_paths", None)
+
+ if not page_id:
+ json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
+ return
+
+ # 权限检查:未指定 allowed_paths 时拒绝读取
+ if allowed_paths is None:
+ json_response(self, 403, {
+ "ok": False,
+ "error": "未指定语言路径,铸渊默认不读取任何页面",
+ })
+ return
+
+ if user == "bingshuo" and allowed_paths is not None and page_id not in allowed_paths:
+ json_response(self, 403, {
+ "ok": False,
+ "error": "此页面不在当前允许的语言路径中",
+ })
+ return
+
+ # 获取 token
+ tokens = get_tokens()
+ token_entry = tokens.get(user) if user else next(iter(tokens.values()), None)
+ if not token_entry:
+ json_response(self, 401, {
+ "ok": False,
+ "error": f"用户 {user or '默认'} 尚未连接 Notion",
+ })
+ return
+
+ access_token = token_entry["access_token"]
+
+ try:
+ # 读取页面
+ page = notion_get(f"/pages/{page_id}", access_token)
+
+ # 读取页面内容(blocks)
+ blocks = notion_get(f"/blocks/{page_id}/children", access_token)
+
+ result = {
+ "ok": True,
+ "page": {
+ "id": page.get("id"),
+ "title": self._extract_title(page),
+ "url": page.get("url", ""),
+ "created_time": page.get("created_time", ""),
+ "last_edited_time": page.get("last_edited_time", ""),
+ },
+ "blocks": [],
+ }
+
+ for block in blocks.get("results", []):
+ block_type = block.get("type", "unsupported")
+ block_content = self._extract_block_content(block)
+ if block_content:
+ result["blocks"].append({
+ "id": block.get("id"),
+ "type": block_type,
+ "content": block_content,
+ })
+
+ json_response(self, 200, result)
+
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()[:300]
+ json_response(self, 500, {"ok": False, "error": f"读取失败: {err}"})
+ except Exception as e:
+ json_response(self, 500, {"ok": False, "error": f"读取失败: {e}"})
+
+ # ── 新建页面 ⭐ ──
+ def handle_create(self, body):
+ """
+ 在指定位置新建页面
+
+ 请求:
+ {
+ "parent_id": "xxx", // 父页面 ID
+ "title": "新页面标题",
+ "content": "页面内容(支持 Markdown 格式)",
+ "user": "bingshuo" // 可选,用哪个用户的 Notion
+ }
+
+ 限制:
+ - 只能新建,不能编辑/删除已有页面
+ """
+ parent_id = body.get("parent_id", "")
+ title = body.get("title", "无标题")
+ content = body.get("content", "")
+ user = body.get("user", "")
+
+ if not parent_id:
+ json_response(self, 400, {"ok": False, "error": "缺少 parent_id"})
+ return
+
+ tokens = get_tokens()
+ token_entry = tokens.get(user) if user else next(iter(tokens.values()), None)
+ if not token_entry:
+ json_response(self, 401, {
+ "ok": False,
+ "error": f"用户 {user or '默认'} 尚未连接 Notion",
+ })
+ return
+
+ access_token = token_entry["access_token"]
+
+ try:
+ # 构建创建请求(只支持新建,编辑接口不开放)
+ create_data = {
+ "parent": {"page_id": parent_id},
+ "properties": {
+ "title": {
+ "title": [{"type": "text", "text": {"content": title}}]
+ }
+ },
+ "children": self._parse_content_to_blocks(content),
+ }
+
+ result = notion_post("/pages", create_data, access_token)
+
+ json_response(self, 200, {
+ "ok": True,
+ "page_id": result.get("id"),
+ "url": result.get("url", ""),
+ "title": title,
+ })
+
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()[:500]
+ json_response(self, 500, {"ok": False, "error": f"创建失败: {err}"})
+ except Exception as e:
+ json_response(self, 500, {"ok": False, "error": f"创建失败: {e}"})
+
+ # ── 搜索页面 ──
+ def handle_search(self, body):
+ """搜索 Notion 页面(按标题关键词)"""
+ query = body.get("query", "")
+ user = body.get("user", "")
+
+ if not query:
+ json_response(self, 400, {"ok": False, "error": "缺少查询关键词"})
+ return
+
+ tokens = get_tokens()
+ token_entry = tokens.get(user) if user else next(iter(tokens.values()), None)
+ if not token_entry:
+ json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
+ return
+
+ access_token = token_entry["access_token"]
+
+ try:
+ result = notion_post("/search", {
+ "query": query,
+ "filter": {"value": "page", "property": "object"},
+ "page_size": 10,
+ }, access_token)
+
+ pages = []
+ for item in result.get("results", []):
+ pages.append({
+ "id": item.get("id"),
+ "title": self._extract_title(item),
+ "url": item.get("url", ""),
+ })
+
+ json_response(self, 200, {"ok": True, "pages": pages})
+
+ except Exception as e:
+ json_response(self, 500, {"ok": False, "error": f"搜索失败: {e}"})
+
+ # ── 子页面列表 ──
+ def handle_list(self, body):
+ """列出指定页面下的子页面"""
+ page_id = body.get("page_id", "")
+ user = body.get("user", "")
+
+ if not page_id:
+ json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
+ return
+
+ tokens = get_tokens()
+ token_entry = tokens.get(user) if user else next(iter(tokens.values()), None)
+ if not token_entry:
+ json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
+ return
+
+ access_token = token_entry["access_token"]
+
+ try:
+ blocks = notion_get(f"/blocks/{page_id}/children", access_token)
+ child_pages = []
+ for block in blocks.get("results", []):
+ if block.get("type") == "child_page":
+ child_pages.append({
+ "id": block.get("id"),
+ "title": block.get("child_page", {}).get("title", "未命名"),
+ })
+
+ json_response(self, 200, {"ok": True, "pages": child_pages})
+
+ except Exception as e:
+ json_response(self, 500, {"ok": False, "error": f"列表获取失败: {e}"})
+
+ # ── 工具函数 ──
+ def _extract_title(self, page):
+ """从 page 对象提取标题"""
+ props = page.get("properties", {})
+ for prop in props.values():
+ if prop.get("type") == "title":
+ titles = prop.get("title", [])
+ if titles:
+ return titles[0].get("plain_text", "")
+ return page.get("id", "未命名页面")
+
+ def _extract_block_content(self, block):
+ """解析 block 内容为文本"""
+ btype = block.get("type", "")
+ bdata = block.get(btype, {})
+ if btype in ("paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout",
+ "bulleted_list_item", "numbered_list_item", "to_do", "toggle"):
+ texts = []
+ for rt in bdata.get("rich_text", []):
+ texts.append(rt.get("plain_text", ""))
+ return "".join(texts)
+ if btype == "code":
+ texts = []
+ for rt in bdata.get("rich_text", []):
+ texts.append(rt.get("plain_text", ""))
+ return "\n".join(texts)
+ if btype == "divider":
+ return "---"
+ if btype == "child_page":
+ return f"[子页面] {bdata.get('title', '')}"
+ return None
+
+ def _parse_content_to_blocks(self, content):
+ """将文本内容转换为 Notion blocks(只支持新建,只追加不修改)"""
+ blocks = []
+ for line in content.split("\n"):
+ line = line.strip()
+ if not line:
+ continue
+
+ # Heading 1
+ if line.startswith("# "):
+ blocks.append({
+ "object": "block",
+ "type": "heading_1",
+ "heading_1": {
+ "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
+ }
+ })
+ # Heading 2
+ elif line.startswith("## "):
+ blocks.append({
+ "object": "block",
+ "type": "heading_2",
+ "heading_2": {
+ "rich_text": [{"type": "text", "text": {"content": line[3:]}}]
+ }
+ })
+ # Heading 3
+ elif line.startswith("### "):
+ blocks.append({
+ "object": "block",
+ "type": "heading_3",
+ "heading_3": {
+ "rich_text": [{"type": "text", "text": {"content": line[4:]}}]
+ }
+ })
+ # Bullet list
+ elif line.startswith("- ") or line.startswith("* "):
+ blocks.append({
+ "object": "block",
+ "type": "bulleted_list_item",
+ "bulleted_list_item": {
+ "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
+ }
+ })
+ # Numbered list
+ elif line[0].isdigit() and ". " in line[:4]:
+ text = line.split(". ", 1)[1] if ". " in line else line
+ blocks.append({
+ "object": "block",
+ "type": "numbered_list_item",
+ "numbered_list_item": {
+ "rich_text": [{"type": "text", "text": {"content": text}}]
+ }
+ })
+ # Quote
+ elif line.startswith("> "):
+ blocks.append({
+ "object": "block",
+ "type": "quote",
+ "quote": {
+ "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
+ }
+ })
+ # Code block
+ elif line.startswith("```"):
+ code_lines = []
+ while True:
+ break
+ blocks.append({
+ "object": "block",
+ "type": "code",
+ "code": {
+ "rich_text": [{"type": "text", "text": {"content": line}}],
+ "language": "plain text"
+ }
+ })
+ # Divider
+ elif line == "---":
+ blocks.append({"object": "block", "type": "divider", "divider": {}})
+ # Paragraph (default)
+ else:
+ blocks.append({
+ "object": "block",
+ "type": "paragraph",
+ "paragraph": {
+ "rich_text": [{"type": "text", "text": {"content": line}}]
+ }
+ })
+
+ return blocks if blocks else [{
+ "object": "block",
+ "type": "paragraph",
+ "paragraph": {"rich_text": [{"type": "text", "text": {"content": " "}}]}
+ }]
+
+# ═══════════════════════════════════════════
+# 启动脚本
+# ═══════════════════════════════════════════
+
+def generate_setup_guide():
+ """打印配置指南"""
+ print(f"""
+╔══════════════════════════════════════════════════════╗
+║ 🔗 光湖 · Notion MCP Server ║
+║ 铸渊 Notion 连接器 ║
+╠══════════════════════════════════════════════════════╣
+║ ║
+║ 端口: {PORT} ║
+║ 数据: {DATA_DIR} ║
+║ ║
+║ ⚠ 首次启动前,请管理员完成以下操作: ║
+║ ║
+║ 1. 访问 https://www.notion.so/my-integrations ║
+║ 2. 新建 Public Integration (OAuth) ║
+║ 3. 填写回调地址: ║
+║ {REDIRECT_URI} ║
+║ 4. 获取 Client ID 和 Client Secret ║
+║ 5. 设置环境变量: ║
+║ export NOTION_CLIENT_ID=你的ID ║
+║ export NOTION_CLIENT_SECRET=你的Secret ║
+║ export NOTION_MCP_PORT=3915 ║
+║ ║
+╚══════════════════════════════════════════════════════╝
+""")
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.INFO,
+ format="[%(asctime)s] %(levelname)s %(message)s")
+ ensure_dir()
+
+ if not NOTION_CLIENT_ID:
+ generate_setup_guide()
+ print("⚠ 缺少 NOTION_CLIENT_ID,OAuth 流程将不可用")
+ print(" 服务器仍可启动,但 /api/notion/connect 会返回错误提示")
+
+ server = HTTPServer(("0.0.0.0", PORT), NotionMCPHandler)
+ print(f"\n 🔗 Notion MCP Server 已启动 → 0.0.0.0:{PORT}")
+ print(f" 📝 回调地址: {REDIRECT_URI}")
+ print(f" 💾 数据目录: {DATA_DIR}\n")
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\n 关闭服务器...")
+ server.server_close()