guanghulab/scripts/bridge/fetch-broadcast.js
Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

235 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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); });