#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); function detectWorkspaceRoot() { const cwd = process.cwd(); if (fs.existsSync(path.join(cwd, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) { return cwd; } const repoRoot = path.resolve(__dirname, '..', '..'); if (fs.existsSync(path.join(repoRoot, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) { return repoRoot; } const openclawWorkspace = path.resolve(__dirname, '..', 'workspace'); if (fs.existsSync(path.join(openclawWorkspace, 'scripts', 'tools', 'douyin-speaking-daily-report.js'))) { return openclawWorkspace; } return cwd; } const root = detectWorkspaceRoot(); const outRoot = path.join(root, 'outputs', 'p1-auto-smoke'); const fixtureDir = path.join(outRoot, 'fixtures'); function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function writeJson(filePath, value) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } function extractLastJson(stdout) { const text = String(stdout || '').trim(); for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { try { return JSON.parse(text.slice(i)); } catch { continue; } } throw new Error(`No JSON object found in output:\n${text.slice(-1200)}`); } function runNode(args, label) { const result = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', maxBuffer: 1024 * 1024 * 120, windowsHide: true }); if (result.error) throw result.error; if (result.status !== 0) { throw new Error(`${label} failed with code ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); } return result.stdout; } function assert(condition, message) { if (!condition) throw new Error(message); } function makeFixtures() { ensureDir(fixtureDir); const profilePath = path.join(fixtureDir, 'profile.json'); const inputPath = path.join(fixtureDir, 'runner-raw-input.json'); const noTranscriptInputPath = path.join(fixtureDir, 'runner-raw-input-no-transcript.json'); const failGateInputPath = path.join(fixtureDir, 'runner-raw-input-fail-gate.json'); const memoryPath = path.join(fixtureDir, 'script-memory.json'); const historyPath = path.join(fixtureDir, 'run-history.json'); const longAudioPath = path.join(fixtureDir, 'long-audio.mp3'); const profile = { projectName: 'p1-smoke', industry: 'AI创业、AI工具、B2B增长', accountPositioning: '老板IP,面向中小创业者输出AI工具和增长判断', targetAudience: ['中小创业者', 'B2B服务团队'], conversionGoal: '获客咨询', coreOffer: 'AI增长诊断和工具落地陪跑', keywords: ['AI工具', 'AI创业'], referenceAccounts: [], forbiddenTopics: ['纯工具清单搬运'], dailyOutputCount: 3 }; const input = { metadata: { fixture: 'p1-auto-smoke' }, videos: [ { aweme_id: 'smoke_001', title: '影视大佬将退出舞台', keyword: 'AI工具', authorName: '9老板Ai创业', likeCount: 2308, commentCount: 246, shareCount: 147, url: 'https://www.iesdouyin.com/share/video/smoke_001/' }, { aweme_id: 'smoke_002', title: '2026年5月最值得使用的AI工具有哪些', keyword: 'AI工具', authorName: '阿川同学', likeCount: 100, commentCount: 7, shareCount: 21, url: 'https://www.iesdouyin.com/share/video/smoke_002/' }, { aweme_id: 'smoke_003', title: '玩汽车行业红绿战略的看这里', keyword: 'AI创业', authorName: '9老板Ai创业', likeCount: 7, commentCount: 2, shareCount: 1, url: 'https://www.iesdouyin.com/share/video/smoke_003/' } ], accountVideos: [ { aweme_id: 'smoke_001', title: '影视大佬将退出舞台', authorName: '9老板Ai创业' } ], comments: [ { cid: 'c1', aweme_id: 'smoke_001', text: '老板到底怎么用AI工具获客?', digg_count: 88 }, { cid: 'c2', aweme_id: 'smoke_001', text: '有没有B2B服务团队能照着做的步骤?', digg_count: 66 }, { cid: 'c3', aweme_id: 'smoke_002', text: '这些工具哪个最适合小团队?', digg_count: 12 } ], transcripts: [ { awemeId: 'smoke_001', provider: 'iflytek-gateway', text: '影视大佬华谊挺不住了。真正的问题不是没有机会,而是过去那套判断方式不适合现在了。老板要先看趋势,再看现金流,最后决定动作。', segments: [] } ] }; const memory = { version: 1, updatedAt: new Date().toISOString(), sessions: [], preferenceSignals: { tones: { '老板视角': 2 }, hookStrength: { strong: 2 }, ctaStyles: { '产品转化': 1 }, add: { '具体场景': 1 }, avoid: { '泛泛表达': 1 } } }; writeJson(profilePath, profile); writeJson(inputPath, input); writeJson(noTranscriptInputPath, { ...input, transcripts: [] }); writeJson(failGateInputPath, { metadata: { fixture: 'p1-auto-smoke-fail-gate' }, videos: [], comments: [], transcripts: [] }); writeJson(memoryPath, memory); fs.writeFileSync(longAudioPath, 'fake audio placeholder for duration guard smoke', 'utf8'); return { profilePath, inputPath, noTranscriptInputPath, failGateInputPath, memoryPath, historyPath, longAudioPath }; } function run() { if (fs.existsSync(outRoot)) fs.rmSync(outRoot, { recursive: true, force: true }); const fixtures = makeFixtures(); const checks = []; const check = (name, fn) => { fn(); checks.push(name); console.log(`[ok] ${name}`); }; check('syntax', () => { runNode(['--check', 'scripts/tools/douyin-speaking-daily-report.js'], 'report syntax'); runNode(['--check', 'scripts/tools/douyin-speaking-daily-runner.js'], 'runner syntax'); runNode(['--check', 'scripts/tools/douyin-video-transcriber.js'], 'transcriber syntax'); }); let v0; const v0Out = path.join(outRoot, 'report-v0'); check('report selected topic v0 uses script memory', () => { v0 = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', v0Out, '--selected-topic-index', '1', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'report v0')); assert(v0.status === 'ok', 'v0 status should be ok'); assert(v0.scriptVersion === 'v0', 'v0 scriptVersion should be v0'); assert(v0.assistantMessage.includes('历史偏好'), 'v0 should surface memory guidance'); assert(v0.assistantMessage.includes('老板'), 'v0 should apply remembered boss angle'); assert(!v0.assistantMessage.includes('VOC'), 'generic profile should not leak VOC wording'); assert(v0.qualityGate && v0.qualityGate.level, 'v0 should include qualityGate'); }); const v1Out = v0Out; check('feedback produces v1', () => { const v1 = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', v1Out, '--script-session', path.join(v0Out, 'script-session.json'), '--feedback', '开头更狠,老板视角,太泛了,加一个具体 B2B 场景', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'report v1')); assert(v1.scriptVersion === 'v1', 'feedback should produce v1'); assert(v1.assistantMessage.includes('本轮理解'), 'v1 should explain parsed feedback'); assert(v1.assistantMessage.includes('具体场景'), 'v1 should retain concrete scenario request'); }); check('implicit finalize reuses output script session and writes memory', () => { const finalResult = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', v1Out, '--finalize', 'true', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'report final')); assert(finalResult.scriptSessionStatus === 'finalized', 'finalize should mark session finalized'); assert(finalResult.scriptVersion === 'v1', 'implicit finalize should reuse v1 output session'); assert(finalResult.assistantMessage.includes('定稿自检'), 'finalized script should include final quality gate'); assert(finalResult.assistantMessage.includes('30秒压缩版'), 'finalized script should include a 30s delivery version'); assert(finalResult.historyMemoryPath === fixtures.historyPath, 'finalize should return history memory path'); const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8')); const last = memory.sessions.at(-1); assert(last.feedbackCount === 1, 'finalize should persist feedback count'); assert(memory.preferenceSignals.tones['老板视角'] >= 1, 'finalize should persist tone preference'); assert(memory.preferenceSignals.hookStrength.strong >= 1, 'finalize should persist hook preference'); const history = JSON.parse(fs.readFileSync(fixtures.historyPath, 'utf8')); assert(history.runs.length >= 1, 'run history should persist report runs'); assert(history.runs.at(-1).scriptVersion === 'v1', 'run history should record script version'); }); check('runner selected topic passthrough', () => { const runner = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', path.join(outRoot, 'runner-selected'), '--selected-topic-index', '1', '--history-memory', fixtures.historyPath ], 'runner selected topic')); assert(runner.scriptVersion === 'v0', 'runner should passthrough selectedTopicIndex to report'); assert(runner.assistantMessage.startsWith('# 选题 1 共创稿'), 'runner should return script co-creation body'); }); check('natural message selects topic and auto-resumes latest raw input', () => { const runner = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--output', path.join(outRoot, 'runner-natural-selected'), '--message', '选题 1,进入脚本共创', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'runner natural selected topic')); assert(runner.selectedTopicIndex === 1, 'natural message should parse selected topic'); assert(runner.scriptVersion === 'v0', 'natural selected topic should produce v0'); assert(runner.autoResolvedInputPath && runner.autoResolvedInputPath.endsWith('runner-raw-input.json'), 'natural selected topic should auto-resolve raw input'); }); check('natural selected topic overrides stale script session', () => { const stalePath = path.join(v0Out, 'script-session.json'); const out = path.join(outRoot, 'report-natural-select-two'); const selected = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', out, '--script-session', stalePath, '--message', '选题2,进入脚本共创', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'report natural select two')); assert(selected.selectedTopicIndex === 2, 'natural select two should use topic 2'); assert(selected.scriptVersion === 'v0', 'topic switch should create a fresh v0 session'); assert(selected.scriptSessionPath === path.join(out, 'script-session.json'), 'topic switch should not overwrite stale session path'); const session = JSON.parse(fs.readFileSync(selected.scriptSessionPath, 'utf8')); assert(session.selectedTopicIndex === 2, 'new session should persist topic 2'); assert(session.sourceAwemeId === 'smoke_002', 'new session should bind to topic 2 source video'); }); check('natural finalize auto-resumes latest script session', () => { const runner = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--output', path.join(outRoot, 'runner-natural-finalize'), '--message', '定稿', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'runner natural finalize')); assert(runner.scriptSessionStatus === 'finalized', 'natural finalize should keep session finalized'); const resumedSessionPath = runner.autoResolvedScriptSessionPath || runner.scriptSessionPath; assert(resumedSessionPath && resumedSessionPath.endsWith('script-session.json'), 'natural finalize should auto-resolve or reuse script session'); }); check('natural preference after finalize writes memory only', () => { const finalizedSessionPath = path.join(v1Out, 'script-session.json'); const before = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8')); assert(before.status === 'finalized', 'preference-only regression needs a finalized session'); const pref = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--output', v1Out, '--message', '保留选题1和2,以后多给这种方向;不要纯工具清单方向', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'runner natural preference after finalize')); assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'runner preference should write script memory'); assert(pref.assistantMessage.includes('偏好已记录'), 'runner preference should acknowledge preference writes'); assert(!pref.assistantMessage.includes('今日热点选题池'), 'preference-only reply should not append a fresh report'); const after = JSON.parse(fs.readFileSync(finalizedSessionPath, 'utf8')); assert(after.status === 'finalized', 'preference-only message must not revert finalized script to draft'); const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8')); assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'keep topic indexes should persist from natural phrase'); assert(memory.topicPreferences.dropPhrases['纯工具清单方向'] >= 1, 'drop phrase should persist from natural phrase'); }); check('natural preference message writes keep and drop memory', () => { const pref = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', path.join(outRoot, 'report-preference-message'), '--message', '保留1、2,不要汽车行业方向', '--script-memory', fixtures.memoryPath, '--history-memory', fixtures.historyPath ], 'report natural preference')); assert(pref.topicPreferenceMemoryPath === fixtures.memoryPath, 'preference message should write script memory'); assert(pref.assistantMessage.includes('偏好已记录'), 'preference message should acknowledge preference writes'); const memory = JSON.parse(fs.readFileSync(fixtures.memoryPath, 'utf8')); assert(memory.topicPreferences.keepPhrases['AI工具'] >= 1, 'kept topic keyword should be weighted'); assert(memory.topicPreferences.dropPhrases['汽车行业方向'] >= 1, 'drop phrase should be weighted'); }); check('final quality gate blocks unsupported precise case claims', () => { const out = path.join(outRoot, 'report-unsupported-claim'); const seed = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', out, '--selected-topic-index', '1', '--history-memory', fixtures.historyPath ], 'report unsupported claim seed')); const sessionPath = seed.scriptSessionPath; const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); session.versions.push({ version: 'v_bad', status: 'draft', title: '未证实案例稿', script: '我们最近服务了一个真实客户,投流20万以后成本降了42%,转化翻了2.7倍。老板要先看评论区真实问题,再决定下一步怎么验证。', cta: '评论区告诉我你的行业。', strategy: 'unsupported precise case smoke', changeNotes: [], createdAt: new Date().toISOString() }); fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8'); const blocked = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-report.js', '--profile', fixtures.profilePath, '--input', fixtures.inputPath, '--output', out, '--script-session', sessionPath, '--finalize', 'true', '--history-memory', fixtures.historyPath ], 'report unsupported claim finalize')); assert(blocked.scriptSessionStatus === 'needs_revision', 'unsupported precise case should block finalization'); assert(blocked.assistantMessage.includes('未证实'), 'unsupported precise case should be explained in chat output'); }); check('no transcript fallback still generates v0', () => { const runner = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--input', fixtures.noTranscriptInputPath, '--output', path.join(outRoot, 'runner-no-transcript'), '--selected-topic-index', '1', '--history-memory', fixtures.historyPath ], 'runner no transcript')); assert(runner.scriptVersion === 'v0', 'no transcript should still produce v0'); assert(runner.assistantMessage.includes('结构推断稿'), 'no transcript v0 should mark structural inference'); }); check('quality gate fail blocks formal script', () => { const out = path.join(outRoot, 'runner-quality-gate-fail'); const runner = extractLastJson(runNode([ 'scripts/tools/douyin-speaking-daily-runner.js', '--profile', fixtures.profilePath, '--input', fixtures.failGateInputPath, '--output', out, '--selected-topic-index', '1', '--finalize', 'true', '--history-memory', fixtures.historyPath ], 'runner quality gate fail')); assert(runner.qualityGate.level === 'fail', 'quality gate should fail without video samples'); assert(runner.scriptSessionStatus === 'blocked', 'quality gate fail should block script session'); const session = JSON.parse(fs.readFileSync(path.join(out, 'script-session.json'), 'utf8')); assert(session.status === 'blocked', 'blocked session should stay blocked'); assert(session.versions[0].status === 'blocked', 'blocked session should not generate formal draft'); assert(session.versions[0].strategy === 'qualityGate.fail', 'blocked session should record quality gate strategy'); }); check('gateway duration guard does not upload long audio', () => { const guard = extractLastJson(runNode([ 'scripts/tools/douyin-video-transcriber.js', '--provider', 'iflytek-gateway', '--file', fixtures.longAudioPath, '--duration-ms', '1200000', '--gateway-max-duration-ms', '480000', '--output', path.join(outRoot, 'gateway-duration-guard') ], 'gateway duration guard')); assert(['needs_media_processing', 'needs_provider_config'].includes(guard.status), 'duration guard should stop before successful upload'); if (guard.status === 'needs_media_processing') { assert(guard.warningCount >= 1, 'duration guard should explain long video limit'); } }); console.log(JSON.stringify({ status: 'ok', checks, outputDir: outRoot }, null, 2)); } try { run(); } catch (error) { console.error(error.stack || error.message); process.exit(1); }