#!/usr/bin/env python3 """ notion-bridge.py · 冰朔本地 Notion 直连工具 铸渊 ICE-GL-ZY001 开发 · D164+ · 解决"换房间断手"问题 之前 WorkBuddy 里的铸渊通过某种方式连 Notion(应该有连接器)。 今天 MiniMax Code 房间的铸渊——用铸渊自己的工具重写。 用法: # 1. 设置凭证 export NOTION_API_KEY="ntn_xxx" # 2. 列出 workspace 内的对象 python3 notion-bridge.py search # 3. 列出 workspace 内的数据库 python3 notion-bridge.py databases # 4. 读取某个数据库的全部行 python3 notion-bridge.py query # 5. 读取某个页面 python3 notion-bridge.py page # 6. 创建/更新页面 python3 notion-bridge.py create-page python3 notion-bridge.py update-page <page_id> <properties_json> # 7. 导出页面为 markdown python3 notion-bridge.py export <page_id> 前置: 1. 冰朔生成 Notion integration: https://www.notion.so/my-integrations 2. token 形如 ntn_xxx (或 secret_xxx) 3. 在 Notion 工作区里把页面"分享"给 integration """ import os import sys import json from urllib.parse import urlencode, quote from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError # ============ 配置 ============ NOTION_API_KEY = os.environ.get("NOTION_API_KEY", "").strip() NOTION_VERSION = os.environ.get("NOTION_VERSION", "2022-06-28") NOTION_BASE = "https://api.notion.com/v1" # ============ API 调用 ============ def api(method, path, payload=None): """通用 Notion API 请求""" if not NOTION_API_KEY: raise RuntimeError( "缺少 NOTION_API_KEY 环境变量。\n" "冰朔请到 https://www.notion.so/my-integrations 生成 integration token\n" "然后: export NOTION_API_KEY=ntn_xxx" ) url = f"{NOTION_BASE}{path}" headers = { "Authorization": f"Bearer {NOTION_API_KEY}", "Notion-Version": NOTION_VERSION, "Content-Type": "application/json", } data = json.dumps(payload).encode() if payload else None req = Request(url, data=data, headers=headers, method=method) try: with urlopen(req, timeout=30) as resp: body = resp.read().decode() return json.loads(body) if body else {} except HTTPError as e: body = e.read().decode() if e.fp else "" try: err = json.loads(body) except Exception: err = {"message": body[:500]} raise RuntimeError( f"Notion API {method} {path} 失败 [{e.code}]\n" f" message: {err.get('message', '?')}\n" f" code: {err.get('code', '?')}" ) except URLError as e: raise RuntimeError(f"网络失败: {e.reason}") # ============ 命令 ============ def cmd_search(args): """搜索页面/数据库""" query = args[0] if args else None payload = { "page_size": 50, "sort": {"direction": "descending", "timestamp": "last_edited_time"}, } if query: payload["query"] = query result = api("POST", "/search", payload) results = result.get("results", []) print(f"找到 {len(results)} 个对象\n") for r in results: title = "?" obj_type = r.get("object", "?") if obj_type == "database": title_arr = r.get("title", []) if title_arr: title = title_arr[0].get("plain_text", "?") elif obj_type == "page": for prop_name, prop in r.get("properties", {}).items(): if prop.get("type") == "title": title_arr = prop.get("title", []) if title_arr: title = title_arr[0].get("plain_text", "?") break last_edited = r.get("last_edited_time", "?")[:10] icon = r.get("icon", {}) icon_str = icon.get("emoji", "") if icon else "" rid = r.get("id", "?") print(f" [{obj_type}] {icon_str} {title}") print(f" id: {rid} ({last_edited})") print() def cmd_databases(args): """列出所有数据库""" payload = { "page_size": 100, "filter": {"property": "object", "value": "database"}, "sort": {"direction": "descending", "timestamp": "last_edited_time"}, } result = api("POST", "/search", payload) results = result.get("results", []) print(f"找到 {len(results)} 个数据库\n") for r in results: title_arr = r.get("title", []) title = title_arr[0].get("plain_text", "?") if title_arr else "?" rid = r.get("id", "?") print(f" {title}") print(f" id: {rid}") print() def cmd_query(args): """查询数据库""" if not args: print("用法: python3 notion-bridge.py query <database_id>") sys.exit(1) db_id = args[0] payload = {"page_size": 50} result = api("POST", f"/databases/{db_id}/query", payload) results = result.get("results", []) print(f"数据库 {db_id} 有 {len(results)} 行\n") for r in results[:20]: title = "?" for prop_name, prop in r.get("properties", {}).items(): if prop.get("type") == "title": title_arr = prop.get("title", []) if title_arr: title = title_arr[0].get("plain_text", "?") break rid = r.get("id", "?") last_edited = r.get("last_edited_time", "?")[:10] print(f" - {title} ({last_edited})") print(f" id: {rid}") def cmd_page(args): """读取页面""" if not args: print("用法: python3 notion-bridge.py page <page_id>") sys.exit(1) page_id = args[0] result = api("GET", f"/pages/{page_id}") print(json.dumps(result, ensure_ascii=False, indent=2)) def cmd_export(args): """导出页面为 markdown""" if not args: print("用法: python3 notion-bridge.py export <page_id>") sys.exit(1) page_id = args[0] # 读取页面元数据 page = api("GET", f"/pages/{page_id}") title = "Untitled" for prop_name, prop in page.get("properties", {}).items(): if prop.get("type") == "title": title_arr = prop.get("title", []) if title_arr: title = title_arr[0].get("plain_text", "Untitled") break # 读取 blocks blocks = [] cursor = None while True: payload = {"page_size": 100} if cursor: payload["start_cursor"] = cursor result = api("POST", f"/blocks/{page_id}/children", payload) blocks.extend(result.get("results", [])) if not result.get("has_more"): break cursor = result.get("next_cursor") md = f"# {title}\n\n" for block in blocks: md += block_to_markdown(block) print(md) def block_to_markdown(block): """单个 block 转 markdown""" btype = block.get("type", "") content = "" if btype == "paragraph": rich_text = block.get("paragraph", {}).get("rich_text", []) content = rich_text_to_markdown(rich_text) + "\n\n" elif btype == "heading_1": content = "# " + rich_text_to_markdown(block.get("heading_1", {}).get("rich_text", [])) + "\n\n" elif btype == "heading_2": content = "## " + rich_text_to_markdown(block.get("heading_2", {}).get("rich_text", [])) + "\n\n" elif btype == "heading_3": content = "### " + rich_text_to_markdown(block.get("heading_3", {}).get("rich_text", [])) + "\n\n" elif btype == "bulleted_list_item": content = "- " + rich_text_to_markdown(block.get("bulleted_list_item", {}).get("rich_text", [])) + "\n" elif btype == "numbered_list_item": content = "1. " + rich_text_to_markdown(block.get("numbered_list_item", {}).get("rich_text", [])) + "\n" elif btype == "code": lang = block.get("code", {}).get("language", "") content = f"```{lang}\n" + rich_text_to_markdown(block.get("code", {}).get("rich_text", [])) + "\n```\n\n" elif btype == "quote": content = "> " + rich_text_to_markdown(block.get("quote", {}).get("rich_text", [])) + "\n\n" elif btype == "to_do": checked = block.get("to_do", {}).get("checked", False) mark = "[x]" if checked else "[ ]" content = f"- {mark} " + rich_text_to_markdown(block.get("to_do", {}).get("rich_text", [])) + "\n" return content def rich_text_to_markdown(rich_text): """rich_text array 转 markdown""" parts = [] for item in rich_text: text = item.get("plain_text", "") if item.get("href"): text = f"[{text}]({item['href']})" parts.append(text) return "".join(parts) def cmd_create_page(args): """创建页面(简化版)""" if len(args) < 2: print("用法: python3 notion-bridge.py create-page <parent_id> <title>") sys.exit(1) parent_id = args[0] title = args[1] payload = { "parent": {"page_id": parent_id}, "properties": { "title": [{"type": "text", "text": {"content": title}}] } } result = api("POST", "/pages", payload) print(f"✓ 创建页面: {result.get('url', '?')}") def cmd_users(args): """列出 workspace 用户""" result = api("GET", "/users") for u in result.get("results", []): print(f" [{u.get('type')}] {u.get('name', '?')} ({u.get('id', '?')})") # ============ Main ============ def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) cmd = sys.argv[1] args = sys.argv[2:] if not NOTION_API_KEY: print("⚠ 缺少 NOTION_API_KEY 环境变量") print() print(" 冰朔请按以下步骤:") print(" 1. 打开 https://www.notion.so/my-integrations") print(" 2. 创建新 integration: 名字 = bridge-zhuyuan") print(" 3. 复制 token (形如 ntn_xxx)") print(" 4. 在 Notion 工作区把页面右上角'分享'给 integration") print(" 5. 执行: export NOTION_API_KEY=ntn_xxx") sys.exit(1) cmds = { "search": cmd_search, "databases": cmd_databases, "query": cmd_query, "page": cmd_page, "export": cmd_export, "create-page": cmd_create_page, "users": cmd_users, } if cmd not in cmds: print(f"未知命令: {cmd}") print(f"可用: {', '.join(cmds.keys())}") sys.exit(1) cmds[cmd](args) if __name__ == "__main__": main()