#!/usr/bin/env node /** * 批量视频生产编排器 * * 职责:读 Excel/CSV → 创建 batch-manifest → 管理 batch 状态 * 不负责:分镜、生图、生视频(由 AI Worker 子 Agent 承担) * * 用法: * node batch-pipeline.js init --file [--account <账号>] [--mode <模式>] * node batch-pipeline.js status --file * node batch-pipeline.js mark --file --row --status <状态> [--manifest-path ] [--error ] * node batch-pipeline.js retry-failed --file */ const fs = require('fs') const path = require('path') const { SKILLS_DIR, ACCOUNTS_DIR, loadConfig, resolveVoice } = require('./lib/pipeline-utils') // output/ 在项目根的父级(美图/output/) const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output') // ============================================================================ // CLI 参数解析 // ============================================================================ function parseArgs(argv) { const args = {} for (let i = 0; i < argv.length; i++) { if (argv[i] === '--file' && argv[i + 1]) args.file = argv[++i] else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i] else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i] else if (argv[i] === '--voice' && argv[i + 1]) args.voice = argv[++i] else if (argv[i] === '--row' && argv[i + 1]) args.row = parseInt(argv[++i]) else if (argv[i] === '--status' && argv[i + 1]) args.status = argv[++i] else if (argv[i] === '--manifest-path' && argv[i + 1]) args.manifestPath = argv[++i] else if (argv[i] === '--error' && argv[i + 1]) args.error = argv[++i] else if (argv[i] === '--topic' && argv[i + 1]) args.topic = argv[++i] else if (argv[i] === '--topic-a' && argv[i + 1]) args.topicA = argv[++i] else if (argv[i] === '--topic-b' && argv[i + 1]) args.topicB = argv[++i] else if (argv[i] === '--draft-name' && argv[i + 1]) args.draftName = argv[++i] else if (argv[i] === '--forward-copy' && argv[i + 1]) args.forwardCopy = argv[++i] else if (argv[i] === '--hashtags' && argv[i + 1]) args.hashtags = argv[++i] else if (argv[i] === '--format' && argv[i + 1]) args.format = argv[++i] else if (argv[i] === '--draft-dir' && argv[i + 1]) args.draftDir = argv[++i] else if (argv[i] === '--with-script') args.withScript = true else if (!args.command) args.command = argv[i] } return args } // ============================================================================ // init: 读 Excel/CSV → batch-manifest.json + 提取脚本文件 // ============================================================================ function cmdInit(args) { const filePath = path.resolve(args.file) if (!fs.existsSync(filePath)) { console.error(`文件不存在: ${filePath}`) process.exit(1) } const ext = path.extname(filePath).toLowerCase() let rows if (ext === '.csv') { rows = parseCsv(filePath) } else if (ext === '.xlsx' || ext === '.xls') { rows = parseExcel(filePath) } else { console.error(`不支持的格式: ${ext},仅支持 .xlsx .xls .csv`) process.exit(1) } if (rows.length === 0) { console.error('表格为空(无数据行)') process.exit(1) } // 创建 batch 输出目录 const dateStr = formatDate(new Date()) let seq = 1 while (fs.existsSync(path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`))) { seq++ } const batchDir = path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`) const scriptsDir = path.join(batchDir, 'scripts') ensureDir(batchDir) ensureDir(scriptsDir) const defaultAccount = args.account || '' const defaultMode = args.mode || 'single' const defaultVoice = args.voice || '' // 构建 items + 提取脚本 const items = [] for (let i = 0; i < rows.length; i++) { const row = rows[i] const script = extractField(row, ['脚本', 'script', '文案', '旁白']) const title = extractField(row, ['选题', '标题', 'title', 'name']) || '' const account = extractField(row, ['账号', 'account']) || defaultAccount const mode = extractField(row, ['模式', 'mode']) || defaultMode const voiceName = extractField(row, ['音色', 'voice']) || defaultVoice const forwardRaw = extractField(row, ['转发文案带话题', '转发文案', 'forwardCopy', '分享文案']) if (!script || !script.trim()) { console.warn(` ⚠ 第 ${i + 2} 行(${title || '无标题'})脚本为空,跳过`) continue } const scriptFile = path.join(scriptsDir, `row_${String(i + 1).padStart(3, '0')}.txt`) fs.writeFileSync(scriptFile, script.trim(), 'utf-8') // 解析音色名称 → ID const resolvedVoice = voiceName ? resolveVoice(voiceName) : '' // 解析转发文案带话题:分离出 hashtags 和转发文案 const { forwardCopy, hashtags } = parseForwardField(forwardRaw) items.push({ row: i + 1, title: title || '', // 原 Excel 选题(可为空,由 AI 后续填充) account: account || defaultAccount, mode: mode || defaultMode, voice: resolvedVoice, forwardCopy: forwardCopy || '', hashtags: hashtags || '', topicA: '', // 方案A: 双句封面 ≤12字 topicB: '', // 方案B: ≤4字极致精简 draftName: '', // 草稿名称: 账号_月日_序号_方案B scriptFile: `scripts/row_${String(i + 1).padStart(3, '0')}.txt`, status: 'pending', manifestPath: null, error: null, }) } if (items.length === 0) { console.error('没有有效的脚本行') process.exit(1) } // 校验账号 validateAccounts(items) // 写 batch-manifest const batchManifest = { source: path.basename(filePath), createdAt: new Date().toISOString(), defaults: { account: defaultAccount, mode: defaultMode, voice: defaultVoice ? resolveVoice(defaultVoice) : '' }, stats: calcStats(items), items, } const manifestPath = path.join(batchDir, 'batch-manifest.json') writeJson(manifestPath, batchManifest) console.log(`\n批量任务已创建: ${manifestPath}`) console.log(` 来源: ${path.basename(filePath)}`) console.log(` 总数: ${items.length}`) console.log(` 默认账号: ${defaultAccount || '(未指定,需每行填写)'}`) console.log(` 默认模式: ${defaultMode}`) console.log(` 默认音色: ${defaultVoice || '(用账号配置)'}`) console.log(` 脚本目录: ${scriptsDir}/`) console.log() } // ============================================================================ // status: 展示批量进度 // ============================================================================ function cmdStatus(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) const batchDir = path.dirname(manifestPath) console.log(`\n批量任务: ${batch.source}`) console.log(` 创建时间: ${batch.createdAt}`) console.log(` 进度: ${batch.stats.completed}/${batch.stats.total} 完成`) console.log() const grouped = { pending: [], processing: [], completed: [], failed: [] } for (const item of batch.items) { const list = grouped[item.status] || grouped.pending list.push(item) } const displayTitle = (it) => it.topicA || it.topic || it.title || '' if (grouped.completed.length > 0) { console.log(` ✅ 完成 (${grouped.completed.length}):`) for (const it of grouped.completed) { console.log(` #${it.row} ${displayTitle(it)} → ${it.manifestPath || ''}`) } } if (grouped.failed.length > 0) { console.log(` ❌ 失败 (${grouped.failed.length}):`) for (const it of grouped.failed) { console.log(` #${it.row} ${displayTitle(it)} — ${it.error || '未知错误'}`) } } if (grouped.processing.length > 0) { console.log(` 🔄 进行中 (${grouped.processing.length}):`) for (const it of grouped.processing) { console.log(` #${it.row} ${displayTitle(it)}`) } } if (grouped.pending.length > 0) { console.log(` ⏳ 待处理 (${grouped.pending.length}):`) for (const it of grouped.pending) { console.log(` #${it.row} ${displayTitle(it)} (账号: ${it.account || '未指定'}, 模式: ${it.mode}, 音色: ${it.voice || '账号默认'})`) } } // 输出下一个待处理的行号(方便 AI agent 消费) const next = batch.items.find(it => it.status === 'pending') if (next) { console.log(`\n ▶ 下一条: #${next.row} ${displayTitle(next)} (账号: ${next.account}, 模式: ${next.mode}, 音色: ${next.voice || '账号默认'})`) console.log(` 脚本文件: ${path.resolve(batchDir, next.scriptFile)}`) } console.log() } // ============================================================================ // mark: 标记某行状态 // ============================================================================ function cmdMark(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) const item = batch.items.find(it => it.row === args.row) if (!item) { console.error(`行 ${args.row} 不存在`) process.exit(1) } const validStatuses = ['pending', 'processing', 'completed', 'failed'] if (!validStatuses.includes(args.status)) { console.error(`无效状态: ${args.status},可选: ${validStatuses.join(', ')}`) process.exit(1) } const oldStatus = item.status item.status = args.status if (args.manifestPath) item.manifestPath = args.manifestPath if (args.error) item.error = args.error if (args.status !== 'failed') item.error = null // 可选元数据更新 if (args.topic) item.topicA = args.topic // 向后兼容 --topic if (args.topicA) item.topicA = args.topicA if (args.topicB) item.topicB = args.topicB if (args.draftName) item.draftName = args.draftName if (args.forwardCopy) item.forwardCopy = args.forwardCopy if (args.hashtags) item.hashtags = args.hashtags batch.stats = calcStats(batch.items) writeJson(manifestPath, batch) const label = item.topicA || item.title || '' console.log(`#${item.row} ${label}: ${oldStatus} → ${args.status}`) } // ============================================================================ // retry-failed: 重置失败行 // ============================================================================ function cmdRetryFailed(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) let count = 0 for (const item of batch.items) { if (item.status === 'failed') { item.status = 'pending' item.error = null count++ } } batch.stats = calcStats(batch.items) writeJson(manifestPath, batch) console.log(`已重置 ${count} 条失败记录为 pending`) } // ============================================================================ // next: 输出下一条待处理的信息(机器友好格式) // ============================================================================ function cmdNext(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) const batchDir = path.dirname(manifestPath) const item = batch.items.find(it => it.status === 'pending') if (!item) { console.log(JSON.stringify({ done: true })) return } const result = { done: false, row: item.row, title: item.title || '', account: item.account, mode: item.mode, voice: item.voice || '', forwardCopy: item.forwardCopy || '', hashtags: item.hashtags || '', topicA: item.topicA || '', topicB: item.topicB || '', draftName: item.draftName || '', scriptFile: path.resolve(batchDir, item.scriptFile), } // --with-script:附带脚本内容,方便 AI 直接基于脚本生成选题/转发文案 if (args.withScript) { try { result.script = fs.readFileSync(path.resolve(batchDir, item.scriptFile), 'utf-8') } catch { result.script = '' } } console.log(JSON.stringify(result)) } // ============================================================================ // export: 输出最终表格(含草稿名称列) // ============================================================================ function cmdExport(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) const batchDir = path.dirname(manifestPath) // 构建导出行 const rows = [] for (const item of batch.items) { // 读取脚本文件 let script = '' try { script = fs.readFileSync(path.resolve(batchDir, item.scriptFile), 'utf-8').trim() } catch {} // 重组转发文案带话题(避免 hashtags 重复) const htags = (item.hashtags || '').trim() let forwardBody = (item.forwardCopy || '').trim() // 如果 forwardCopy 已包含 hashtags,则剥离避免重复 if (htags && forwardBody.endsWith(htags)) { forwardBody = forwardBody.slice(0, -htags.length).trim() } const forwardFull = [forwardBody, htags].filter(Boolean).join('') // 选题列:topicA(方案A)> 旧字段 topic > 原 title const topicDisplay = item.topicA || item.topic || item.title || '' rows.push({ row: item.row, 选题: topicDisplay, 脚本: script, 账号: item.account, 模式: item.mode, 音色: item.voice || '', 转发文案带话题: forwardFull, 草稿名称: item.draftName || '', }) } // 按 row 排序 rows.sort((a, b) => a.row - b.row) const format = args.format || 'csv' const dateStr = formatDate(new Date()) const baseName = path.basename(manifestPath, '.json') if (format === 'xlsx') { exportXlsx(manifestPath, rows) } else { exportCsv(manifestPath, rows) } } function exportCsv(manifestPath, rows) { const outPath = manifestPath.replace('.json', '_export.csv') const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称'] const lines = [headers.join(',')] for (const r of rows) { const vals = headers.map(h => { const v = String(r[h] || '') // CSV 转义:含逗号、引号、换行的字段用引号包裹 if (v.includes(',') || v.includes('"') || v.includes('\n')) { return `"${v.replace(/"/g, '""')}"` } return v }) lines.push(vals.join(',')) } fs.writeFileSync(outPath, lines.join('\n'), 'utf-8') console.log(`表格已导出: ${outPath}`) console.log(` 共 ${rows.length} 条记录`) // 同时打印到控制台 console.log() printTable(rows, headers) } function exportXlsx(manifestPath, rows) { try { const XLSX = require('xlsx') const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称'] const data = rows.map(r => headers.map(h => r[h] || '')) data.unshift(headers) const ws = XLSX.utils.aoa_to_sheet(data) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, '视频清单') const outPath = manifestPath.replace('.json', '_export.xlsx') XLSX.writeFile(wb, outPath) console.log(`表格已导出: ${outPath}`) console.log(` 共 ${rows.length} 条记录`) console.log() printTable(rows, headers) } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { console.warn('xlsx 模块未安装,改用 CSV 格式') exportCsv(manifestPath, rows) } else { throw err } } } function printTable(rows, headers) { // 计算每列最大宽度 const widths = headers.map(h => { const maxData = rows.reduce((m, r) => Math.max(m, String(r[h] || '').length), 0) return Math.min(Math.max(maxData, h.length), 40) // 单列最长 40 字符 }) // 分隔线 const sep = '|-' + widths.map(w => '-'.repeat(w)).join('-|-') + '-|' // 表头 const headerLine = '| ' + headers.map((h, i) => pad(h, widths[i])).join(' | ') + ' |' console.log(sep) console.log(headerLine) console.log(sep) for (const r of rows) { const line = '| ' + headers.map((h, i) => pad(String(r[h] || ''), widths[i])).join(' | ') + ' |' console.log(line) } console.log(sep) } function pad(s, width) { // 中文字符占 2 个显示宽度 let displayLen = 0 for (const ch of s) { displayLen += /[一-鿿＀-￯]/.test(ch) ? 2 : 1 } const padding = Math.max(0, width - displayLen) return s + ' '.repeat(padding) } // ============================================================================ // rename-drafts: 批量重命名剪映草稿箱文件夹 // Mac 版剪映草稿显示名 = 文件夹名,直接 mv 即可 // ============================================================================ function cmdRenameDrafts(args) { const manifestPath = path.resolve(args.file) const batch = readJson(manifestPath) const batchDir = path.dirname(manifestPath) // 草稿目录:默认 Mac 版 JianyingPro 路径 const homeDir = require('os').homedir() const draftDir = args.draftDir || path.join(homeDir, 'Movies', 'JianyingPro', 'User Data', 'Projects', 'com.lveditor.draft') if (!fs.existsSync(draftDir)) { console.error(`草稿目录不存在: ${draftDir}`) process.exit(1) } // 构建脚本指纹 → draftName 映射 const itemLookup = [] for (const it of batch.items) { if (!it.draftName) continue const sp = path.resolve(batchDir, it.scriptFile) let s = '' try { s = fs.readFileSync(sp, 'utf-8').trim().replace(/\s+/g, '') } catch {} itemLookup.push({ row: it.row, draftName: it.draftName, sig: s.slice(0, 30) }) } // 扫描草稿目录 const allDrafts = fs.readdirSync(draftDir) .filter(d => fs.statSync(path.join(draftDir, d)).isDirectory()) // 匹配 const matches = [] for (const folderId of allDrafts) { const cp = path.join(draftDir, folderId, 'draft_content.json') if (!fs.existsSync(cp)) continue let content try { const raw = fs.readFileSync(cp, 'utf-8') if (!raw.startsWith('{')) continue content = JSON.parse(raw) } catch { continue } const texts = (content.materials?.texts || []) .filter(t => t.type === 'subtitle') .map(t => { try { const c = JSON.parse(t.content); return c.text || '' } catch { return '' } }) const fullText = texts.join('').replace(/\s+/g, '') if (fullText.length < 50) continue for (const item of itemLookup) { if (fullText.includes(item.sig)) { matches.push({ folderId, item, dateStr: folderId.slice(0, 14) }) break } } } // 按 row 分组,按日期排序(同 row 多个草稿用 _v2 区分) const rowGroups = {} for (const m of matches) { if (!rowGroups[m.item.row]) rowGroups[m.item.row] = [] rowGroups[m.item.row].push(m) } // 收集所有目标名称,用于跳过已改名的 const targetNames = new Set() for (const [row, group] of Object.entries(rowGroups)) { for (let i = 0; i < group.length; i++) { targetNames.add(i === 0 ? group[i].item.draftName : group[i].item.draftName + '_v' + (i + 1)) } } let renamed = 0 for (const [row, group] of Object.entries(rowGroups)) { group.sort((a, b) => a.dateStr.localeCompare(b.dateStr)) for (let i = 0; i < group.length; i++) { const m = group[i] let newName = m.item.draftName if (i > 0) newName = m.item.draftName + '_v' + (i + 1) // 跳过已是目标名称或已被其他行匹配占用的 if (m.folderId === newName || (targetNames.has(m.folderId) && m.folderId !== newName)) { continue } const oldPath = path.join(draftDir, m.folderId) const newPath = path.join(draftDir, newName) if (oldPath === newPath) continue try { fs.renameSync(oldPath, newPath) console.log('#' + row + ' ' + m.folderId + ' → ' + newName) renamed++ } catch (e) { console.log('#' + row + ' ' + m.folderId + ' FAILED: ' + e.message) } } } const unmatched = itemLookup.filter(it => !matches.some(m => m.item.row === it.row)) console.log('\n改名: ' + renamed + ' 匹配: ' + matches.length + ' 未匹配: ' + unmatched.length) if (unmatched.length > 0) { console.log('未匹配:') for (const u of unmatched) console.log(' #' + u.row + ' ' + u.draftName) } } // ============================================================================ // Helpers // ============================================================================ function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }) } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf-8')) } function writeJson(filePath, data) { const tmp = filePath + '.tmp' fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8') fs.renameSync(tmp, filePath) } function calcStats(items) { const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 } for (const item of items) { if (stats[item.status] !== undefined) stats[item.status]++ else stats.pending++ } return stats } function formatDate(d) { return [ d.getFullYear(), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'), ].join('') } function extractField(row, names) { for (const name of names) { if (row[name] != null && String(row[name]).trim()) return String(row[name]).trim() } return '' } function validateAccounts(items) { const uniqueAccounts = [...new Set(items.map(it => it.account).filter(Boolean))] const missing = uniqueAccounts.filter(acc => { return !fs.existsSync(path.join(ACCOUNTS_DIR, acc, 'account.json')) }) if (missing.length > 0) { console.warn(`\n ⚠ 以下账号不存在: ${missing.join(', ')}`) const available = fs.readdirSync(ACCOUNTS_DIR).filter(d => { if (d.startsWith('_')) return false return fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json')) }) console.warn(' 可用账号:') for (const acc of available) { try { const cfg = readJson(path.join(ACCOUNTS_DIR, acc, 'account.json')) console.warn(` - ${acc} (${cfg.name})`) } catch {} } console.warn() } } // ============================================================================ // Excel / CSV 解析 // ============================================================================ function parseExcel(filePath) { try { const XLSX = require('xlsx') const workbook = XLSX.readFile(filePath) const sheetName = workbook.SheetNames[0] const sheet = workbook.Sheets[sheetName] return XLSX.utils.sheet_to_json(sheet) } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { console.error('需要安装 xlsx: cd scripts && pnpm add xlsx') process.exit(1) } throw err } } function parseCsv(filePath) { const content = fs.readFileSync(filePath, 'utf-8') const lines = content.split(/\r?\n/).filter(l => l.trim()) if (lines.length < 2) return [] const headers = parseCsvLine(lines[0]) const rows = [] for (let i = 1; i < lines.length; i++) { const values = parseCsvLine(lines[i]) const row = {} headers.forEach((h, j) => { row[h.trim()] = (values[j] || '').trim() }) rows.push(row) } return rows } /** * 解析「转发文案带话题」字段 * 输入: "孩子只要长大,就会开始清算父母。#反派人格#执黑先行" * 输出: { forwardCopy: "孩子只要长大,就会开始清算父母。", hashtags: "#反派人格#执黑先行" } */ function parseForwardField(raw) { if (!raw || !raw.trim()) return { forwardCopy: '', hashtags: '' } // 提取所有 #xxx 格式的话题 const hashtagRe = /#[^\s#]+/g const hashtagMatches = raw.match(hashtagRe) || [] const hashtags = hashtagMatches.join('') // 移除所有话题后剩余的是转发文案 let forwardCopy = raw for (const tag of hashtagMatches) { forwardCopy = forwardCopy.replace(tag, '') } // 清理多余空白和标点周围的空格 forwardCopy = forwardCopy.replace(/\s+/g, ' ').trim() return { forwardCopy, hashtags } } function parseCsvLine(line) { const result = [] let current = '' let inQuotes = false for (const char of line) { if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { result.push(current); current = '' } else { current += char } } result.push(current) return result } // ============================================================================ // CLI 入口 // ============================================================================ function main() { const args = parseArgs(process.argv.slice(2)) const command = args.command if (command === 'init') { if (!args.file) { console.error('用法: batch-pipeline.js init --file [--account <账号>] [--mode <模式>]') process.exit(1) } cmdInit(args) } else if (command === 'status') { if (!args.file) { console.error('用法: batch-pipeline.js status --file ') process.exit(1) } cmdStatus(args) } else if (command === 'mark') { if (!args.file || !args.row || !args.status) { console.error('用法: batch-pipeline.js mark --file --row --status [--manifest-path ] [--error ] [--topic-a <方案A>] [--topic-b <方案B>] [--draft-name <草稿名称>] [--forward-copy <转发文案>] [--hashtags <话题>]') process.exit(1) } cmdMark(args) } else if (command === 'retry-failed') { if (!args.file) { console.error('用法: batch-pipeline.js retry-failed --file ') process.exit(1) } cmdRetryFailed(args) } else if (command === 'next') { if (!args.file) { console.error('用法: batch-pipeline.js next --file ') process.exit(1) } cmdNext(args) } else if (command === 'export') { if (!args.file) { console.error('用法: batch-pipeline.js export --file [--format csv|xlsx]') process.exit(1) } cmdExport(args) } else if (command === 'rename-drafts') { if (!args.file) { console.error('用法: batch-pipeline.js rename-drafts --file [--draft-dir <草稿箱路径>]') process.exit(1) } cmdRenameDrafts(args) } else { console.log('批量视频生产编排器') console.log('') console.log('用法:') console.log(' batch-pipeline.js init --file [--account <账号>] [--mode ] [--voice <音色>]') console.log(' batch-pipeline.js status --file ') console.log(' batch-pipeline.js next --file ') console.log(' batch-pipeline.js mark --file <...> --row --status [--manifest-path ] [--error ] [--topic-a <方案A>] [--topic-b <方案B>] [--draft-name <草稿名称>] [--forward-copy <转发文案>] [--hashtags <话题>]') console.log(' batch-pipeline.js retry-failed --file ') console.log(' batch-pipeline.js export --file [--format csv|xlsx]') console.log(' batch-pipeline.js rename-drafts --file [--draft-dir <路径>]') console.log('') console.log('Excel 格式:') console.log(' 选题 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题') console.log(' 选题/标题/title — 标题(可选,留空则由 AI 根据脚本自动生成)') console.log(' 脚本/文案/旁白 — 口播文案(必填)') console.log(' 账号/account — 账号ID(可选,可由 --account 指定默认值)') console.log(' 模式/mode — single|framePair(可选,可由 --mode 指定默认值)') console.log(' 音色/voice — 音色名称或ID(可选,可由 --voice 指定默认值)') console.log(' 转发文案带话题/转发文案/forwardCopy — 转发文案+#话题(可选,留空则由 AI 生成)') } } if (require.main === module) { main() } module.exports = { cmdInit, cmdStatus, cmdMark, cmdRetryFailed, cmdNext, cmdExport, cmdRenameDrafts }