🌉 BRIDGE-DEPLOY: 全链路桥接系统部署 — 脚本8个 · Workflow3个 · 飞书路由1个
Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
parent
d616a33ed0
commit
40c12c75a3
|
|
@ -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' }}"
|
||||||
|
|
@ -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' }}"
|
||||||
|
|
@ -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 闭环处理"
|
||||||
|
|
@ -4,7 +4,11 @@ const router = express.Router();
|
||||||
|
|
||||||
const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
|
const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
|
||||||
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET;
|
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 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
|
// 获取飞书 tenant_access_token
|
||||||
async function getToken() {
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
// scripts/bridge/upload-pdf.js
|
||||||
|
// 🌉 桥接·PDF 上传
|
||||||
|
//
|
||||||
|
// 将生成的 PDF 文件上传到可下载位置
|
||||||
|
// 支持多种上传方式:
|
||||||
|
// 方案A:阿里云 OSS(国内快)
|
||||||
|
// 方案B:GitHub 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); });
|
||||||
Loading…
Reference in New Issue