guanghulab/scripts/bridge/fetch-broadcast.js

235 lines
8.7 KiB
JavaScript
Raw Normal View History

// scripts/bridge/fetch-broadcast.js
// 🌉 桥接·从 Notion 拉取广播内容
//
// 根据调度队列中 BROADCAST_READY 任务的关联页面,
// 从 Notion 读取广播页面完整内容,转换为 Markdown 格式
//
// 环境变量:
// NOTION_TOKEN Notion API token
// QUEUE_FILE check-queue.js 输出的任务列表文件路径
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');
const NOTION_VERSION = '2022-06-28';
const NOTION_API_HOSTNAME = 'api.notion.com';
const OUTPUT_DIR = path.join('data', 'broadcasts', 'pdf');
// ══════════════════════════════════════════════════════════
// Notion API
// ══════════════════════════════════════════════════════════
function notionRequest(method, endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : '';
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: method,
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
},
};
if (payload) {
opts.headers['Content-Length'] = Buffer.byteLength(payload);
}
const req = https.request(opts, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(parsed);
} else {
reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data}`));
}
} catch (e) {
reject(new Error(`Notion API parse error: ${data}`));
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// Notion Block → Markdown 转换
// ══════════════════════════════════════════════════════════
function blockToMarkdown(block) {
const type = block.type;
const content = block[type];
if (!content) return '';
const text = extractRichTextContent(content.rich_text);
switch (type) {
case 'paragraph': return text + '\n';
case 'heading_1': return '# ' + text + '\n';
case 'heading_2': return '## ' + text + '\n';
case 'heading_3': return '### ' + text + '\n';
case 'bulleted_list_item': return '- ' + text + '\n';
case 'numbered_list_item': return '1. ' + text + '\n';
case 'to_do': return (content.checked ? '- [x] ' : '- [ ] ') + text + '\n';
case 'toggle': return '> ' + text + '\n';
case 'code': return '```' + (content.language || '') + '\n' + text + '\n```\n';
case 'quote': return '> ' + text + '\n';
case 'divider': return '---\n';
case 'callout': return '> ' + (content.icon?.emoji || '💡') + ' ' + text + '\n';
default: return text ? text + '\n' : '';
}
}
function extractRichTextContent(richTextArray) {
if (!richTextArray || !Array.isArray(richTextArray)) return '';
return richTextArray.map(t => {
let text = t.plain_text || '';
if (t.annotations) {
if (t.annotations.bold) text = '**' + text + '**';
if (t.annotations.italic) text = '*' + text + '*';
if (t.annotations.code) text = '`' + text + '`';
if (t.annotations.strikethrough) text = '~~' + text + '~~';
}
if (t.href) text = '[' + text + '](' + t.href + ')';
return text;
}).join('');
}
// ══════════════════════════════════════════════════════════
// 获取页面所有 blocks
// ══════════════════════════════════════════════════════════
async function getPageBlocks(pageId, token) {
let allBlocks = [];
let cursor = undefined;
do {
const endpoint = `/v1/blocks/${pageId}/children?page_size=100` +
(cursor ? `&start_cursor=${cursor}` : '');
const result = await notionRequest('GET', endpoint, null, token);
allBlocks = allBlocks.concat(result.results || []);
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
return allBlocks;
}
// ══════════════════════════════════════════════════════════
// 获取页面标题
// ══════════════════════════════════════════════════════════
async function getPageTitle(pageId, token) {
const page = await notionRequest('GET', `/v1/pages/${pageId}`, null, token);
const props = page.properties || {};
for (const key of Object.keys(props)) {
if (props[key].type === 'title' && props[key].title) {
return props[key].title.map(t => t.plain_text).join('');
}
}
return '未命名广播';
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function main() {
const token = process.env.NOTION_TOKEN;
const queueFile = process.env.QUEUE_FILE;
if (!token) {
console.log('⚠️ 缺少 NOTION_TOKEN跳过广播拉取');
process.exit(0);
}
if (!queueFile || !fs.existsSync(queueFile)) {
console.log('📭 无待处理的 BROADCAST_READY 任务,跳过');
process.exit(0);
}
const tasks = JSON.parse(fs.readFileSync(queueFile, 'utf8'));
if (tasks.length === 0) {
console.log('📭 任务列表为空,跳过');
process.exit(0);
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log(`📥 准备拉取 ${tasks.length} 条广播内容…`);
const mdFiles = [];
let ok = 0, failed = 0;
for (const task of tasks) {
// 尝试从 Payload 中提取页面 ID
let pageId = '';
try {
const payload = JSON.parse(task.payload || '{}');
pageId = payload.page_id || payload.broadcast_page_id || '';
} catch (_) { /* ignore */ }
if (!pageId) {
console.log(` ⚠️ 任务 ${task.task_name} 无关联页面 ID跳过`);
failed++;
continue;
}
try {
console.log(` 📖 拉取广播: ${task.task_name} (page: ${pageId})`);
const title = await getPageTitle(pageId, token);
const blocks = await getPageBlocks(pageId, token);
// 转换为 Markdown
let markdown = `# ${title}\n\n`;
markdown += `> 广播编号: ${task.broadcast_id || 'N/A'}\n`;
markdown += `> 生成时间: ${new Date().toISOString()}\n\n`;
markdown += '---\n\n';
for (const block of blocks) {
markdown += blockToMarkdown(block);
}
// 写入 Markdown 文件
const safeName = (task.broadcast_id || task.task_name || 'broadcast')
.replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_');
const mdFile = path.join(OUTPUT_DIR, `${safeName}.md`);
fs.writeFileSync(mdFile, markdown, 'utf8');
mdFiles.push({ file: mdFile, task_id: task.id, title });
console.log(`${mdFile} (${blocks.length} blocks)`);
ok++;
} catch (e) {
console.error(` ❌ 拉取失败: ${task.task_name}: ${e.message}`);
failed++;
}
}
console.log(`\n✅ 广播内容拉取完成 · 成功 ${ok} 条 · 失败 ${failed}`);
// 将 Markdown 文件列表写入供后续步骤使用
const manifestFile = path.join(OUTPUT_DIR, 'manifest.json');
fs.writeFileSync(manifestFile, JSON.stringify(mdFiles, null, 2));
console.log(`📁 清单已写入: ${manifestFile}`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`broadcast_count=${ok}\nmanifest_file=${manifestFile}\n`
);
}
}
main().catch(e => { console.error(e); process.exit(1); });