import fs from "node:fs/promises"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const ROOT = path.resolve("E:/workspace/openclaw-voc-skill"); const OUT_DIR = path.join(ROOT, "docs", "voc-skill-student-course"); const WORKSPACE = path.join( ROOT, "outputs", process.env.CODEX_THREAD_ID || `manual-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}`, "presentations", "voc-community-course-two-lessons", ); const PREVIEW_DIR = path.join(WORKSPACE, "preview"); const LAYOUT_DIR = path.join(WORKSPACE, "layout"); const QA_DIR = path.join(WORKSPACE, "qa"); const NODE_HOME = process.env.USERPROFILE || process.env.HOME; const ARTIFACT_TOOL = path.join( NODE_HOME, ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "node", "node_modules", "@oai", "artifact-tool", "dist", "artifact_tool.mjs", ); const PYTHON = process.env.PYTHON || path.join( NODE_HOME, ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python", "python.exe", ); const MAKE_CONTACT_SHEET = path.join( NODE_HOME, ".codex", "plugins", "cache", "openai-primary-runtime", "presentations", "26.601.10930", "skills", "presentations", "scripts", "make_contact_sheet.py", ); const W = 1280; const H = 720; const palette = { porcelain: "#F8FBFF", paper: "#FFFFFF", ink: "#133B68", ink2: "#0A2440", blue: "#1E68A8", mist: "#E8F2FC", mist2: "#F1F7FC", jade: "#1F7A72", jadeSoft: "#DDF2EE", cinnabar: "#C9513B", cinnabarSoft: "#F8E5DE", gold: "#B88932", charcoal: "#13202B", muted: "#5F7082", line: "#C7D9EA", dark: "#0F1A24", }; const FONT = { title: "Microsoft YaHei UI", body: "Microsoft YaHei", mono: "Cascadia Mono", }; function line(fill = "#00000000", width = 0, style = "solid") { return { style, fill, width }; } function addShape(slide, options) { const { geometry = "rect", fill = "#00000000", border = "#00000000", borderWidth = 0, left, top, width, height, name, } = options; return slide.shapes.add({ geometry, name, position: { left, top, width, height }, fill, line: line(border, borderWidth), }); } function addText(slide, options) { const { text, left, top, width, height, size = 24, color = palette.charcoal, bold = false, typeface = FONT.body, align = "left", valign = "top", fill = "#00000000", border = "#00000000", borderWidth = 0, insets = { left: 0, right: 0, top: 0, bottom: 0 }, } = options; const shape = addShape(slide, { left, top, width, height, fill, border, borderWidth, }); shape.text = text; shape.text.fontSize = size; shape.text.color = color; shape.text.bold = bold; shape.text.typeface = typeface; shape.text.alignment = align; shape.text.verticalAlignment = valign; shape.text.insets = insets; return shape; } function addFooter(slide, deckLabel, index, total) { addText(slide, { text: deckLabel, left: 64, top: 668, width: 600, height: 22, size: 13, color: palette.muted, }); addText(slide, { text: `${String(index).padStart(2, "0")} / ${String(total).padStart(2, "0")}`, left: 1112, top: 666, width: 100, height: 24, size: 14, color: palette.muted, typeface: FONT.mono, align: "right", }); } function addTitle(slide, title, kicker, subtitle) { if (kicker) { addText(slide, { text: kicker, left: 64, top: 46, width: 680, height: 28, size: 16, bold: true, color: palette.jade, }); } addText(slide, { text: title, left: 64, top: kicker ? 82 : 58, width: 860, height: 88, size: 38, bold: true, color: palette.ink2, typeface: FONT.title, }); if (subtitle) { addText(slide, { text: subtitle, left: 66, top: 174, width: 780, height: 44, size: 18, color: palette.muted, }); } } function bg(slide, tone = "light") { addShape(slide, { left: 0, top: 0, width: W, height: H, fill: tone === "dark" ? palette.ink2 : palette.porcelain }); if (tone !== "dark") { addShape(slide, { left: 0, top: 0, width: 24, height: H, fill: palette.ink }); addShape(slide, { left: 24, top: 0, width: 6, height: H, fill: palette.jade }); } } function cover(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide, "light"); addShape(slide, { left: 760, top: 0, width: 520, height: H, fill: spec.accent || palette.ink }); addShape(slide, { left: 820, top: 78, width: 330, height: 330, fill: "#FFFFFF12", border: "#FFFFFF22", borderWidth: 1 }); addShape(slide, { left: 880, top: 138, width: 210, height: 210, fill: "#FFFFFF12", border: "#FFFFFF2A", borderWidth: 1 }); addText(slide, { text: spec.kicker, left: 70, top: 78, width: 560, height: 30, size: 17, bold: true, color: palette.jade, }); addText(slide, { text: spec.title, left: 68, top: 128, width: 610, height: 170, size: 54, bold: true, color: palette.ink2, typeface: FONT.title, }); addText(slide, { text: spec.subtitle, left: 72, top: 330, width: 585, height: 72, size: 23, color: palette.charcoal, }); addText(slide, { text: spec.rightTitle, left: 820, top: 468, width: 330, height: 64, size: 32, bold: true, color: palette.paper, typeface: FONT.title, }); addText(slide, { text: spec.rightText, left: 822, top: 545, width: 330, height: 84, size: 18, color: "#D8EAF8", }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function agenda(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const y = 260; const gap = 26; const w = (1088 - gap * (spec.items.length - 1)) / spec.items.length; spec.items.forEach((item, i) => { const x = 76 + i * (w + gap); addShape(slide, { left: x, top: y, width: w, height: 245, fill: item.fill || palette.paper, border: palette.line, borderWidth: 1 }); addText(slide, { text: `0${i + 1}`, left: x + 24, top: y + 22, width: 90, height: 58, size: 48, bold: true, color: item.accent || palette.blue, typeface: FONT.mono, }); addText(slide, { text: item.title, left: x + 24, top: y + 96, width: w - 48, height: 54, size: 25, bold: true, color: palette.ink2, typeface: FONT.title, }); addText(slide, { text: item.text, left: x + 24, top: y + 164, width: w - 48, height: 58, size: 17, color: palette.muted, }); }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function claim(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide, spec.dark ? "dark" : "light"); const c = spec.dark ? palette.paper : palette.ink2; const muted = spec.dark ? "#C8D9E8" : palette.muted; addText(slide, { text: spec.kicker || "", left: 72, top: 56, width: 460, height: 28, size: 16, bold: true, color: spec.dark ? palette.jadeSoft : palette.jade }); addText(slide, { text: spec.title, left: 70, top: 96, width: 740, height: 150, size: spec.titleSize || 46, bold: true, color: c, typeface: FONT.title }); addText(slide, { text: spec.text, left: 74, top: 252, width: 600, height: 88, size: 22, color: muted }); addShape(slide, { left: 770, top: 112, width: 360, height: 360, fill: spec.blockFill || (spec.dark ? "#FFFFFF10" : palette.paper), border: spec.dark ? "#FFFFFF22" : palette.line, borderWidth: 1 }); addText(slide, { text: spec.proofTitle, left: 812, top: 158, width: 276, height: 50, size: 27, bold: true, color: c, typeface: FONT.title, align: "center" }); addText(slide, { text: spec.proofText, left: 820, top: 232, width: 260, height: 164, size: 20, color: muted, align: "center", valign: "mid" }); addShape(slide, { left: 812, top: 426, width: 276, height: 8, fill: spec.accent || palette.cinnabar }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function compare(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const colW = spec.columns.length === 2 ? 510 : 340; const startX = spec.columns.length === 2 ? 108 : 86; const gap = spec.columns.length === 2 ? 42 : 28; spec.columns.forEach((col, i) => { const x = startX + i * (colW + gap); addShape(slide, { left: x, top: 232, width: colW, height: 348, fill: col.fill || palette.paper, border: col.border || palette.line, borderWidth: 1 }); addShape(slide, { left: x, top: 232, width: colW, height: 12, fill: col.accent || palette.blue }); addText(slide, { text: col.title, left: x + 28, top: 270, width: colW - 56, height: 48, size: 28, bold: true, color: col.accent || palette.ink2, typeface: FONT.title }); addText(slide, { text: col.body, left: x + 30, top: 340, width: colW - 60, height: 155, size: 19, color: palette.charcoal }); addText(slide, { text: col.bottom, left: x + 30, top: 518, width: colW - 60, height: 36, size: 17, color: palette.muted }); }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function flow(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const n = spec.steps.length; const startX = 78; const y = 278; const boxW = (1090 - (n - 1) * 18) / n; spec.steps.forEach((step, i) => { const x = startX + i * (boxW + 18); addShape(slide, { left: x, top: y, width: boxW, height: 190, fill: step.fill || palette.paper, border: palette.line, borderWidth: 1 }); addText(slide, { text: String(i + 1).padStart(2, "0"), left: x + 18, top: y + 18, width: 60, height: 32, size: 22, bold: true, color: step.accent || palette.blue, typeface: FONT.mono }); addText(slide, { text: step.title, left: x + 18, top: y + 64, width: boxW - 36, height: 56, size: 21, bold: true, color: palette.ink2 }); addText(slide, { text: step.text, left: x + 18, top: y + 126, width: boxW - 36, height: 40, size: 14, color: palette.muted }); if (i < n - 1) { addText(slide, { text: "→", left: x + boxW + 2, top: y + 70, width: 18, height: 28, size: 24, bold: true, color: palette.line, align: "center" }); } }); if (spec.bottom) { addText(slide, { text: spec.bottom, left: 100, top: 530, width: 1040, height: 40, size: 20, bold: true, color: palette.cinnabar, align: "center" }); } addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function matrix(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const x = 76; const y = 225; const w = 1120; const rowH = 66; const colW = [210, 290, 310, 310]; addShape(slide, { left: x, top: y, width: w, height: rowH, fill: palette.ink, border: palette.ink, borderWidth: 1 }); spec.headers.forEach((h, i) => { const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0); addText(slide, { text: h, left: left + 16, top: y + 18, width: colW[i] - 32, height: 28, size: 17, bold: true, color: palette.paper }); }); spec.rows.forEach((row, r) => { const top = y + rowH * (r + 1); addShape(slide, { left: x, top, width: w, height: rowH, fill: r % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 }); row.forEach((cell, i) => { const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0); addText(slide, { text: cell, left: left + 14, top: top + 10, width: colW[i] - 28, height: rowH - 24, size: i === 0 ? 16 : 14, bold: i === 0, color: i === 0 ? palette.ink2 : palette.charcoal }); }); }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function prompt(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); addShape(slide, { left: 82, top: 230, width: 1110, height: 350, fill: palette.dark, border: "#27394A", borderWidth: 1 }); addShape(slide, { left: 82, top: 230, width: 1110, height: 50, fill: "#162A3D" }); addShape(slide, { left: 106, top: 246, width: 12, height: 12, fill: palette.cinnabar }); addShape(slide, { left: 128, top: 246, width: 12, height: 12, fill: palette.gold }); addShape(slide, { left: 150, top: 246, width: 12, height: 12, fill: palette.jade }); addText(slide, { text: spec.label || "Prompt / Command", left: 184, top: 241, width: 400, height: 22, size: 14, color: "#AFC6D8", typeface: FONT.mono }); addText(slide, { text: spec.code, left: 112, top: 298, width: 1028, height: 248, size: spec.size || 18, color: "#ECF5FF", typeface: FONT.mono }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function checklist(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const x = 92; let y = 230; spec.items.forEach((item, i) => { addShape(slide, { left: x, top: y, width: 1040, height: 58, fill: i % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 }); addText(slide, { text: item[0], left: x + 22, top: y + 15, width: 215, height: 26, size: 18, bold: true, color: palette.ink2 }); addText(slide, { text: item[1], left: x + 265, top: y + 15, width: 420, height: 28, size: 17, color: palette.charcoal }); addText(slide, { text: item[2], left: x + 720, top: y + 15, width: 290, height: 28, size: 16, color: palette.jade, bold: true }); y += 66; }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function cards(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide); addTitle(slide, spec.title, spec.kicker, spec.subtitle); const cols = spec.cards.length === 4 ? 4 : 3; const gap = 24; const cardW = (1094 - gap * (cols - 1)) / cols; spec.cards.forEach((card, i) => { const row = Math.floor(i / cols); const col = i % cols; const x = 76 + col * (cardW + gap); const y = 230 + row * 168; addShape(slide, { left: x, top: y, width: cardW, height: 142, fill: card.fill || palette.paper, border: palette.line, borderWidth: 1 }); addText(slide, { text: card.title, left: x + 24, top: y + 22, width: cardW - 48, height: 34, size: 21, bold: true, color: card.accent || palette.ink2 }); addText(slide, { text: card.text, left: x + 24, top: y + 68, width: cardW - 48, height: 52, size: 16, color: palette.muted }); }); if (spec.note) { addText(slide, { text: spec.note, left: 96, top: 596, width: 1040, height: 28, size: 18, bold: true, color: palette.cinnabar, align: "center" }); } addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } function section(presentation, spec, ctx) { const slide = presentation.slides.add(); bg(slide, "dark"); addText(slide, { text: spec.number, left: 74, top: 78, width: 200, height: 90, size: 74, bold: true, color: palette.jadeSoft, typeface: FONT.mono }); addText(slide, { text: spec.title, left: 78, top: 210, width: 760, height: 120, size: 48, bold: true, color: palette.paper, typeface: FONT.title }); addText(slide, { text: spec.text, left: 82, top: 360, width: 690, height: 72, size: 23, color: "#CADBEA" }); addShape(slide, { left: 880, top: 120, width: 240, height: 420, fill: "#FFFFFF0F", border: "#FFFFFF22", borderWidth: 1 }); addText(slide, { text: spec.side, left: 910, top: 206, width: 180, height: 230, size: 30, bold: true, color: palette.paper, align: "center", valign: "mid" }); addFooter(slide, spec.footer, ctx.index, ctx.total); return slide; } const theorySlides = [ { type: "cover", kicker: "理论课 01", title: "VOC 不是抓数据", subtitle: "它是一条从用户声音到经营判断、内容动作和复盘指标的证据链。", rightTitle: "社区课定位", rightText: "免费技能用于 OPC / 测试\n付费技能用于企业级稳定交付", footer: "VOC 理论课", }, { type: "agenda", kicker: "本节学习路线", title: "先建立判断框架,再进入工具", subtitle: "学员要先知道为什么采、采什么、采完怎么判断。", items: [ { title: "为什么", text: "从经验判断转向证据判断", accent: palette.ink }, { title: "看什么", text: "平台声音、评论追问和转化阻力", accent: palette.jade }, { title: "怎么分层", text: "OPC / 测试 / 企业级场景", accent: palette.cinnabar }, { title: "交付什么", text: "问题池、行动计划、报告", accent: palette.gold }, ], footer: "VOC 理论课", }, { type: "claim", kicker: "课程开场问题", title: "老板真正缺的不是更多内容,而是可验证的判断", text: "如果没有用户证据,短视频脚本、产品建议和活动方案都会变成拍脑袋。", proofTitle: "证据链", proofText: "用户原话\n→ 问题聚类\n→ 优先级\n→ 动作\n→ 指标复盘", accent: palette.cinnabar, footer: "VOC 理论课", }, { type: "compare", kicker: "概念纠偏", title: "VOC 和普通市场分析的区别", subtitle: "这页要让学员明白:VOC 的核心不是结论漂亮,而是能追溯。", columns: [ { title: "普通分析", body: "先写观点,再找行业常识支撑。\n容易出现:空泛、像模板、无法落地。", bottom: "结果:看完觉得对,但不知道明天改什么。", accent: palette.muted, fill: palette.mist2, }, { title: "VOC 分析", body: "先抓用户表达,再归纳问题和阻力。\n每个建议都能回到样本、评论、关键词。", bottom: "结果:能做动作、能验证、能复盘。", accent: palette.jade, fill: palette.paper, }, ], footer: "VOC 理论课", }, { type: "section", number: "01", title: "先讲清业务场景", text: "采集前不先问平台,而是先问:这次要改善哪个经营结果?", side: "业务问题\n决定数据质量", footer: "VOC 理论课", }, { type: "matrix", kicker: "采集前置条件", title: "一条合格 VOC 任务,至少要有 4 个输入", subtitle: "输入越像业务问题,输出越像经营情报;输入越像泛泛关键词,输出越像闲聊。", headers: ["输入", "茶饮案例", "为什么重要", "换行业怎么填"], rows: [ ["业务目标", "提升新客转化", "决定报告回答什么", "咨询转化 / 复购 / 客诉"], ["决策门槛", "18-22 元价格带", "决定用户比较方式", "客单价 / 合同金额 / 使用成本"], ["核心问题", "值不值、踩不踩雷", "决定关键词和判断口径", "怕增项 / 怕无效 / 怕售后"], ["用户原话", "茶底是什么?我要避雷", "防止凭经验发散", "评论、私信、差评、问答"], ], footer: "VOC 理论课", }, { type: "flow", kicker: "VOC 小闭环", title: "不是采完就结束,而是跑完一轮证据闭环", subtitle: "这页是整门课的主流程,后面的实操课全部围绕它展开。", steps: [ { title: "定义问题", text: "业务目标 + 用户阻力", accent: palette.ink }, { title: "采集矩阵", text: "平台、人群、关键词", accent: palette.blue }, { title: "真实采集", text: "样本、评论、链接", accent: palette.jade }, { title: "问题池", text: "严重度和优先级", accent: palette.gold }, { title: "单点深挖", text: "动作和验证指标", accent: palette.cinnabar }, { title: "交付复盘", text: "内容、报告、补采", accent: palette.ink2 }, ], bottom: "每一环都要能回到用户证据,而不是回到模型想象。", footer: "VOC 理论课", }, { type: "compare", kicker: "技能组合定位", title: "免费技能和付费技能,不是互相替代", subtitle: "它们服务于不同阶段:学习验证 vs 企业级稳定交付。", columns: [ { title: "免费 / 开源技能", body: "适合 OPC、教学演示、小范围测试。\n重点是理解流程、验证方法、低成本起步。", bottom: "判断标准:能跑通,能形成初步洞察。", accent: palette.blue, fill: palette.mist2, }, { title: "付费 VOC 技能", body: "适合稳定采集、大批量样本、企业报告。\n重点是权限、余额、错误处理、审计和复用。", bottom: "判断标准:能稳定交付,能复盘追溯。", accent: palette.cinnabar, fill: palette.paper, }, ], footer: "VOC 理论课", }, { type: "cards", kicker: "企业级场景为什么需要付费能力", title: "专业 VOC 交付要解决 6 件免费工具常常忽略的事", subtitle: "这页用于解释商业边界,不要讲成“免费不好”,而是讲“使用场景不同”。", cards: [ { title: "稳定采集", text: "接口失败、限流、重试和余额状态可解释", accent: palette.cinnabar }, { title: "批量样本", text: "多关键词、多平台、持续补采", accent: palette.ink }, { title: "证据审计", text: "样本、评论、链接、批次能追溯", accent: palette.jade }, { title: "错误修复", text: "关键词、token、余额和平台异常有明确下一步", accent: palette.gold }, { title: "报告交付", text: "从趋势、问题池到 HTML 报告可复用", accent: palette.blue }, { title: "团队协作", text: "老板、运营、内容、产品都能读懂", accent: palette.cinnabar }, ], note: "企业级价值不是“多一个工具”,而是减少采集和交付过程中的不确定性。", footer: "VOC 理论课", }, { type: "section", number: "02", title: "以茶饮新客转化为统一案例", text: "所有方法都要落回一个具体业务:18-22 元价格带,如何降低新客首单决策成本。", side: "值不值\n会不会踩雷\n怎么点最稳", footer: "VOC 理论课", }, { type: "claim", kicker: "案例洞察", title: "18-22 元不是绝对贵,真正阻力是看不懂值在哪", titleSize: 42, text: "用户会比较套餐、券后价、自制成本、茶底、甜度、配料表和外卖到手体验。", proofTitle: "用户追问", proofText: "茶底是什么?\n甜度怎么选?\n买一送一有没有坑?\n外卖到手会翻车吗?", accent: palette.jade, footer: "VOC 理论课", }, { type: "matrix", kicker: "平台分工", title: "抖音和小红书看到的用户心态不同", subtitle: "同一个业务问题,需要用不同平台补齐决策链路。", headers: ["平台", "更像什么", "重点看什么", "转成什么动作"], rows: [ ["小红书", "做功课和避坑", "值不值、茶底、甜度、配料", "解释型笔记、避坑指南"], ["抖音", "触发注意和下单", "低价新品、团购、买一送一", "短视频钩子、活动规则"], ["评论区", "转化前验真", "怎么点、多少钱、会不会踩雷", "置顶回复、统一话术"], ["门店/外卖", "体验兑现", "出杯、排队、到手口感", "流程提示、外卖稳定款"], ], footer: "VOC 理论课", }, { type: "cards", kicker: "报告不等于策略口号", title: "一份合格 VOC 报告至少包含 6 类内容", subtitle: "后面的实战课会把每一类都跑出来。", cards: [ { title: "数据口径", text: "平台、关键词、样本数、评论数", accent: palette.ink }, { title: "显著问题", text: "用户反复表达的阻力", accent: palette.cinnabar }, { title: "证据样本", text: "原话、链接、互动和截图", accent: palette.jade }, { title: "优先级", text: "按业务影响排序,不只看词频", accent: palette.gold }, { title: "行动计划", text: "今天、7 天、长期沉淀", accent: palette.blue }, { title: "验证指标", text: "评论变化、咨询、下单、复购", accent: palette.cinnabar }, ], footer: "VOC 理论课", }, { type: "claim", kicker: "学员最终要带走的能力", title: "看到一条评论,就能判断它该进入哪个经营环节", text: "不是把评论复制给 AI,而是识别它影响产品、价格、内容、服务还是信任。", proofTitle: "判断题", proofText: "“我要避雷”\n不是情绪词\n而是首单风险信号", accent: palette.cinnabar, dark: true, footer: "VOC 理论课", }, { type: "checklist", kicker: "理论课收尾", title: "进入实战课前,学员需要记住 5 个标准", subtitle: "这页可以作为第一节课的课后检查。", items: [ ["先业务", "先写经营目标,再写关键词", "避免乱采"], ["先 sample", "先跑通格式,再 live 采集", "降低卡点"], ["有证据", "判断必须能回到原话或样本", "防止空泛"], ["分场景", "免费用于测试,付费用于稳定交付", "边界清楚"], ["可验证", "每个建议都要有 7 天指标", "能复盘"], ], footer: "VOC 理论课", }, { type: "section", number: "NEXT", title: "下一节:从 FmodeStudio 开始实操", text: "创建项目模板,安装技能,跑 sample,进入 live,生成问题池、脚本和 HTML 报告。", side: "打开工具\n开始交付", footer: "VOC 理论课", }, ]; const practicalSlides = [ { type: "cover", kicker: "实战课 02", title: "FmodeStudio VOC 实操", subtitle: "从技能安装、项目模板创建,到小红书/抖音采集和 HTML 报告交付。", rightTitle: "两段演示", rightText: "免费技能安装与演示\n付费 VOC 技能企业级展示", accent: palette.jade, footer: "VOC 实战课", }, { type: "agenda", kicker: "本节产出", title: "学员跟完后,要拿到一套能复用的项目样板", subtitle: "不是只看老师演示,而是知道自己下一次怎么换行业复跑。", items: [ { title: "建项目", text: "FmodeStudio + 项目模板", accent: palette.ink }, { title: "装技能", text: "免费技能与付费技能两条线", accent: palette.blue }, { title: "跑采集", text: "sample、矩阵、live 小规模", accent: palette.jade }, { title: "交付", text: "问题池、内容计划、HTML 报告", accent: palette.cinnabar }, ], footer: "VOC 实战课", }, { type: "flow", kicker: "完整演示路径", title: "实操课按 8 个动作推进", subtitle: "每一步都有截图点和通过标准,方便课程发布后学员独立复盘。", steps: [ { title: "打开 Studio", text: "创建课程项目" }, { title: "选模板", text: "VOC 项目结构" }, { title: "装免费技能", text: "跑通 OPC/测试" }, { title: "装付费技能", text: "workspace smoke" }, { title: "sample", text: "先看格式" }, { title: "live", text: "小规模真实采集" }, ], bottom: "先证明流程可用,再追求样本规模;先小范围验证,再进入企业级采集。", footer: "VOC 实战课", }, { type: "checklist", kicker: "课前准备", title: "开始前先检查这 5 项", subtitle: "这页是实操课开场,不要等出错了再补环境。", items: [ ["FmodeStudio", "能打开并创建项目", "通过"], ["Claude Code", "能在项目内正常对话", "通过"], ["Node / npx", "终端能返回版本号", "通过"], ["截图文件夹", "为每个步骤留空截图", "通过"], [".env.local", "如有 token,不展示真实值", "通过"], ], footer: "VOC 实战课", }, { type: "matrix", kicker: "项目模板", title: "VOC 项目模板建议包含这些文件夹", subtitle: "FmodeStudio 里创建项目后,学员能用这个结构沉淀材料。", headers: ["目录", "放什么", "为什么需要", "截图点"], rows: [ ["01-inputs", "业务背景、关键词、用户原话", "防止忘记输入口径", "项目初始化"], ["02-samples", "sample 输出和第一轮观察", "先跑通流程", "sample 结果"], ["03-live-data", "live 样本、评论、链接", "保留真实证据", "工具输出"], ["04-analysis", "矩阵、问题池、深挖", "形成经营判断", "问题排序"], ["05-delivery", "脚本、HTML、复盘指标", "交付给老板/团队", "最终报告"], ], footer: "VOC 实战课", }, { type: "section", number: "A", title: "免费技能安装与演示", text: "免费技能用于让学员理解技能调用、OPC 和小范围验证,不承诺企业级稳定采集。", side: "学习\n测试\n验证", footer: "VOC 实战课", }, { type: "cards", kicker: "免费技能演示口径", title: "免费技能这一段要演示 4 件事", subtitle: "这里不要过度承诺,只让学员看懂技能如何进入业务流程。", cards: [ { title: "找到技能", text: "在 FmodeStudio / Claude Code 中确认技能入口", accent: palette.blue }, { title: "安装或启用", text: "按该免费技能的 README 操作", accent: palette.jade }, { title: "跑 sample", text: "先用课程案例或内置样例输出", accent: palette.gold }, { title: "读结果", text: "看是否有证据、边界和下一步", accent: palette.cinnabar }, ], note: "如果免费技能名单暂未确定,这一页保留为“替换实际开源技能名称和链接”的通用演示页。", footer: "VOC 实战课", }, { type: "prompt", kicker: "免费技能演示 Prompt", title: "先让 Claude Code 明确使用当前免费技能", subtitle: "这段用于 OPC/测试,不进入大批量 live 采集。", label: "复制给 Claude Code", code: `请使用当前项目中已安装的免费 / 开源 VOC 技能,先不要做大批量真实采集。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:值不值、会不会踩雷、首单怎么点最稳。\n\n请先用 sample 或小范围测试数据输出:\n1. 第一轮初步判断\n2. 证据样本\n3. 用户顾虑\n4. 数据边界\n5. 下一步需要真实采集验证的问题`, size: 16, footer: "VOC 实战课", }, { type: "compare", kicker: "免费技能的边界", title: "演示结束后,要把边界讲清楚", subtitle: "让学员知道什么时候免费技能够用,什么时候需要企业级能力。", columns: [ { title: "够用的场景", body: "学习 VOC 方法\nOPC / 概念验证\n小样本跑流程\n检查 prompt 是否合理", bottom: "目标:理解流程和判断口径", accent: palette.jade, fill: palette.mist2, }, { title: "不建议硬撑的场景", body: "客户交付\n多平台批量采集\n长期复盘\n需要审计和错误处理", bottom: "目标:稳定、可追溯、可复用", accent: palette.cinnabar, fill: palette.paper, }, ], footer: "VOC 实战课", }, { type: "section", number: "B", title: "付费 VOC 技能企业级展示", text: "这一段演示正式安装、token、sample、live 和错误处理,让企业客户看到稳定交付链路。", side: "稳定\n批量\n审计", footer: "VOC 实战课", }, { type: "prompt", kicker: "付费技能安装", title: "在项目工作区安装 VOC 技能包", subtitle: "这一页要让学员知道:安装发生在当前项目,不是口头说“我有技能”。", label: "PowerShell", code: `npx --yes @vocmarket/voc-skill@latest workspace --smoke\n\n# 安装后检查\n# .\\.mcp.json\n# .\\.claude\\plugins\\voc-intelligence\n# .\\.claude\\skills\\xiaohongshu-trend-intelligence\n# .\\.claude\\skills\\douyin-trend-intelligence`, footer: "VOC 实战课", }, { type: "checklist", kicker: "安装通过标准", title: "看到这些结果,才算安装成功", subtitle: "不要只看命令跑完,要看项目里是否写入了技能和 MCP 配置。", items: [ ["smoke 通过", "终端没有报错,提示 ready", "截图"], [".mcp.json", "工作区根目录生成 MCP 配置", "截图"], ["plugins", "出现 voc-intelligence 插件目录", "截图"], ["skills", "出现小红书和抖音技能说明", "截图"], ["Claude Code", "重启后能触发 VOC 技能", "截图"], ], footer: "VOC 实战课", }, { type: "prompt", kicker: "token 配置", title: "live 采集前,先配置 token,但不要展示真实值", subtitle: "课程截图里可以展示文件名,不展示 token 内容。", label: ".env.local", code: `VOC_TOKEN=你的服务 token\n\n# 注意:\n# 1. 不要提交到 Git\n# 2. 不要放进截图\n# 3. 不要发到聊天记录\n# 4. token 异常时先做 token / 余额预检`, footer: "VOC 实战课", }, { type: "prompt", kicker: "sample 首跑", title: "先用 sample 跑通输出格式", subtitle: "这一步的目标不是拿真实结论,而是确认技能调用、结构和报告阅读方式。", label: "复制给 Claude Code", code: `请使用已安装的 VOC 技能,不要凭经验回答。\n\n帮我做一份茶饮新客转化的小红书趋势情报。\n先用 sample 模式跑通流程,不要真实采集。\n业务目标:提升新客转化。\n价格带:18-22 元。\n重点看:值不值、会不会踩雷、首单怎么点最稳。\n\n请输出第一轮初步判断、样本观察、用户顾虑、证据样本和待确认问题。`, size: 17, footer: "VOC 实战课", }, { type: "matrix", kicker: "采集矩阵", title: "进入 live 前,先写采集矩阵", subtitle: "矩阵让采集从“乱搜关键词”变成“围绕业务问题取证”。", headers: ["字段", "本案例填写", "合格标准", "截图点"], rows: [ ["平台", "小红书 / 抖音", "平台分开,不混写", "矩阵表头"], ["目标人群", "茶饮新客、价格敏感用户", "必须对应转化目标", "人群列"], ["关键词", "券后价、首单、避坑、茶底", "像用户会搜索的话", "关键词列"], ["样本量", "小规模先跑", "不要第一轮全量", "样本量列"], ["判断标准", "是否影响首单转化", "能判断是否深挖", "标准列"], ], footer: "VOC 实战课", }, { type: "prompt", kicker: "live 小规模采集", title: "真实采集先小规模,不要一上来全量", subtitle: "这段是正式演示付费技能价值:平台、关键词、样本数、评论数和错误处理。", label: "复制给 Claude Code", code: `请进入真实采集 live 模式。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n平台:小红书、抖音。\n采集范围:先小规模采集,不要全量。\n\n重点关键词:\n小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度\n抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑\n\n请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。`, size: 15, footer: "VOC 实战课", }, { type: "cards", kicker: "采集结果怎么读", title: "live 结果出来后,不要立刻写最终策略", subtitle: "先判断数据质量,再决定是否补采。", cards: [ { title: "样本数", text: "看笔记/视频数量是否足够", accent: palette.ink }, { title: "评论数", text: "看是否有真实追问", accent: palette.jade }, { title: "显著问题", text: "看是否集中到转化阻力", accent: palette.cinnabar }, { title: "证据样本", text: "看能否回到原帖/原视频", accent: palette.gold }, { title: "补采词", text: "看下一轮该补什么", accent: palette.blue }, { title: "边界", text: "低样本只写阶段性信号", accent: palette.cinnabar }, ], footer: "VOC 实战课", }, { type: "flow", kicker: "从采集到交付", title: "后半段把结果转成 4 个交付物", subtitle: "学员看到这里要知道:采集只是开始,交付才是课程成果。", steps: [ { title: "问题池", text: "Top 3 转化阻力" }, { title: "单点深挖", text: "价格/性价比" }, { title: "内容计划", text: "7 天短视频脚本" }, { title: "HTML 报告", text: "老板/运营可读" }, ], bottom: "每个交付物都要有:证据、动作、指标。", footer: "VOC 实战课", }, { type: "prompt", kicker: "问题池 Prompt", title: "把采集结果整理成 VOC 问题池", subtitle: "问题池要按业务影响排序,不要只按词频排序。", label: "复制给 Claude Code", code: `请把上面的采集结果整理成 VOC 问题池。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n重点影响:新客转化、客单结构、评论信任。\n\n要求:\n1. 按业务影响排序,不要只按词频排序\n2. 每个问题都要有用户证据\n3. 标注影响环节\n4. 给出建议动作\n5. 输出 Top 3 问题和下一步是否适合深挖`, size: 17, footer: "VOC 实战课", }, { type: "prompt", kicker: "单点深挖 Prompt", title: "选择 Top 1:价格/性价比", subtitle: "不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。", label: "复制给 Claude Code", code: `请继续深挖这个 VOC 问题:价格/性价比。\n\n业务背景:茶饮连锁,产品定价在 18-22 元区间。\n主要目标:提升新客转化。\n\n证据:\n1. 探店内容别只拍氛围,评论区最关心值不值\n2. 茶底是什么?我要避雷\n3. 低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但仍需解释规则、口味和是否有坑。\n\n请输出表层问题、深层问题、今天动作、7 天验证动作、评论区回复方向和验证指标。`, size: 15, footer: "VOC 实战课", }, { type: "prompt", kicker: "内容计划 Prompt", title: "把 VOC 问题变成 7 天短视频计划", subtitle: "每条内容都必须绑定真实用户问题,不写泛泛爆款标题。", label: "复制给 Claude Code", code: `请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。\n\n每条内容包含:对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。\n\n方向覆盖:第一次来怎么点;18-22 元值在哪;券后价和套餐;茶底甜度配料;避坑款;外卖到手;团购规则。`, size: 15, footer: "VOC 实战课", }, { type: "prompt", kicker: "HTML 报告 Prompt", title: "HTML 报告让 Claude Code 直接生成文件", subtitle: "不要让它进入复杂 workflow;用前面材料直接生成可打开页面。", label: "复制给 Claude Code", code: `请基于前面 VOC 分析材料,直接生成一份老板/运营视角可阅读的 HTML 策略报告。\n\n要求:\n1. 使用基础样本数据、采集矩阵、问题池、单点深挖和内容计划\n2. 生成一个可打开的 HTML 文件\n3. 风格参考青花瓷:白底、瓷蓝、细线纹样、清爽高级\n4. 包含关键结论、采集矩阵、问题池、单点深挖、7 天内容计划、补采关键词\n5. 生成后请打开浏览器预览,并根据页面效果继续优化`, size: 16, footer: "VOC 实战课", }, { type: "checklist", kicker: "常见报错", title: "实操中遇到错误,按状态判断下一步", subtitle: "这页要让学员不再被“余额不足/关键词错误/token 缺失”卡死。", items: [ ["needs_token", "没有检测到 token", "配置 .env.local"], ["needs_recharge", "未开通或余额不足", "打开充值/开通链接"], ["关键词错误", "搜索接口不接受入参", "缩短关键词、加空格重试"], ["链接 404", "平台链接或原帖失效", "保留样本编号和截图"], ["工具不触发", "Claude Code 没调用技能", "明确要求使用 VOC 技能"], ], footer: "VOC 实战课", }, { type: "compare", kicker: "课程商业表达", title: "免费技能满足学习需求,付费技能满足企业稳定需求", subtitle: "这页可以作为社区课转化页,但语气要克制。", columns: [ { title: "买课用户", body: "能用免费技能或 sample 跑通 VOC 方法。\n重点是理解流程、学会判断、能做小范围分析。", bottom: "课程价值:学会做一次 VOC 小闭环。", accent: palette.jade, fill: palette.mist2, }, { title: "企业用户", body: "需要稳定采集、批量样本、权限管理、错误处理、审计和报告复用。", bottom: "技能价值:稳定地把 VOC 变成交付。", accent: palette.cinnabar, fill: palette.paper, }, ], footer: "VOC 实战课", }, { type: "checklist", kicker: "实战课交付检查", title: "学员最终提交 6 个成果", subtitle: "这页作为实战课收尾,明确学完以后手上应该有什么。", items: [ ["项目模板", "有 inputs / samples / live-data / analysis / delivery", "可复用"], ["技能可用", "免费技能演示 + 付费技能 smoke", "可截图"], ["采集矩阵", "平台、人群、关键词、样本量、标准", "可复跑"], ["问题池", "Top 3 问题和证据", "可决策"], ["内容计划", "7 天标题、口播、指标", "可拍摄"], ["HTML 报告", "老板/运营可直接阅读", "可交付"], ], footer: "VOC 实战课", }, { type: "section", number: "END", title: "下一次换行业,只替换输入,不替换流程", text: "业务目标、价格门槛、用户原话、关键词、平台可以换;证据链和交付结构不要换。", side: "可复制\n可复盘\n可交付", footer: "VOC 实战课", }, ]; const renderers = { cover, agenda, claim, compare, flow, matrix, prompt, checklist, cards, section }; async function saveBlobToFile(blob, filePath) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, Buffer.from(await blob.arrayBuffer())); } async function writeDeck({ fileName, slides }) { const artifact = await import(pathToFileURL(ARTIFACT_TOOL).href); const { Presentation, PresentationFile } = artifact; const presentation = Presentation.create({ slideSize: { width: W, height: H } }); slides.forEach((spec, idx) => { renderers[spec.type](presentation, spec, { index: idx + 1, total: slides.length }); }); const base = fileName.replace(/\.pptx$/i, ""); const deckPreviewDir = path.join(PREVIEW_DIR, base); const deckLayoutDir = path.join(LAYOUT_DIR, base); const deckQaDir = path.join(QA_DIR, base); await fs.mkdir(deckPreviewDir, { recursive: true }); await fs.mkdir(deckLayoutDir, { recursive: true }); await fs.mkdir(deckQaDir, { recursive: true }); const previewPaths = []; for (let i = 0; i < presentation.slides.count; i += 1) { const slide = presentation.slides.getItem(i); const previewPath = path.join(deckPreviewDir, `slide-${String(i + 1).padStart(2, "0")}.png`); const png = await presentation.export({ slide, format: "png", scale: 1 }); await saveBlobToFile(png, previewPath); previewPaths.push(previewPath); const layout = await presentation.export({ slide, format: "layout" }); await fs.writeFile( path.join(deckLayoutDir, `slide-${String(i + 1).padStart(2, "0")}.layout.json`), await layout.text(), "utf8", ); } const pptx = await PresentationFile.exportPptx(presentation); const outPath = path.join(OUT_DIR, fileName); await fs.mkdir(OUT_DIR, { recursive: true }); await pptx.save(outPath); const contactSheet = path.join(deckQaDir, "contact-sheet.png"); const cs = spawnSync(PYTHON, [MAKE_CONTACT_SHEET, "--output", contactSheet, ...previewPaths], { encoding: "utf8", }); if (cs.status !== 0) { throw new Error(`contact sheet failed\n${cs.stdout}\n${cs.stderr}`); } return { outPath, previewDir: deckPreviewDir, layoutDir: deckLayoutDir, contactSheet, slides: slides.length }; } async function main() { await fs.mkdir(WORKSPACE, { recursive: true }); const theory = await writeDeck({ fileName: "VOC理论课-从用户声音到经营证据链.pptx", slides: theorySlides, }); const practical = await writeDeck({ fileName: "VOC实战课-FmodeStudio技能项目模板.pptx", slides: practicalSlides, }); const manifest = { generatedAt: new Date().toISOString(), workspace: WORKSPACE, decks: [theory, practical] }; await fs.writeFile(path.join(WORKSPACE, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8"); console.log(JSON.stringify(manifest, null, 2)); } main().catch((error) => { console.error(error.stack || error.message || String(error)); process.exit(1); });