477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 模块C · Notion接入COS桶 MCP 集成工具
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 签发: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* Notion ↔ COS双向同步桥接
|
|||
|
|
*
|
|||
|
|
* 读取方向: COS桶中的Notion导出压缩文件 → 解压 → 建立目录索引
|
|||
|
|
* 写入方向: Notion层人格体通过API写入COS桶指定路径
|
|||
|
|
*
|
|||
|
|
* COS桶内类Notion数据库结构:
|
|||
|
|
* /notion-mirror/{page_id}/content.json — 页面内容
|
|||
|
|
* /notion-mirror/{page_id}/metadata.json — 页面属性
|
|||
|
|
* /notion-mirror/index.json — 全局索引
|
|||
|
|
*
|
|||
|
|
* 工具清单:
|
|||
|
|
* notionCosSyncPage — 同步Notion页面到COS桶
|
|||
|
|
* notionCosReadMirror — 从COS镜像读取页面
|
|||
|
|
* notionCosListMirror — 列出COS镜像中的页面
|
|||
|
|
* notionCosBuildIndex — 重建COS镜像索引
|
|||
|
|
* notionCosWriteWorkorder — 写入工单到COS桶(供Notion人格体使用)
|
|||
|
|
* notionCosReadWorkorder — 读取工单
|
|||
|
|
* notionCosListWorkorders — 列出工单
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
const cos = require('../cos');
|
|||
|
|
// 运行时可选加载 notion-client(不影响核心功能)
|
|||
|
|
let notionClient = null;
|
|||
|
|
try {
|
|||
|
|
notionClient = require('../notion-client');
|
|||
|
|
} catch {
|
|||
|
|
// Notion模块未安装时降级
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 常量 ───
|
|||
|
|
const MIRROR_PREFIX = 'notion-mirror/';
|
|||
|
|
const MIRROR_PREFIX_REGEX = /^notion-mirror\/([^/]+)\//;
|
|||
|
|
const WORKORDER_PREFIX = 'workorders/';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosSyncPage — 同步Notion页面到COS桶
|
|||
|
|
*
|
|||
|
|
* 从Notion API读取页面内容,镜像写入COS桶
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* page_id: string — Notion页面ID
|
|||
|
|
* bucket: string — 目标COS桶(默认hot)
|
|||
|
|
* include_blocks: boolean — 是否包含内容块(默认true)
|
|||
|
|
*/
|
|||
|
|
async function notionCosSyncPage(input) {
|
|||
|
|
const { page_id, bucket, include_blocks } = input;
|
|||
|
|
if (!page_id) throw new Error('缺少 page_id');
|
|||
|
|
if (!notionClient) throw new Error('Notion客户端未加载(ZY_NOTION_TOKEN未配置)');
|
|||
|
|
|
|||
|
|
const targetBucket = bucket || 'hot';
|
|||
|
|
const shouldIncludeBlocks = include_blocks !== false;
|
|||
|
|
|
|||
|
|
// 从Notion读取页面
|
|||
|
|
const page = await notionClient.readPage(page_id);
|
|||
|
|
|
|||
|
|
// 提取元数据
|
|||
|
|
const metadata = {
|
|||
|
|
id: page.id,
|
|||
|
|
created_time: page.created_time,
|
|||
|
|
last_edited_time: page.last_edited_time,
|
|||
|
|
title: extractTitle(page.properties),
|
|||
|
|
properties: simplifyProperties(page.properties),
|
|||
|
|
synced_at: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 提取内容
|
|||
|
|
const content = {
|
|||
|
|
id: page.id,
|
|||
|
|
title: metadata.title,
|
|||
|
|
blocks: shouldIncludeBlocks ? simplifyBlocks(page.blocks) : [],
|
|||
|
|
text_content: shouldIncludeBlocks ? extractTextFromBlocks(page.blocks) : '',
|
|||
|
|
synced_at: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 写入COS
|
|||
|
|
const metadataKey = `${MIRROR_PREFIX}${page_id}/metadata.json`;
|
|||
|
|
const contentKey = `${MIRROR_PREFIX}${page_id}/content.json`;
|
|||
|
|
|
|||
|
|
await Promise.all([
|
|||
|
|
cos.write(targetBucket, metadataKey, JSON.stringify(metadata, null, 2), 'application/json'),
|
|||
|
|
cos.write(targetBucket, contentKey, JSON.stringify(content, null, 2), 'application/json')
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
status: 'synced',
|
|||
|
|
page_id,
|
|||
|
|
title: metadata.title,
|
|||
|
|
metadata_key: metadataKey,
|
|||
|
|
content_key: contentKey,
|
|||
|
|
blocks_count: content.blocks.length,
|
|||
|
|
text_length: content.text_content.length
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosReadMirror — 从COS镜像读取页面
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* page_id: string — 页面ID
|
|||
|
|
* bucket: string — COS桶(默认hot)
|
|||
|
|
* type: string — 读取类型: content|metadata|both(默认both)
|
|||
|
|
*/
|
|||
|
|
async function notionCosReadMirror(input) {
|
|||
|
|
const { page_id, bucket, type } = input;
|
|||
|
|
if (!page_id) throw new Error('缺少 page_id');
|
|||
|
|
|
|||
|
|
const targetBucket = bucket || 'hot';
|
|||
|
|
const readType = type || 'both';
|
|||
|
|
|
|||
|
|
const result = {};
|
|||
|
|
|
|||
|
|
if (readType === 'metadata' || readType === 'both') {
|
|||
|
|
try {
|
|||
|
|
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${page_id}/metadata.json`);
|
|||
|
|
result.metadata = JSON.parse(raw.content);
|
|||
|
|
} catch {
|
|||
|
|
result.metadata = null;
|
|||
|
|
result.metadata_error = '元数据不存在';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (readType === 'content' || readType === 'both') {
|
|||
|
|
try {
|
|||
|
|
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${page_id}/content.json`);
|
|||
|
|
result.content = JSON.parse(raw.content);
|
|||
|
|
} catch {
|
|||
|
|
result.content = null;
|
|||
|
|
result.content_error = '内容不存在';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosListMirror — 列出COS镜像中的页面
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* bucket: string — COS桶(默认hot)
|
|||
|
|
* limit: number — 最大数量(默认100)
|
|||
|
|
*/
|
|||
|
|
async function notionCosListMirror(input) {
|
|||
|
|
const { bucket, limit } = input;
|
|||
|
|
const targetBucket = bucket || 'hot';
|
|||
|
|
|
|||
|
|
const result = await cos.list(targetBucket, MIRROR_PREFIX, limit || 200);
|
|||
|
|
|
|||
|
|
// 提取唯一的page_id
|
|||
|
|
const pageIds = new Set();
|
|||
|
|
for (const file of result.files) {
|
|||
|
|
const match = file.key.match(MIRROR_PREFIX_REGEX);
|
|||
|
|
if (match) pageIds.add(match[1]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 读取每个page的metadata(异步并行)
|
|||
|
|
const pages = await Promise.all(
|
|||
|
|
[...pageIds].slice(0, limit || 100).map(async (pageId) => {
|
|||
|
|
try {
|
|||
|
|
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${pageId}/metadata.json`);
|
|||
|
|
const meta = JSON.parse(raw.content);
|
|||
|
|
return {
|
|||
|
|
page_id: pageId,
|
|||
|
|
title: meta.title || '未命名',
|
|||
|
|
synced_at: meta.synced_at,
|
|||
|
|
last_edited_time: meta.last_edited_time
|
|||
|
|
};
|
|||
|
|
} catch {
|
|||
|
|
return { page_id: pageId, title: '(元数据缺失)', synced_at: null };
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
pages,
|
|||
|
|
total: pages.length
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosBuildIndex — 重建COS镜像索引
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* bucket: string — COS桶(默认hot)
|
|||
|
|
*/
|
|||
|
|
async function notionCosBuildIndex(input) {
|
|||
|
|
const { bucket } = input;
|
|||
|
|
const targetBucket = bucket || 'hot';
|
|||
|
|
|
|||
|
|
// 列出所有镜像文件
|
|||
|
|
const result = await cos.list(targetBucket, MIRROR_PREFIX, 500);
|
|||
|
|
|
|||
|
|
// 提取唯一的page_id和元数据
|
|||
|
|
const pageMap = {};
|
|||
|
|
for (const file of result.files) {
|
|||
|
|
const match = file.key.match(MIRROR_PREFIX_REGEX);
|
|||
|
|
if (match) {
|
|||
|
|
const pageId = match[1];
|
|||
|
|
if (!pageMap[pageId]) pageMap[pageId] = { files: [] };
|
|||
|
|
pageMap[pageId].files.push(file.key);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 读取每个page的metadata
|
|||
|
|
const pages = [];
|
|||
|
|
for (const [pageId, info] of Object.entries(pageMap)) {
|
|||
|
|
try {
|
|||
|
|
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${pageId}/metadata.json`);
|
|||
|
|
const meta = JSON.parse(raw.content);
|
|||
|
|
pages.push({
|
|||
|
|
page_id: pageId,
|
|||
|
|
title: meta.title || '未命名',
|
|||
|
|
synced_at: meta.synced_at,
|
|||
|
|
last_edited_time: meta.last_edited_time,
|
|||
|
|
properties: meta.properties || {},
|
|||
|
|
files: info.files
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
pages.push({
|
|||
|
|
page_id: pageId,
|
|||
|
|
title: '(元数据缺失)',
|
|||
|
|
files: info.files
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 构建索引
|
|||
|
|
const index = {
|
|||
|
|
version: '1.0',
|
|||
|
|
built_at: new Date().toISOString(),
|
|||
|
|
total_pages: pages.length,
|
|||
|
|
pages,
|
|||
|
|
categories: categorizePages(pages)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 写入索引
|
|||
|
|
await cos.write(targetBucket, `${MIRROR_PREFIX}index.json`, JSON.stringify(index, null, 2), 'application/json');
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
status: 'index_built',
|
|||
|
|
total_pages: pages.length,
|
|||
|
|
index_key: `${MIRROR_PREFIX}index.json`,
|
|||
|
|
categories: index.categories
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosWriteWorkorder — 写入工单到COS桶
|
|||
|
|
*
|
|||
|
|
* 供Notion层人格体通过COS桶发送工单给铸渊
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* bucket: string — COS桶(默认team)
|
|||
|
|
* workorder_id: string — 工单ID(如 WO-20260408-001)
|
|||
|
|
* title: string — 工单标题
|
|||
|
|
* type: string — 工单类型: dev|bug|feature|query
|
|||
|
|
* priority: string — 优先级: critical|high|normal|low
|
|||
|
|
* description: string — 工单描述
|
|||
|
|
* source: string — 来源: notion|chat|manual
|
|||
|
|
* assigned_to: string — 指派给(默认zhuyuan)
|
|||
|
|
* attachments: object[] — 附件列表(可选)
|
|||
|
|
*/
|
|||
|
|
async function notionCosWriteWorkorder(input) {
|
|||
|
|
const {
|
|||
|
|
bucket, workorder_id, title, type, priority,
|
|||
|
|
description, source, assigned_to, attachments
|
|||
|
|
} = input;
|
|||
|
|
if (!title) throw new Error('缺少 title');
|
|||
|
|
|
|||
|
|
const targetBucket = bucket || 'team';
|
|||
|
|
const woId = workorder_id || `WO-${formatDate()}-${crypto.randomBytes(4).toString('hex')}`;
|
|||
|
|
|
|||
|
|
// 验证 workorder_id 安全性
|
|||
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(woId)) {
|
|||
|
|
throw new Error('workorder_id 包含非法字符');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const now = new Date().toISOString();
|
|||
|
|
const workorder = {
|
|||
|
|
workorder_id: woId,
|
|||
|
|
title,
|
|||
|
|
type: type || 'dev',
|
|||
|
|
priority: priority || 'normal',
|
|||
|
|
status: 'pending',
|
|||
|
|
description: description || '',
|
|||
|
|
source: source || 'manual',
|
|||
|
|
assigned_to: assigned_to || 'zhuyuan',
|
|||
|
|
created_at: now,
|
|||
|
|
updated_at: now,
|
|||
|
|
attachments: attachments || [],
|
|||
|
|
history: [{
|
|||
|
|
action: 'created',
|
|||
|
|
timestamp: now,
|
|||
|
|
by: source || 'manual'
|
|||
|
|
}]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const key = `${WORKORDER_PREFIX}pending/${woId}.json`;
|
|||
|
|
await cos.write(targetBucket, key, JSON.stringify(workorder, null, 2), 'application/json');
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
status: 'created',
|
|||
|
|
workorder_id: woId,
|
|||
|
|
key,
|
|||
|
|
bucket: targetBucket,
|
|||
|
|
priority: workorder.priority
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosReadWorkorder — 读取工单
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* bucket: string — COS桶(默认team)
|
|||
|
|
* workorder_id: string — 工单ID
|
|||
|
|
* status_folder: string — 状态文件夹: pending|processing|completed|rejected(默认pending)
|
|||
|
|
*/
|
|||
|
|
async function notionCosReadWorkorder(input) {
|
|||
|
|
const { bucket, workorder_id, status_folder } = input;
|
|||
|
|
if (!workorder_id) throw new Error('缺少 workorder_id');
|
|||
|
|
|
|||
|
|
const targetBucket = bucket || 'team';
|
|||
|
|
const folder = status_folder || 'pending';
|
|||
|
|
const key = `${WORKORDER_PREFIX}${folder}/${workorder_id}.json`;
|
|||
|
|
|
|||
|
|
const result = await cos.read(targetBucket, key);
|
|||
|
|
return {
|
|||
|
|
workorder: JSON.parse(result.content),
|
|||
|
|
key,
|
|||
|
|
size_bytes: result.size_bytes
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* notionCosListWorkorders — 列出工单
|
|||
|
|
*
|
|||
|
|
* input:
|
|||
|
|
* bucket: string — COS桶(默认team)
|
|||
|
|
* status_folder: string — 状态文件夹: pending|processing|completed|rejected(默认pending)
|
|||
|
|
* limit: number — 最大数量
|
|||
|
|
*/
|
|||
|
|
async function notionCosListWorkorders(input) {
|
|||
|
|
const { bucket, status_folder, limit } = input;
|
|||
|
|
const targetBucket = bucket || 'team';
|
|||
|
|
const folder = status_folder || 'pending';
|
|||
|
|
|
|||
|
|
const result = await cos.list(targetBucket, `${WORKORDER_PREFIX}${folder}/`, limit || 100);
|
|||
|
|
|
|||
|
|
const workorders = result.files
|
|||
|
|
.filter(f => f.key.endsWith('.json'))
|
|||
|
|
.map(f => ({
|
|||
|
|
key: f.key,
|
|||
|
|
workorder_id: f.key.split('/').pop().replace('.json', ''),
|
|||
|
|
size_bytes: f.size_bytes,
|
|||
|
|
status: folder
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
workorders,
|
|||
|
|
count: workorders.length,
|
|||
|
|
status_folder: folder
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// 辅助函数
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function extractTitle(properties) {
|
|||
|
|
if (!properties) return '未命名';
|
|||
|
|
for (const [, prop] of Object.entries(properties)) {
|
|||
|
|
if (prop.type === 'title' && prop.title) {
|
|||
|
|
return prop.title.map(t => t.plain_text || '').join('') || '未命名';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '未命名';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function simplifyProperties(properties) {
|
|||
|
|
if (!properties) return {};
|
|||
|
|
const result = {};
|
|||
|
|
for (const [name, prop] of Object.entries(properties)) {
|
|||
|
|
switch (prop.type) {
|
|||
|
|
case 'title':
|
|||
|
|
result[name] = prop.title?.map(t => t.plain_text).join('') || '';
|
|||
|
|
break;
|
|||
|
|
case 'rich_text':
|
|||
|
|
result[name] = prop.rich_text?.map(t => t.plain_text).join('') || '';
|
|||
|
|
break;
|
|||
|
|
case 'select':
|
|||
|
|
result[name] = prop.select?.name || null;
|
|||
|
|
break;
|
|||
|
|
case 'multi_select':
|
|||
|
|
result[name] = prop.multi_select?.map(s => s.name) || [];
|
|||
|
|
break;
|
|||
|
|
case 'date':
|
|||
|
|
result[name] = prop.date?.start || null;
|
|||
|
|
break;
|
|||
|
|
case 'checkbox':
|
|||
|
|
result[name] = prop.checkbox || false;
|
|||
|
|
break;
|
|||
|
|
case 'number':
|
|||
|
|
result[name] = prop.number;
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
result[name] = `(${prop.type})`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function simplifyBlocks(blocks) {
|
|||
|
|
if (!blocks) return [];
|
|||
|
|
return blocks.map(b => {
|
|||
|
|
const type = b.type;
|
|||
|
|
const blockData = b[type];
|
|||
|
|
if (!blockData) return { type, text: '' };
|
|||
|
|
|
|||
|
|
let text = '';
|
|||
|
|
if (blockData.rich_text) {
|
|||
|
|
text = blockData.rich_text.map(t => t.plain_text || '').join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id: b.id,
|
|||
|
|
type,
|
|||
|
|
text,
|
|||
|
|
has_children: b.has_children || false
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractTextFromBlocks(blocks) {
|
|||
|
|
if (!blocks) return '';
|
|||
|
|
return blocks.map(b => {
|
|||
|
|
const type = b.type;
|
|||
|
|
const blockData = b[type];
|
|||
|
|
if (!blockData?.rich_text) return '';
|
|||
|
|
return blockData.rich_text.map(t => t.plain_text || '').join('');
|
|||
|
|
}).filter(Boolean).join('\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function categorizePages(pages) {
|
|||
|
|
const categories = {};
|
|||
|
|
for (const page of pages) {
|
|||
|
|
const cat = page.properties?.category || page.properties?.类型 || 'uncategorized';
|
|||
|
|
const catName = typeof cat === 'string' ? cat : 'uncategorized';
|
|||
|
|
categories[catName] = (categories[catName] || 0) + 1;
|
|||
|
|
}
|
|||
|
|
return categories;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDate() {
|
|||
|
|
const d = new Date();
|
|||
|
|
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
notionCosSyncPage,
|
|||
|
|
notionCosReadMirror,
|
|||
|
|
notionCosListMirror,
|
|||
|
|
notionCosBuildIndex,
|
|||
|
|
notionCosWriteWorkorder,
|
|||
|
|
notionCosReadWorkorder,
|
|||
|
|
notionCosListWorkorders
|
|||
|
|
};
|