feat(publish): B站-智能工厂四级补贴首发归档

- drafts/ 按日期+名称分类重整
- 2026-05-09_B站首发归档至published/
- 配图6张永久存档
This commit is contained in:
小橙
2026-05-09 13:20:00 +00:00
parent 8104e1ccf2
commit 7edb53c43c
90 changed files with 5002 additions and 101 deletions

View File

@@ -0,0 +1,102 @@
---
name: bilibili-publisher
description: 生成B站视频发布版docx文档自动嵌入配图。当用户说"生成B站发布版"、"帮我生成docx"、"发B站"时触发。工作流读取drafts/下对应草稿 → AI生成6张配图存assets/ → Node.js脚本生成带图的docx → 发给用户。
---
# B站发布版生成器
将 B站 视频草稿md格式转换为**发布版 docx文档**图片直接嵌入可直接导入B站创作页面发布。
## 输入
- drafts/ 下对应的主题 md 文件(如 `2026-05-08_B站_智能工厂四级补贴.md`
- 内容形态:视频脚本,包含时间戳旁白(如 `[开场 0:000:30]`
## 输出
- `drafts/assets/` 下生成6张配图png永久存储
- `drafts/<日期>_<主题>_发布版.docx`1.1MB左右,含嵌入图片)
- 直接发 MEDIA: 路径给用户下载
## 工作流
### Step 1读取草稿
读取 `drafts/` 下对应 md 文件,提取:
- 标题(从文件名或文件内 H1 提取)
- 封面建议文字
- 视频简介 / 标签
- 脚本正文(去掉 `[时间] 旁白指令` 行)
- 分段结构H2 标题)
### Step 2生成配图
根据脚本6个段落生成6张配图
| 段落 | 图片主题 | 文件名 |
|------|---------|--------|
| 封面 | 工厂+数字仪表盘全息投影 | `bilibili_cover.png` |
| 开场 | 工厂车间暗光设备剪影 | `bilibili_opening.png` |
| 政策全景 | 四级政策图解信息图 | `bilibili_policy.png` |
| 补贴数字 | 金额对比信息图 | `bilibili_subsidy.png` |
| 四项指标 | SCADA dashboard截图 | `bilibili_indicators.png` |
| 真实案例 | 工厂中控室数据大屏 | `bilibili_case.png` |
**关键**:每张图生成后**立即用 `cp`** 复制到 `drafts/assets/`再生成下一张media 目录会被定时清理,不能存那里)
### Step 3生成 docx
使用 `scripts/gen_bilibili_docx.js`
```bash
node ~/.openclaw/workspace/skills/bilibili-publisher/scripts/gen_bilibili_docx.js \
--title "标题" \
--subtitle "封面建议文字" \
--intro "视频简介" \
--cover ./drafts/assets/bilibili_cover.png \
--opening ./drafts/assets/bilibili_opening.png \
--policy ./drafts/assets/bilibili_policy.png \
--subsidy ./drafts/assets/bilibili_subsidy.png \
--indicators ./drafts/assets/bilibili_indicators.png \
--case ./drafts/assets/bilibili_case.png \
--output ./drafts/<日期>_<主题>_发布版.docx \
--content "./drafts/assets/content.json"
```
内容通过 `content.json` 传入(避免命令行参数转义问题):
```json
{
"title": "标题",
"subtitle": "副标题",
"intro": "视频简介",
"sections": [
{
"heading": "段落小标题",
"image": "配图路径(可选)",
"paragraphs": ["段落1", "段落2"]
}
]
}
```
### Step 4发送给用户
docx 生成后,发 MEDIA: 路径给用户告知可以直接导入B站。
## docx 格式规范
- **标题**28pt加粗居中
- **副标题**14pt灰色居中
- **小标题H2**18pt加粗
- **核心观点**:加粗,带下划线或底边线
- **普通正文**14pt
- **结束语**:斜体,灰色
- **来源**12pt灰色居中
- **图片**宽度500px居中段前段后间距80
## 禁止出现的内容(发布版必须删除)
- 所有 `[时间节点] 旁白类型` 格式的行(如 `[开场 0:000:30] 悬念引入`
- 任何分镜备注、拍摄指导
- 未通过合规扫描的内容

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* gen_bilibili_docx.js
* 生成B站视频发布版docx文档
*
* 用法:
* node gen_bilibili_docx.js \
* --title "视频标题" \
* --subtitle "封面副标题" \
* --intro "视频简介和标签" \
* --output "output.docx" \
* --cover "cover.png" \
* --opening "opening.png" \
* --policy "policy.png" \
* --subsidy "subsidy.png" \
* --indicators "indicators.png" \
* --case "case.png" \
* --content-json '<JSON_STRING>'
*
* JSON_STRING 格式:
* {
* "sections": [
* { "heading": "小标题", "img": "对应图片key(cover/opening/policy/subsidy/indicators/case)", "paragraphs": ["正文1", "正文2"] }
* ]
* }
*/
const { Document, Packer, Paragraph, TextRun, ImageRun } = require('/home/node/.openclaw/node_modules/docx');
const fs = require('fs');
const args = process.argv.slice(2);
let title = '', subtitle = '', intro = '', output = '';
let coverImg = '', openingImg = '', policyImg = '', subsidyImg = '', indicatorsImg = '', caseImg = '';
let contentJson = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--title' && args[i+1]) title = args[++i];
else if (args[i] === '--subtitle' && args[i+1]) subtitle = args[++i];
else if (args[i] === '--intro' && args[i+1]) intro = args[++i];
else if (args[i] === '--output' && args[i+1]) output = args[++i];
else if (args[i] === '--cover' && args[i+1]) coverImg = args[++i];
else if (args[i] === '--opening' && args[i+1]) openingImg = args[++i];
else if (args[i] === '--policy' && args[i+1]) policyImg = args[++i];
else if (args[i] === '--subsidy' && args[i+1]) subsidyImg = args[++i];
else if (args[i] === '--indicators' && args[i+1]) indicatorsImg = args[++i];
else if (args[i] === '--case' && args[i+1]) caseImg = args[++i];
else if (args[i] === '--content-json' && args[i+1]) contentJson = args[++i];
}
if (!output) { console.error('Error: --output required'); process.exit(1); }
const imgMap = { cover: coverImg, opening: openingImg, policy: policyImg, subsidy: subsidyImg, indicators: indicatorsImg, case: caseImg };
const loadImg = (k) => { const p = imgMap[k]; return (p && fs.existsSync(p)) ? fs.readFileSync(p) : null; };
const imgs = { cover: loadImg('cover'), opening: loadImg('opening'), policy: loadImg('policy'), subsidy: loadImg('subsidy'), indicators: loadImg('indicators'), case: loadImg('case') };
const E = () => new Paragraph({ text: '' });
const TITLE = (t) => new Paragraph({ children: [new TextRun({ text: t, bold: true, size: 56 })], alignment: 'center', spacing: { before: 0, after: 160 } });
const SUB = (t) => new Paragraph({ children: [new TextRun({ text: t, size: 28, color: '666666' })], alignment: 'center', spacing: { before: 0, after: 200 } });
const H2 = (t) => new Paragraph({ children: [new TextRun({ text: t, bold: true, size: 36 })], spacing: { before: 240, after: 120 } });
const BODY = (t) => new Paragraph({ children: [new TextRun({ text: t, size: 28 })], spacing: { before: 60, after: 60 } });
const HL = (t) => new Paragraph({ children: [new TextRun({ text: t, bold: true, size: 28, color: '1A1A1A' })], spacing: { before: 120, after: 80 } });
const EMP = (t) => new Paragraph({ children: [new TextRun({ text: t, bold: true, size: 28 })], spacing: { before: 160, after: 160 }, border: { bottom: { color: 'CCCCCC', space: 1, style: 'single', size: 4 } } });
const END = (t) => new Paragraph({ children: [new TextRun({ text: t, size: 28, italics: true, color: '555555' })], spacing: { before: 80, after: 80 } });
const IMGP = (d, w, h) => d ? new Paragraph({ children: [new ImageRun({ data: d, transformation: { width: w, height: h }, type: 'png' })], alignment: 'center', spacing: { before: 80, after: 80 } }) : E();
let sections = [];
try { sections = contentJson ? JSON.parse(contentJson) : {}; } catch(e) { console.error('JSON parse error:', e.message); }
// Auto-detect which image matches a heading keyword
const headingImgMap = {
'开场': 'opening', '悬念': 'opening', '引入': 'opening',
'政策': 'policy', '全景': 'policy', '四级': 'policy',
'补贴': 'subsidy', '金额': 'subsidy', '数字': 'subsidy',
'指标': 'indicators', '门槛': 'indicators', '硬指标': 'indicators',
'案例': 'case', '真实': 'case', 'SCADA': 'case',
};
const detectImg = (heading) => {
if (!heading) return null;
for (const [kw, imgKey] of Object.entries(headingImgMap)) {
if (heading.includes(kw)) return imgs[imgKey];
}
return null;
};
const children = [];
// Cover
children.push(IMGP(imgs.cover, 560, 315));
children.push(E());
children.push(TITLE(title));
if (subtitle) children.push(SUB(subtitle));
if (intro) { const introPara = new Paragraph({ children: [new TextRun({ text: intro, size: 22, color: '0077CC' })], alignment: 'center', spacing: { before: 0, after: 200 } }); children.push(introPara); }
children.push(E());
// Sections
if (sections.sections && Array.isArray(sections.sections)) {
for (const sec of sections.sections) {
if (sec.heading) children.push(H2(sec.heading));
const secImg = sec.img ? imgs[sec.img] : detectImg(sec.heading);
if (secImg) { children.push(IMGP(secImg, 480, 270)); children.push(E()); }
if (sec.paragraphs && Array.isArray(sec.paragraphs)) {
for (const p of sec.paragraphs) {
if (!p || p.trim() === '') { children.push(E()); continue; }
const clean = p.trim();
if (clean.length < 60 && (clean.includes('——') || clean.match(/^[①②③④]/))) {
children.push(BODY(clean));
} else if (clean.length < 80 && !clean.includes('。')) {
children.push(BODY(clean));
} else if (clean.startsWith('') || clean.startsWith('')) {
children.push(HL(clean));
} else {
children.push(BODY(clean));
}
}
}
children.push(E());
}
}
// Source
children.push(new Paragraph({
children: [new TextRun({ text: '内容来源上海橙轩智能Orpaon· 制造业数字化解决方案 | 官网www.orpaon.com', size: 20, color: '999999' })],
alignment: 'center', spacing: { before: 200, after: 0 },
}));
const doc = new Document({ sections: [{ children }] });
Packer.toBuffer(doc).then(buf => {
fs.writeFileSync(output, buf);
console.log('done');
}).catch(e => { console.error(e); process.exit(1); });