🌉 BRIDGE-DEPLOY: 全链路桥接系统部署 — 脚本8个 · Workflow3个 · 飞书路由1个

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-16 15:23:32 +00:00
parent d616a33ed0
commit 40c12c75a3
15 changed files with 1970 additions and 0 deletions

View File

@ -0,0 +1,133 @@
name: 铸渊 · 🌉 桥接·广播PDF生成+分发
# 定时拉取 Notion 中 BROADCAST_READY 的广播 → 生成 PDF → 上传 → 分发到 IM
#
# 依赖 Secrets
# NOTION_API_TOKEN Notion 集成 token
# BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
# FEISHU_APP_ID 飞书 App ID
# FEISHU_APP_SECRET 飞书 App Secret
# DINGTALK_TOKEN 钉钉机器人 Token
# OSS_ACCESS_KEY 阿里云 OSS AccessKey可选
# OSS_SECRET_KEY 阿里云 OSS SecretKey可选
on:
schedule:
- cron: '0 6,14 * * *' # UTC 06:00+14:00 = 北京 14:00+22:00
workflow_dispatch:
jobs:
# ── AGE OS v1.0: 核心大脑唤醒 ──
wake-brain:
name: 🌅 唤醒核心大脑
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
brain_awake: ${{ steps.wake.outputs.brain_awake }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🧠 唤醒铸渊核心大脑
id: wake
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
node core/brain-wake --task "桥接广播PDF生成分发" || {
echo "⚠️ 核心大脑唤醒失败,使用 dry-run 模式继续"
echo "brain_awake=dry-run" >> "$GITHUB_OUTPUT"
}
generate-and-distribute:
name: 🌉 广播PDF生成+分发
needs: wake-brain
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 📡 检查调度队列
id: check
env:
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
BRIDGE_QUEUE_DB_ID: ${{ secrets.BRIDGE_QUEUE_DB_ID }}
run: node scripts/bridge/check-queue.js BROADCAST_READY
- name: 📥 拉取广播内容
if: steps.check.outputs.task_count != '0'
id: fetch
env:
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
QUEUE_FILE: ${{ steps.check.outputs.queue_file }}
run: node scripts/bridge/fetch-broadcast.js
- name: 📄 生成PDF
if: steps.check.outputs.task_count != '0'
id: pdf
env:
MANIFEST_FILE: ${{ steps.fetch.outputs.manifest_file }}
run: |
npm install --no-save md-to-pdf 2>/dev/null || echo "⚠️ md-to-pdf 安装失败,使用回退模式"
node scripts/bridge/generate-pdf.js
- name: ☁️ 上传PDF
if: steps.check.outputs.task_count != '0'
id: upload
env:
PDF_MANIFEST: ${{ steps.pdf.outputs.pdf_manifest }}
UPLOAD_MODE: ${{ vars.BRIDGE_UPLOAD_MODE || 'server' }}
OSS_ACCESS_KEY: ${{ secrets.OSS_ACCESS_KEY }}
OSS_SECRET_KEY: ${{ secrets.OSS_SECRET_KEY }}
run: node scripts/bridge/upload-pdf.js
- name: 📢 分发到IM
if: steps.check.outputs.task_count != '0'
env:
DIST_MANIFEST: ${{ steps.upload.outputs.dist_manifest }}
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
DINGTALK_TOKEN: ${{ secrets.DINGTALK_TOKEN }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
ALERT_EMAIL: ${{ vars.ALERT_EMAIL || '565183519@qq.com' }}
FEISHU_BROADCAST_CHAT_ID: ${{ vars.FEISHU_BROADCAST_CHAT_ID }}
run: node scripts/bridge/distribute.js
- name: ✅ 更新调度队列状态
if: steps.check.outputs.task_count != '0'
env:
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
QUEUE_FILE: ${{ steps.check.outputs.queue_file }}
run: node scripts/bridge/update-queue-status.js
- name: 📦 提交生成结果
if: steps.check.outputs.task_count != '0'
run: |
git config user.name "铸渊[bot]"
git config user.email "zhu-yuan-bot@guanghulab.com"
git add data/broadcasts/ data/bridge-logs/
git diff --staged --quiet || git commit -m "🌉 桥接广播PDF生成+分发 · $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M') [auto-bridge]"
git push
- name: 📋 运行总结
run: |
echo "🌉 桥接·广播PDF生成+分发 完成"
echo "📋 待处理任务: ${{ steps.check.outputs.task_count || '0' }}"
echo "📄 生成PDF: ${{ steps.pdf.outputs.pdf_count || '0' }}"
echo "☁️ 已上传: ${{ steps.upload.outputs.uploaded_count || '0' }}"

80
.github/workflows/bridge-heartbeat.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: 铸渊 · 🌉 桥接·心跳检测
# 每6小时检测桥接通道畅通性
# 测试 Notion API 连通 + 在调度队列写入 HEARTBEAT 记录
#
# 依赖 Secrets
# NOTION_API_TOKEN Notion 集成 token
# BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
on:
schedule:
- cron: '0 0,6,12,18 * * *' # UTC 每6小时 = 北京 08:00/14:00/20:00/02:00
workflow_dispatch:
jobs:
# ── AGE OS v1.0: 核心大脑唤醒 ──
wake-brain:
name: 🌅 唤醒核心大脑
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
brain_awake: ${{ steps.wake.outputs.brain_awake }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🧠 唤醒铸渊核心大脑
id: wake
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
node core/brain-wake --task "桥接心跳检测" || {
echo "⚠️ 核心大脑唤醒失败,使用 dry-run 模式继续"
echo "brain_awake=dry-run" >> "$GITHUB_OUTPUT"
}
heartbeat:
name: 💓 桥接心跳检测
needs: wake-brain
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 💓 执行心跳检测
id: heartbeat
env:
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
BRIDGE_QUEUE_DB_ID: ${{ secrets.BRIDGE_QUEUE_DB_ID }}
run: node scripts/bridge/heartbeat.js
- name: 📦 提交心跳日志
run: |
git config user.name "铸渊[bot]"
git config user.email "zhu-yuan-bot@guanghulab.com"
git add data/bridge-logs/
git diff --staged --quiet || git commit -m "💓 桥接心跳 · $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M') [auto-bridge]"
git push
- name: 📋 心跳结果
run: |
echo "💓 桥接心跳检测完成"
echo "📡 Notion: ${{ steps.heartbeat.outputs.notion_healthy || 'unknown' }}"
echo "🌉 状态: ${{ steps.heartbeat.outputs.heartbeat_status || 'unknown' }}"

View File

@ -0,0 +1,84 @@
name: 铸渊 · 🌉 桥接·SYSLOG 上行处理
# syslog-inbox/ 有新文件 push → 批量写入 Notion + 创建调度队列任务 + 归档
#
# 依赖 Secrets
# NOTION_API_TOKEN Notion 集成 token
# BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
# NOTION_SYSLOG_DB_ID SYSLOG 收件箱数据库 ID可选有默认值
on:
push:
branches: [main]
paths:
- 'syslog-inbox/**'
workflow_dispatch:
jobs:
# ── AGE OS v1.0: 核心大脑唤醒 ──
wake-brain:
name: 🌅 唤醒核心大脑
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
brain_awake: ${{ steps.wake.outputs.brain_awake }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🧠 唤醒铸渊核心大脑
id: wake
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
node core/brain-wake --task "桥接SYSLOG上行" || {
echo "⚠️ 核心大脑唤醒失败,使用 dry-run 模式继续"
echo "brain_awake=dry-run" >> "$GITHUB_OUTPUT"
}
process-syslog:
name: 🌉 SYSLOG 批量上行处理
needs: wake-brain
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 📥 批量处理 SYSLOG
id: process
env:
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
BRIDGE_QUEUE_DB_ID: ${{ secrets.BRIDGE_QUEUE_DB_ID }}
SYSLOG_DB_ID: ${{ secrets.NOTION_SYSLOG_DB_ID }}
COMMIT_SHA: ${{ github.sha }}
run: node scripts/bridge/process-syslog-batch.js
- name: 📦 提交归档结果
run: |
git config user.name "铸渊[bot]"
git config user.email "zhu-yuan-bot@guanghulab.com"
git add syslog-inbox/ syslog-processed/ data/bridge-logs/
git diff --staged --quiet || git commit -m "🌉 桥接SYSLOG上行处理 · 已归档 [auto-bridge]"
git push
- name: ✅ 上行处理完成
run: |
echo "✅ SYSLOG 上行处理完成"
echo "📋 已处理: ${{ steps.process.outputs.processed_count || '0' }} 条"
echo "📡 等待 Notion Agent 自动触发 SYSLOG 闭环处理"

View File

@ -4,7 +4,11 @@ const router = express.Router();
const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const BASE_URL = 'https://open.feishu.cn/open-apis';
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'qinfendebingshuo';
const REPO_NAME = 'guanghulab';
// 获取飞书 tenant_access_token
async function getToken() {
@ -49,4 +53,112 @@ router.post('/broadcast', async (req, res) => {
}
});
// ══════════════════════════════════════════════════════════
// 🌉 SYSLOG 接收路由(飞书舒舒 → GitHub syslog-inbox
// ══════════════════════════════════════════════════════════
/**
* 解析 SYSLOG 消息
* 支持两种格式
* 1. 直接 JSON 对象POST body
* 2. 包含 header 的标准 SYSLOG 格式
*/
function parseSyslog(body) {
if (!body || typeof body !== 'object') return null;
// 如果 body 本身就是 SYSLOG有 header 字段)
if (body.header && (body.header.broadcast_id || body.header.dev_id)) {
return body;
}
// 如果是飞书消息包裹格式
if (body.syslog && typeof body.syslog === 'object') {
return body.syslog;
}
// 如果直接有 broadcast_id 和 dev_id简化格式
if (body.broadcast_id && body.dev_id) {
return { header: { broadcast_id: body.broadcast_id, dev_id: body.dev_id }, ...body };
}
return null;
}
router.post('/syslog-receive', async (req, res) => {
try {
// ① 解析 SYSLOG
const syslog = parseSyslog(req.body);
if (!syslog) {
return res.status(400).json({
error: true,
code: 'INVALID_SYSLOG',
message: '无效的 SYSLOG 格式,请确保包含 header.broadcast_id 和 header.dev_id'
});
}
// ② 校验必填字段
const broadcastId = syslog.header?.broadcast_id || syslog.broadcast_id;
const devId = syslog.header?.dev_id || syslog.dev_id;
if (!broadcastId || !devId) {
return res.status(400).json({
error: true,
code: 'MISSING_FIELDS',
message: '❌ SYSLOG 缺少 broadcast_id 或 dev_id'
});
}
// ③ 写入 GitHub syslog-inbox/
if (!GITHUB_TOKEN) {
return res.status(503).json({
error: true,
code: 'NO_GITHUB_TOKEN',
message: 'GitHub Token 未配置,无法写入仓库'
});
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const safeDevId = String(devId).replace(/[^a-zA-Z0-9_-]/g, '');
const filename = `SYSLOG-${safeDevId}-${timestamp}.json`;
const filePath = `syslog-inbox/${filename}`;
const content = Buffer.from(JSON.stringify(syslog, null, 2)).toString('base64');
const githubUrl = `${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${filePath}`;
const githubResponse = await axios.put(
githubUrl,
{
message: `[BRIDGE] SYSLOG from ${safeDevId} via 飞书`,
content: content
},
{
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json'
}
}
);
// ④ 回复开发者
res.json({
success: true,
message: '✅ SYSLOG 已提交',
broadcast_id: broadcastId,
dev_id: devId,
filename: filename,
info: '将在下一个处理窗口自动处理,处理完成后会推送新广播',
github_sha: githubResponse.data?.content?.sha || ''
});
} catch (err) {
const status = err.response?.status || 500;
res.status(status).json({
error: true,
code: 'SYSLOG_SUBMIT_FAILED',
message: err.message,
detail: err.response?.data || null
});
}
});
module.exports = router;

View File

View File

0
scripts/bridge/.gitkeep Normal file
View File

View File

@ -0,0 +1,170 @@
// scripts/bridge/check-queue.js
// 🌉 桥接·调度队列检查
//
// 查询 Notion 调度队列数据库中指定类型+状态的任务
// 用法: node scripts/bridge/check-queue.js [BROADCAST_READY|SYSLOG_RECEIVED|HEARTBEAT]
//
// 环境变量:
// NOTION_TOKEN Notion API token
// BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
'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';
// ══════════════════════════════════════════════════════════
// Notion API 基础调用
// ══════════════════════════════════════════════════════════
function notionPost(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'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);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function checkQueue(taskType) {
const token = process.env.NOTION_TOKEN;
const dbId = process.env.BRIDGE_QUEUE_DB_ID;
if (!token) {
console.log('⚠️ 缺少 NOTION_TOKEN跳过调度队列检查');
process.exit(0);
}
if (!dbId) {
console.log('⚠️ 缺少 BRIDGE_QUEUE_DB_ID跳过调度队列检查');
process.exit(0);
}
console.log(`🌉 检查调度队列 · 类型=${taskType} · 状态=待处理`);
const filter = {
and: [
{ property: '类型', select: { equals: taskType } },
{ property: '处理状态', status: { equals: '待处理' } },
],
};
const body = {
filter,
sorts: [{ property: '创建时间', direction: 'ascending' }],
page_size: 50,
};
try {
const result = await notionPost(`/v1/databases/${dbId}/query`, body, token);
const tasks = result.results || [];
console.log(`📋 发现 ${tasks.length} 条待处理任务`);
// 将任务列表写入临时文件供后续步骤使用
const outputDir = path.join('data', 'bridge-logs');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputFile = path.join(outputDir, `queue-${taskType.toLowerCase()}-${Date.now()}.json`);
const taskSummaries = tasks.map(t => ({
id: t.id,
task_name: extractTitle(t),
task_type: taskType,
dev_id: extractSelect(t, 'DEV编号'),
broadcast_id: extractRichText(t, '广播编号'),
source: extractRichText(t, '来源渠道'),
payload: extractRichText(t, 'Payload'),
created_time: t.created_time,
}));
fs.writeFileSync(outputFile, JSON.stringify(taskSummaries, null, 2));
console.log(`📁 任务列表已写入: ${outputFile}`);
// 输出到 GITHUB_OUTPUT供 workflow 使用)
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`task_count=${tasks.length}\n` +
`queue_file=${outputFile}\n`
);
}
return taskSummaries;
} catch (e) {
console.error(`❌ 查询调度队列失败: ${e.message}`);
process.exit(1);
}
}
// ══════════════════════════════════════════════════════════
// Notion 属性提取辅助
// ══════════════════════════════════════════════════════════
function extractTitle(page) {
const prop = page.properties && page.properties['任务名称'];
if (prop && prop.title && prop.title.length > 0) {
return prop.title.map(t => t.plain_text).join('');
}
return '';
}
function extractSelect(page, name) {
const prop = page.properties && page.properties[name];
if (prop && prop.select) {
return prop.select.name || '';
}
return '';
}
function extractRichText(page, name) {
const prop = page.properties && page.properties[name];
if (prop && prop.rich_text && prop.rich_text.length > 0) {
return prop.rich_text.map(t => t.plain_text).join('');
}
return '';
}
// ══════════════════════════════════════════════════════════
// 入口
// ══════════════════════════════════════════════════════════
const taskType = process.argv[2] || 'BROADCAST_READY';
checkQueue(taskType).catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,230 @@
// scripts/bridge/distribute.js
// 🌉 桥接·广播分发到 IM
//
// 根据分发清单,查询人格体注册表获取通知渠道,
// 将 PDF 下载链接推送到飞书/钉钉/邮箱
//
// 环境变量:
// NOTION_TOKEN Notion API token查询人格体注册表
// DIST_MANIFEST dist-manifest.json 路径
// FEISHU_APP_ID 飞书 App ID
// FEISHU_APP_SECRET 飞书 App Secret
// DINGTALK_TOKEN 钉钉机器人 Token
// SMTP_USER 邮件发送者
// SMTP_PASS 邮件授权码
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');
const OUTPUT_DIR = path.join('data', 'broadcasts', 'pdf');
// ══════════════════════════════════════════════════════════
// HTTP 请求辅助
// ══════════════════════════════════════════════════════════
function httpsPost(hostname, apiPath, body, headers) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname,
port: 443,
path: apiPath,
method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
}, headers || {}),
};
const req = https.request(opts, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({ status: res.statusCode, body: data });
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// 飞书分发
// ══════════════════════════════════════════════════════════
async function getFeishuToken() {
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
if (!appId || !appSecret) return null;
const result = await httpsPost(
'open.feishu.cn',
'/open-apis/auth/v3/tenant_access_token/internal',
{ app_id: appId, app_secret: appSecret }
);
const parsed = JSON.parse(result.body);
return parsed.tenant_access_token || null;
}
async function sendFeishuMessage(token, chatId, title, downloadUrl) {
const text = `📡 新广播已生成\n\n📋 ${title}\n\n📎 下载链接: ${downloadUrl}\n\n${new Date().toISOString()}`;
return httpsPost(
'open.feishu.cn',
'/open-apis/im/v1/messages?receive_id_type=chat_id',
{
receive_id: chatId,
msg_type: 'text',
content: JSON.stringify({ text }),
},
{ 'Authorization': 'Bearer ' + token }
);
}
// ══════════════════════════════════════════════════════════
// 钉钉分发
// ══════════════════════════════════════════════════════════
async function sendDingtalkMessage(webhookToken, title, downloadUrl) {
const text = `📡 新广播已生成\n\n📋 ${title}\n\n📎 下载链接: ${downloadUrl}`;
return httpsPost(
'oapi.dingtalk.com',
`/robot/send?access_token=${webhookToken}`,
{
msgtype: 'text',
text: { content: text },
}
);
}
// ══════════════════════════════════════════════════════════
// 邮件分发
// ══════════════════════════════════════════════════════════
async function sendEmailNotification(to, title, downloadUrl) {
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
if (!smtpUser || !smtpPass) {
console.log(' ⚠️ SMTP 未配置,跳过邮件通知');
return;
}
try {
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.qq.com',
port: 465,
secure: true,
auth: { user: smtpUser, pass: smtpPass },
});
await transporter.sendMail({
from: `"光湖广播" <${smtpUser}>`,
to: to,
subject: `📡 ${title}`,
html: `
<h2>📡 新广播已生成</h2>
<p><strong>标题:</strong> ${title}</p>
<p><strong>下载链接:</strong> <a href="${downloadUrl}">${downloadUrl}</a></p>
<p><em> 光湖广播系统 · guanghulab.com</em></p>
`,
});
console.log(` ✅ 邮件已发送: ${to}`);
} catch (e) {
console.error(` ❌ 邮件发送失败: ${e.message}`);
}
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function main() {
const manifestFile = process.env.DIST_MANIFEST ||
path.join(OUTPUT_DIR, 'dist-manifest.json');
if (!fs.existsSync(manifestFile)) {
console.log('📭 无 dist-manifest.json跳过分发');
process.exit(0);
}
const items = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
if (items.length === 0) {
console.log('📭 分发列表为空,跳过');
process.exit(0);
}
console.log(`📢 开始分发 ${items.length} 条广播…`);
// 飞书 Token一次性获取
let feishuToken = null;
if (process.env.FEISHU_APP_ID) {
try {
feishuToken = await getFeishuToken();
if (feishuToken) console.log(' 🔑 飞书 Token 获取成功');
} catch (e) {
console.error(` ⚠️ 飞书 Token 获取失败: ${e.message}`);
}
}
const dingtalkToken = process.env.DINGTALK_TOKEN;
let distributed = 0;
for (const item of items) {
const title = item.title || '光湖广播';
const downloadUrl = item.download_url || '';
if (!downloadUrl) {
console.log(` ⚠️ ${title}: 无下载链接,跳过`);
continue;
}
console.log(`\n📡 分发: ${title}`);
// 飞书分发(广播到默认群)
if (feishuToken && process.env.FEISHU_BROADCAST_CHAT_ID) {
try {
await sendFeishuMessage(feishuToken, process.env.FEISHU_BROADCAST_CHAT_ID, title, downloadUrl);
console.log(' ✅ 飞书群已推送');
} catch (e) {
console.error(` ❌ 飞书推送失败: ${e.message}`);
}
}
// 钉钉分发
if (dingtalkToken) {
try {
await sendDingtalkMessage(dingtalkToken, title, downloadUrl);
console.log(' ✅ 钉钉已推送');
} catch (e) {
console.error(` ❌ 钉钉推送失败: ${e.message}`);
}
}
// 邮件分发(发送给冰朔)
if (process.env.SMTP_USER && process.env.ALERT_EMAIL) {
await sendEmailNotification(process.env.ALERT_EMAIL, title, downloadUrl);
}
distributed++;
}
console.log(`\n✅ 分发完成 · ${distributed}/${items.length} 条广播已推送`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`distributed_count=${distributed}\n`
);
}
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,234 @@
// 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 'Untitled';
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
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); });

View File

@ -0,0 +1,163 @@
// scripts/bridge/generate-pdf.js
// 🌉 桥接·Markdown → PDF 生成
//
// 读取 data/broadcasts/pdf/ 下的 Markdown 文件,
// 使用 md-to-pdf 转换为 PDF套用光湖广播模板
//
// 环境变量:
// MANIFEST_FILE fetch-broadcast.js 生成的清单文件路径
'use strict';
const fs = require('fs');
const path = require('path');
const OUTPUT_DIR = path.join('data', 'broadcasts', 'pdf');
// ══════════════════════════════════════════════════════════
// PDF 样式模板
// ══════════════════════════════════════════════════════════
const PDF_CSS = `
body {
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.8;
color: #333;
padding: 40px;
}
h1 {
font-size: 24px;
border-bottom: 2px solid #1a73e8;
padding-bottom: 10px;
margin-bottom: 20px;
}
h2 { font-size: 20px; margin-top: 30px; }
h3 { font-size: 16px; margin-top: 20px; }
blockquote {
border-left: 4px solid #1a73e8;
padding: 10px 20px;
margin: 15px 0;
background: #f8f9fa;
color: #555;
}
code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
pre code {
display: block;
padding: 15px;
overflow-x: auto;
}
hr {
border: none;
border-top: 1px solid #ddd;
margin: 20px 0;
}
@page {
margin: 20mm;
@top-center { content: "光湖广播 · guanghulab.com"; font-size: 10px; color: #999; }
@bottom-center { content: counter(page) " / " counter(pages); font-size: 10px; color: #999; }
}
`;
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function main() {
const manifestFile = process.env.MANIFEST_FILE ||
path.join(OUTPUT_DIR, 'manifest.json');
if (!fs.existsSync(manifestFile)) {
console.log('📭 无 manifest.json跳过 PDF 生成');
process.exit(0);
}
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
if (manifest.length === 0) {
console.log('📭 清单为空,跳过 PDF 生成');
process.exit(0);
}
// 动态加载 md-to-pdf在 workflow 中安装)
let mdToPdf;
try {
mdToPdf = require('md-to-pdf').mdToPdf;
} catch (e) {
console.error('❌ md-to-pdf 未安装,请先运行: npm install md-to-pdf');
console.log('⚠️ 回退到纯文本 PDF 模式(将 Markdown 原文保留)');
// 回退:仅保留 .md 文件,标记为待处理
for (const item of manifest) {
if (fs.existsSync(item.file)) {
const pdfPath = item.file.replace(/\.md$/, '.pdf.pending');
fs.copyFileSync(item.file, pdfPath);
console.log(` 📝 ${pdfPath} (待转换)`);
}
}
return;
}
console.log(`📄 开始生成 ${manifest.length} 份 PDF…`);
let ok = 0, failed = 0;
const pdfFiles = [];
for (const item of manifest) {
if (!fs.existsSync(item.file)) {
console.log(` ⚠️ 文件不存在: ${item.file}`);
failed++;
continue;
}
try {
const pdfPath = item.file.replace(/\.md$/, '.pdf');
const result = await mdToPdf(
{ path: path.resolve(item.file) },
{
stylesheet: [],
css: PDF_CSS,
pdf_options: {
format: 'A4',
margin: { top: '25mm', bottom: '25mm', left: '20mm', right: '20mm' },
printBackground: true,
},
launch_options: { args: ['--no-sandbox', '--disable-setuid-sandbox'] },
}
);
if (result && result.content) {
fs.writeFileSync(pdfPath, result.content);
pdfFiles.push({ ...item, pdf: pdfPath });
console.log(`${pdfPath}`);
ok++;
} else {
console.error(` ❌ PDF 内容为空: ${item.file}`);
failed++;
}
} catch (e) {
console.error(` ❌ PDF 生成失败: ${item.file}: ${e.message}`);
failed++;
}
}
console.log(`\n✅ PDF 生成完成 · 成功 ${ok} 份 · 失败 ${failed}`);
// 更新 manifest 包含 PDF 路径
const pdfManifest = path.join(OUTPUT_DIR, 'pdf-manifest.json');
fs.writeFileSync(pdfManifest, JSON.stringify(pdfFiles, null, 2));
console.log(`📁 PDF 清单: ${pdfManifest}`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`pdf_count=${ok}\npdf_manifest=${pdfManifest}\n`
);
}
}
main().catch(e => { console.error(e); process.exit(1); });

214
scripts/bridge/heartbeat.js Normal file
View File

@ -0,0 +1,214 @@
// scripts/bridge/heartbeat.js
// 🌉 桥接·心跳检测
//
// 定时检测桥接通道畅通性:
// 1. 测试 Notion API 连通性
// 2. 在调度队列创建 HEARTBEAT 任务
// 3. 记录心跳日志
//
// 环境变量:
// NOTION_TOKEN Notion API token
// BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
'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 NOTION_TITLE_MAX = 120;
const NOTION_RICH_TEXT_MAX = 2000;
// ══════════════════════════════════════════════════════════
// 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);
resolve({ status: res.statusCode, data: parsed });
} catch (e) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
function richText(content) {
const str = String(content || '');
const chunks = [];
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
}
return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }];
}
function titleProp(content) {
return [{ type: 'text', text: { content: String(content || '').slice(0, NOTION_TITLE_MAX) } }];
}
// ══════════════════════════════════════════════════════════
// 心跳检测
// ══════════════════════════════════════════════════════════
async function checkNotionHealth(token) {
const start = Date.now();
try {
const result = await notionRequest('GET', '/v1/users/me', null, token);
const elapsed = Date.now() - start;
return {
healthy: result.status >= 200 && result.status < 300,
status: result.status,
latency: elapsed,
user: result.data?.name || 'unknown',
};
} catch (e) {
return {
healthy: false,
status: 0,
latency: Date.now() - start,
error: e.message,
};
}
}
async function createHeartbeatTask(queueDbId, healthResult, token) {
const now = new Date().toISOString();
const taskName = `HEARTBEAT-${now.slice(0, 10)}-${now.slice(11, 19).replace(/:/g, '')}`;
const status = healthResult.healthy ? '✅ 通道畅通' : '❌ 通道异常';
const payload = JSON.stringify({
notion_healthy: healthResult.healthy,
notion_status: healthResult.status,
notion_latency: healthResult.latency,
notion_user: healthResult.user || '',
error: healthResult.error || '',
timestamp: now,
});
const properties = {
'任务名称': { title: titleProp(taskName) },
'类型': { select: { name: 'HEARTBEAT' } },
'处理状态': { status: { name: healthResult.healthy ? '已完成' : '待处理' } },
'来源渠道': { rich_text: richText('铸渊心跳') },
'Payload': { rich_text: richText(payload) },
};
const body = { parent: { database_id: queueDbId }, properties };
try {
const result = await notionRequest('POST', '/v1/pages', body, token);
if (result.status >= 200 && result.status < 300) {
return { success: true, page_id: result.data.id };
}
return { success: false, error: `${result.status}: ${JSON.stringify(result.data)}` };
} catch (e) {
return { success: false, error: e.message };
}
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function main() {
const token = process.env.NOTION_TOKEN;
const queueDb = process.env.BRIDGE_QUEUE_DB_ID;
console.log('💓 桥接心跳检测开始…');
console.log(`${new Date().toISOString()}`);
const heartbeat = {
timestamp: new Date().toISOString(),
notion: { healthy: false },
queue: { created: false },
};
// ① 测试 Notion API 连通性
if (token) {
console.log('\n📡 检测 Notion API 连通性…');
heartbeat.notion = await checkNotionHealth(token);
if (heartbeat.notion.healthy) {
console.log(` ✅ Notion API 正常 · 延迟 ${heartbeat.notion.latency}ms · 用户: ${heartbeat.notion.user}`);
} else {
console.error(` ❌ Notion API 异常 · 状态 ${heartbeat.notion.status} · ${heartbeat.notion.error || ''}`);
}
} else {
console.log(' ⚠️ NOTION_TOKEN 未配置,跳过 Notion 检测');
}
// ② 在调度队列创建心跳记录
if (token && queueDb) {
console.log('\n📋 写入心跳记录到调度队列…');
const queueResult = await createHeartbeatTask(queueDb, heartbeat.notion, token);
heartbeat.queue = queueResult;
if (queueResult.success) {
console.log(` ✅ 心跳记录已创建: ${queueResult.page_id}`);
} else {
console.error(` ❌ 心跳记录创建失败: ${queueResult.error}`);
}
} else {
console.log(' ⚠️ 调度队列未配置,跳过心跳记录');
}
// ③ 写入本地心跳日志
const logDir = path.join('data', 'bridge-logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logFile = path.join(logDir, `heartbeat-${new Date().toISOString().slice(0, 10)}.json`);
let logs = [];
if (fs.existsSync(logFile)) {
try { logs = JSON.parse(fs.readFileSync(logFile, 'utf8')); } catch (_) { logs = []; }
}
logs.push(heartbeat);
fs.writeFileSync(logFile, JSON.stringify(logs, null, 2));
console.log(`\n📁 心跳日志: ${logFile}`);
// ④ 总结
const allHealthy = heartbeat.notion.healthy;
console.log(`\n${allHealthy ? '✅' : '❌'} 心跳检测完成 · Notion: ${heartbeat.notion.healthy ? '正常' : '异常'}`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`notion_healthy=${heartbeat.notion.healthy}\nheartbeat_status=${allHealthy ? 'ok' : 'error'}\n`
);
}
// 心跳异常不退出失败(避免 workflow 频繁报错)
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,252 @@
// scripts/bridge/process-syslog-batch.js
// 🌉 桥接·SYSLOG 批量处理
//
// 读取 syslog-inbox/ 中的所有新 SYSLOG 文件,
// 通过 Notion API 写入回执+画像+主控台,
// 并在调度队列创建 SYSLOG_RECEIVED 任务,
// 然后移动已处理文件到 syslog-processed/
//
// 环境变量:
// NOTION_TOKEN Notion API token
// BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
// SYSLOG_DB_ID SYSLOG 收件箱数据库 ID可选有默认值
// COMMIT_SHA 当前 Git commit SHA
'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 NOTION_RICH_TEXT_MAX = 2000;
const NOTION_TITLE_MAX = 120;
const DEFAULT_SYSLOG_DB_ID = '330ab17507d542c9bbb96d0749b41197';
const INBOX_DIR = 'syslog-inbox';
const PROCESSED_DIR = 'syslog-processed';
// ══════════════════════════════════════════════════════════
// Notion API
// ══════════════════════════════════════════════════════════
function notionPost(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'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);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// 属性构建辅助
// ══════════════════════════════════════════════════════════
function richText(content) {
const str = String(content || '');
const chunks = [];
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
}
return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }];
}
function titleProp(content) {
return [{ type: 'text', text: { content: String(content || '').slice(0, NOTION_TITLE_MAX) } }];
}
// ══════════════════════════════════════════════════════════
// 文件扫描
// ══════════════════════════════════════════════════════════
function scanDir(dir, extensions) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.gitkeep' || entry.name === 'README.md') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...scanDir(fullPath, extensions));
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
results.push(fullPath);
}
}
return results;
}
// ══════════════════════════════════════════════════════════
// 核心逻辑
// ══════════════════════════════════════════════════════════
/**
* SYSLOG 写入 Notion SYSLOG 收件箱数据库
*/
async function writeSyslogToNotion(dbId, fileContent, filePath, entry, commitSha, token) {
const filename = path.basename(filePath);
const title = entry.title || filename;
const devId = entry.from || entry.dev_id || entry.header?.dev_id || '';
const ts = entry.timestamp || new Date().toISOString();
const properties = {
'标题': { title: titleProp(title) },
'文件内容': { rich_text: richText(fileContent) },
'来源路径': { rich_text: richText(filePath) },
'接收时间': { date: { start: ts } },
'处理状态': { status: { name: '待处理' } },
'commit_sha': { rich_text: richText(commitSha || '') },
'推送方': { rich_text: richText('铸渊') },
};
if (devId) {
properties['DEV编号'] = { select: { name: devId } };
}
return notionPost('/v1/pages', { parent: { database_id: dbId }, properties }, token);
}
/**
* 在调度队列创建 SYSLOG_RECEIVED 任务
*/
async function createQueueTask(queueDbId, entry, filePath, source, token) {
const devId = entry.from || entry.dev_id || entry.header?.dev_id || '';
const broadcastId = entry.broadcast_id || entry.header?.broadcast_id || '';
const taskName = `SYSLOG-${devId}-${broadcastId || path.basename(filePath, '.json')}`;
const properties = {
'任务名称': { title: titleProp(taskName) },
'类型': { select: { name: 'SYSLOG_RECEIVED' } },
'处理状态': { status: { name: '待处理' } },
'来源渠道': { rich_text: richText(source || '飞书') },
'Payload': { rich_text: richText(JSON.stringify(entry).slice(0, NOTION_RICH_TEXT_MAX)) },
};
if (devId) properties['DEV编号'] = { select: { name: devId } };
if (broadcastId) properties['广播编号'] = { rich_text: richText(broadcastId) };
return notionPost('/v1/pages', { parent: { database_id: queueDbId }, properties }, token);
}
/**
* 移动已处理文件到 syslog-processed/
*/
function moveToProcessed(filePath) {
const dateDir = new Date().toISOString().slice(0, 7); // YYYY-MM
const destDir = path.join(PROCESSED_DIR, dateDir);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const destPath = path.join(destDir, path.basename(filePath));
fs.renameSync(filePath, destPath);
return destPath;
}
// ══════════════════════════════════════════════════════════
// 主流程
// ══════════════════════════════════════════════════════════
async function main() {
const token = process.env.NOTION_TOKEN;
const syslogDb = process.env.SYSLOG_DB_ID || DEFAULT_SYSLOG_DB_ID;
const queueDb = process.env.BRIDGE_QUEUE_DB_ID;
const commitSha = process.env.COMMIT_SHA || '';
if (!token) {
console.log('⚠️ 缺少 NOTION_TOKEN跳过 SYSLOG 批处理');
process.exit(0);
}
const files = scanDir(INBOX_DIR, ['.json', '.md', '.txt']);
if (files.length === 0) {
console.log('📭 syslog-inbox/ 无待处理条目,跳过');
process.exit(0);
}
console.log(`📥 发现 ${files.length} 条 SYSLOG开始批量处理…`);
let ok = 0, failed = 0;
for (const fullPath of files) {
const relPath = fullPath.replace(/\\/g, '/');
let raw, entry;
try {
raw = fs.readFileSync(fullPath, 'utf8');
entry = fullPath.endsWith('.json') ? JSON.parse(raw) : {};
} catch (e) {
console.error(`❌ 读取 ${relPath} 失败: ${e.message}`);
failed++;
continue;
}
try {
// 写入 SYSLOG 收件箱
const page = await writeSyslogToNotion(syslogDb, raw, relPath, entry, commitSha, token);
console.log(`${relPath} → Notion SYSLOG: ${page.id}`);
// 在调度队列创建任务(如配置了队列数据库)
if (queueDb) {
const source = entry.source || entry.header?.source || '飞书';
const task = await createQueueTask(queueDb, entry, relPath, source, token);
console.log(` 📋 调度队列任务已创建: ${task.id}`);
}
// 移动到已处理目录
const destPath = moveToProcessed(fullPath);
console.log(` 📦 已归档: ${destPath}`);
ok++;
} catch (e) {
console.error(`${relPath} 处理失败: ${e.message}`);
failed++;
}
}
console.log(`\n✅ SYSLOG 批处理完成 · 成功 ${ok} 条 · 失败 ${failed}`);
// 输出到 GITHUB_OUTPUT
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`processed_count=${ok}\nfailed_count=${failed}\n`
);
}
if (failed > 0) process.exit(1);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,117 @@
// scripts/bridge/update-queue-status.js
// 🌉 桥接·更新调度队列状态
//
// 将已处理的调度队列任务状态更新为「已完成」
//
// 环境变量:
// NOTION_TOKEN Notion API token
// BRIDGE_QUEUE_DB_ID 桥接调度队列数据库 ID
// 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';
// ══════════════════════════════════════════════════════════
// Notion API
// ══════════════════════════════════════════════════════════
function notionPatch(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'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);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
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('📭 无待更新的任务,跳过');
process.exit(0);
}
const tasks = JSON.parse(fs.readFileSync(queueFile, 'utf8'));
if (tasks.length === 0) {
console.log('📭 任务列表为空,跳过');
process.exit(0);
}
console.log(`✏️ 更新 ${tasks.length} 条任务状态为「已完成」…`);
let ok = 0, failed = 0;
for (const task of tasks) {
try {
await notionPatch(`/v1/pages/${task.id}`, {
properties: {
'处理状态': { status: { name: '已完成' } },
},
}, token);
console.log(`${task.task_name} → 已完成`);
ok++;
} catch (e) {
console.error(`${task.task_name} 更新失败: ${e.message}`);
failed++;
}
}
console.log(`\n✅ 状态更新完成 · 成功 ${ok} 条 · 失败 ${failed}`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`updated_count=${ok}\n`
);
}
if (failed > 0) process.exit(1);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,181 @@
// scripts/bridge/upload-pdf.js
// 🌉 桥接·PDF 上传
//
// 将生成的 PDF 文件上传到可下载位置
// 支持多种上传方式:
// 方案A阿里云 OSS国内快
// 方案BGitHub Release Assets
// 方案C服务器目录/var/www/guanghulab/broadcasts/
//
// 环境变量:
// PDF_MANIFEST pdf-manifest.json 路径
// UPLOAD_MODE 上传模式: oss | github | server默认 server
// OSS_ACCESS_KEY 阿里云 OSS AccessKey
// OSS_SECRET_KEY 阿里云 OSS SecretKey
// OSS_BUCKET 阿里云 OSS Bucket
// OSS_REGION 阿里云 OSS Region
// SERVER_BROADCAST_DIR 服务器广播目录
'use strict';
const fs = require('fs');
const path = require('path');
const OUTPUT_DIR = path.join('data', 'broadcasts', 'pdf');
// ══════════════════════════════════════════════════════════
// 上传模式
// ══════════════════════════════════════════════════════════
/**
* 服务器本地模式复制 PDF 到指定目录
*/
async function uploadToServer(pdfFiles) {
const serverDir = process.env.SERVER_BROADCAST_DIR ||
'/var/www/guanghulab/broadcasts';
// 在 CI 环境中,只记录路径(不实际复制)
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
console.log(`☁️ 服务器模式 · 目标目录: ${serverDir}`);
const results = [];
for (const item of pdfFiles) {
const pdfPath = item.pdf;
if (!pdfPath || !fs.existsSync(pdfPath)) {
console.log(` ⚠️ PDF 不存在: ${pdfPath}`);
continue;
}
const filename = path.basename(pdfPath);
const destPath = path.join(serverDir, filename);
if (isCI) {
// CI 环境:仅记录,由部署脚本负责实际复制
const downloadUrl = `https://guanghulab.com/broadcasts/${filename}`;
results.push({ ...item, download_url: downloadUrl });
console.log(` 📋 ${filename}${downloadUrl} (CI模式·待部署)`);
} else {
try {
if (!fs.existsSync(serverDir)) {
fs.mkdirSync(serverDir, { recursive: true });
}
fs.copyFileSync(pdfPath, destPath);
const downloadUrl = `https://guanghulab.com/broadcasts/${filename}`;
results.push({ ...item, download_url: downloadUrl });
console.log(`${filename}${destPath}`);
} catch (e) {
console.error(` ❌ 复制失败: ${filename}: ${e.message}`);
}
}
}
return results;
}
/**
* GitHub 模式 PDF 提交到仓库已在 data/broadcasts/pdf/
*/
async function uploadToGitHub(pdfFiles) {
console.log(`☁️ GitHub 模式 · PDF 已在仓库 data/broadcasts/pdf/ 中`);
const results = [];
for (const item of pdfFiles) {
const pdfPath = item.pdf;
if (!pdfPath || !fs.existsSync(pdfPath)) continue;
const filename = path.basename(pdfPath);
const downloadUrl = `https://github.com/qinfendebingshuo/guanghulab/raw/main/${pdfPath}`;
results.push({ ...item, download_url: downloadUrl });
console.log(`${filename}${downloadUrl}`);
}
return results;
}
/**
* OSS 模式上传到阿里云 OSS需安装 ali-oss SDK
*/
async function uploadToOSS(pdfFiles) {
const accessKey = process.env.OSS_ACCESS_KEY;
const secretKey = process.env.OSS_SECRET_KEY;
const bucket = process.env.OSS_BUCKET || 'guanghulab-broadcasts';
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
if (!accessKey || !secretKey) {
console.log('⚠️ OSS 密钥未配置,回退到 GitHub 模式');
return uploadToGitHub(pdfFiles);
}
console.log(`☁️ OSS 模式 · Bucket: ${bucket} · Region: ${region}`);
let OSS;
try {
OSS = require('ali-oss');
} catch (e) {
console.log('⚠️ ali-oss 未安装,回退到 GitHub 模式');
return uploadToGitHub(pdfFiles);
}
const client = new OSS({ region, accessKeyId: accessKey, accessKeySecret: secretKey, bucket });
const results = [];
for (const item of pdfFiles) {
const pdfPath = item.pdf;
if (!pdfPath || !fs.existsSync(pdfPath)) continue;
const filename = path.basename(pdfPath);
const ossKey = `broadcasts/${filename}`;
try {
const result = await client.put(ossKey, pdfPath);
results.push({ ...item, download_url: result.url });
console.log(`${filename}${result.url}`);
} catch (e) {
console.error(` ❌ OSS 上传失败: ${filename}: ${e.message}`);
}
}
return results;
}
// ══════════════════════════════════════════════════════════
// 主逻辑
// ══════════════════════════════════════════════════════════
async function main() {
const manifestFile = process.env.PDF_MANIFEST ||
path.join(OUTPUT_DIR, 'pdf-manifest.json');
if (!fs.existsSync(manifestFile)) {
console.log('📭 无 pdf-manifest.json跳过上传');
process.exit(0);
}
const pdfFiles = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
if (pdfFiles.length === 0) {
console.log('📭 PDF 列表为空,跳过上传');
process.exit(0);
}
const mode = (process.env.UPLOAD_MODE || 'server').toLowerCase();
console.log(`🌉 PDF 上传 · 模式: ${mode} · 共 ${pdfFiles.length}`);
let results;
switch (mode) {
case 'oss': results = await uploadToOSS(pdfFiles); break;
case 'github': results = await uploadToGitHub(pdfFiles); break;
case 'server':
default: results = await uploadToServer(pdfFiles); break;
}
// 写入分发清单
const distManifest = path.join(OUTPUT_DIR, 'dist-manifest.json');
fs.writeFileSync(distManifest, JSON.stringify(results, null, 2));
console.log(`\n✅ 上传完成 · ${results.length} 份 · 清单: ${distManifest}`);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`uploaded_count=${results.length}\ndist_manifest=${distManifest}\n`
);
}
}
main().catch(e => { console.error(e); process.exit(1); });