#!/usr/bin/env node const fs = require('fs'); const path = require('path'); function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/douyin-speaking-daily-report.js --profile --input [--output ]', ' node scripts/tools/douyin-speaking-daily-report.js --project --industry --keywords "kw1,kw2" [--input ]', '', 'Outputs:', ' daily-report.md', ' daily-report.json', ' audit.json' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); const text = String(value).trim(); if (!text) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : []; } catch { return []; } } return text.split(/[,,;;|、]/).map(item => item.trim()).filter(Boolean); } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function truncate(value, length = 88) { const text = cleanText(value); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } function toNumber(value) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; } function toIndex(value) { const number = Number(value || 0); return Number.isFinite(number) ? Math.max(0, Math.round(number)) : 0; } function boolArg(value, fallback = false) { if (value === undefined || value === null || value === '') return fallback; if (typeof value === 'boolean') return value; return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase()); } function hasConcreteArg(value) { if (value === undefined || value === null) return false; const text = String(value).trim(); return Boolean(text) && !/^\{\{[^}]+\}$/.test(text); } function readJsonMaybe(filePath) { if (!hasConcreteArg(filePath)) return undefined; const resolved = path.resolve(filePath); if (!fs.existsSync(resolved)) return undefined; return readJson(resolved); } function defaultProfilePath() { return path.join(process.cwd(), 'memory', 'douyin-speaking-profile.json'); } function findLatestFileByName(fileName, roots = []) { const queue = roots .map(root => path.resolve(root)) .filter(root => fs.existsSync(root)); let latest = null; while (queue.length) { const dir = queue.shift(); let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (!['node_modules', '.git', 'dist'].includes(entry.name)) queue.push(full); } else if (entry.isFile() && entry.name === fileName) { const stat = fs.statSync(full); if (!latest || stat.mtimeMs > latest.mtimeMs) latest = { path: full, mtimeMs: stat.mtimeMs }; } } } return latest?.path || ''; } function outputSearchRoots() { return [ path.join(process.cwd(), 'outputs'), path.join(process.cwd(), 'openclaw-voc-output'), path.join(process.cwd(), 'memory') ]; } function parseIndexList(text) { return String(text || '') .replace(/选题|第|条|个/g, '') .split(/[,,、\s和与&+]+/) .map(item => Number(item)) .filter(number => Number.isFinite(number) && number > 0) .map(number => Math.round(number)); } function parseNaturalCommand(text) { const raw = cleanText(text); const command = { raw, selectedTopicIndex: 0, feedback: '', finalize: false, keepIndexes: [], dropText: '', transcriptRequested: false, isContinuation: false, preferenceOnly: false }; if (!raw) return command; const topicMatch = raw.match(/(?:选题|第)\s*(\d+)\s*(?:条|个)?|topic\s*(\d+)/i); if (topicMatch) command.selectedTopicIndex = Number(topicMatch[1] || topicMatch[2] || 0); command.finalize = /^(定稿|确认定稿|final|finalize)$/i.test(raw) || /(^|[,,。\s])定稿($|[,,。\s])/.test(raw); command.transcriptRequested = /转写|逐字稿|补 transcript|transcript/i.test(raw); const keepMatch = raw.match(/保留\s*((?:选题|第|topic)?\s*\d+(?:\s*(?:[,,、和与&+]|\s+)\s*(?:选题|第|topic)?\s*\d+)*)/i); if (keepMatch) command.keepIndexes = parseIndexList(keepMatch[1]); const dropMatch = raw.match(/(?:不要|降权|禁区|排除)\s*([^,。;;]+)/); if (dropMatch) command.dropText = cleanText(dropMatch[1]); if (command.keepIndexes.length && /^保留/.test(raw)) { command.selectedTopicIndex = 0; } const isPureSelect = command.selectedTopicIndex && /^(选题|第|topic)/i.test(raw) && !/[,,。;;]/.test(raw.replace(/选题\s*\d+|第\s*\d+\s*条|topic\s*\d+/ig, '')); const hasPreferenceSignal = Boolean(command.keepIndexes.length || command.dropText); const hasFeedbackSignal = /改稿|调整|开头|老板|专家|案例|场景|具体|太泛|压成|短版|转化|更狠|更短|更长|口语|定稿前|去掉/.test(raw); if (!command.finalize && hasFeedbackSignal && !isPureSelect && !hasPreferenceSignal) { command.feedback = raw.replace(/^改稿[::]?/i, '').trim(); } command.isContinuation = Boolean(command.selectedTopicIndex || command.feedback || command.finalize || command.transcriptRequested || command.keepIndexes.length || command.dropText); command.preferenceOnly = Boolean((command.keepIndexes.length || command.dropText) && !command.selectedTopicIndex && !command.feedback && !command.finalize); return command; } function slugify(value) { return String(value || 'douyin-speaking-daily') .trim() .replace(/[\\/:*?"<>|\s]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') || 'douyin-speaking-daily'; } function loadProfile(args) { const profilePath = hasConcreteArg(args.profile) ? path.resolve(args.profile) : fs.existsSync(defaultProfilePath()) ? defaultProfilePath() : ''; const fileProfile = profilePath ? readJson(profilePath) : {}; const inlineProfile = { projectName: args.project || args.projectName || args['project-name'], industry: args.industry, accountPositioning: args.positioning || args.accountPositioning || args['account-positioning'], targetAudience: args.audience || args.targetAudience || args['target-audience'], conversionGoal: args.goal || args.conversionGoal || args['conversion-goal'], coreOffer: args.offer || args.coreOffer || args['core-offer'], contentPillars: splitList(args.pillars || args.contentPillars || args['content-pillars']), keywords: splitList(args.keywords), referenceAccounts: splitList(args.accounts || args.referenceAccounts || args['reference-accounts']), forbiddenTopics: splitList(args.forbidden || args.forbiddenTopics || args['forbidden-topics']), tone: args.tone, dailyOutputCount: Number(args.count || args.dailyOutputCount || args['daily-output-count'] || 8) }; const profile = { ...fileProfile }; Object.entries(inlineProfile).forEach(([key, value]) => { if (Array.isArray(value)) { if (value.length) profile[key] = value; } else if (value !== undefined && value !== null && value !== '' && !(typeof value === 'number' && Number.isNaN(value))) { profile[key] = value; } }); profile.projectName = profile.projectName || 'douyin-speaking-daily'; profile.keywords = splitList(profile.keywords); profile.referenceAccounts = splitList(profile.referenceAccounts); profile.contentPillars = splitList(profile.contentPillars); profile.forbiddenTopics = splitList(profile.forbiddenTopics); profile.dailyOutputCount = Number(profile.dailyOutputCount || 8); return profile; } function loadInput(inputPath) { if (!inputPath) return {}; const absolute = path.resolve(inputPath); const stat = fs.statSync(absolute); if (stat.isDirectory()) { const preferred = [ 'douyin-mini-voc.json', 'raw.json', '_merged.json', 'keyword-videos.json', 'videos.json', 'daily-report.json' ].map(name => path.join(absolute, name)); const found = preferred.find(filePath => fs.existsSync(filePath)); if (found) return readJson(found); const merged = {}; fs.readdirSync(absolute) .filter(name => name.endsWith('.json')) .forEach(name => Object.assign(merged, mergeData(merged, readJson(path.join(absolute, name))))); return merged; } return readJson(absolute); } function mergeData(left, right) { return { metadata: { ...(left.metadata || {}), ...(right.metadata || {}) }, videos: [ ...asArray(left.videos), ...asArray(right.videos), ...asArray(right.accountVideos), ...asArray(right.items).filter(item => item.sourceType === 'video') ], comments: [...asArray(left.comments), ...asArray(right.comments)], replies: [...asArray(left.replies), ...asArray(right.replies)], hashtags: [...asArray(left.hashtags), ...asArray(right.hashtags)], transcripts: [...asArray(left.transcripts), ...asArray(right.transcripts)] }; } function normalizeVideo(video, index) { const raw = video.raw || video; const statistics = video.statistics || raw.statistics || raw.stats || {}; const author = video.author || raw.author || {}; const hashtags = asArray(video.hashtags || raw.cha_list || raw.text_extra) .map(item => item.cha_name || item.hashtag_name || item.name || item.hashtag || item) .map(cleanText) .filter(Boolean); return { id: cleanText(video.aweme_id || video.awemeId || video.id || video.productId || raw.aweme_id || raw.awemeId || `video_${index}`), keyword: cleanText(video.keyword || raw.keyword), title: cleanText(video.title || video.desc || video.content || raw.desc || raw.title), author: cleanText(typeof author === 'string' ? author : author.nickname || author.name || video.authorName), likeCount: toNumber(video.likeCount ?? video.digg_count ?? statistics.digg_count), commentCount: toNumber(video.commentCount ?? video.comment_count ?? statistics.comment_count), shareCount: toNumber(video.shareCount ?? video.share_count ?? statistics.share_count), playCount: toNumber(video.playCount ?? video.play_count ?? statistics.play_count), publishTime: video.publishTime || raw.create_time || raw.publish_time, url: video.url || video.share_url || raw.share_url, hashtags, raw }; } function normalizeComment(comment, index) { const raw = comment.raw || comment; const user = comment.user || raw.user || {}; return { id: cleanText(comment.cid || comment.commentId || comment.id || raw.cid || raw.id || `comment_${index}`), parentId: cleanText(comment.parentId || comment.aweme_id || raw.aweme_id || raw.item_id), keyword: cleanText(comment.keyword || raw.keyword), text: cleanText(comment.text || comment.content || raw.text || raw.content), author: cleanText(comment.author || user.nickname || user.name), likeCount: toNumber(comment.likeCount ?? comment.digg_count ?? raw.digg_count), replyCount: toNumber(comment.replyCount ?? comment.reply_comment_total ?? raw.reply_comment_total), raw }; } function normalizeTranscript(transcript, index) { if (!transcript) return undefined; if (typeof transcript === 'string') { return { id: `transcript_${index}`, awemeId: '', text: cleanText(transcript), segments: [] }; } return { id: transcript.id || `transcript_${index}`, awemeId: cleanText(transcript.awemeId || transcript.aweme_id || transcript.videoId || transcript.itemId), sourceUrl: transcript.sourceUrl || transcript.url || '', provider: transcript.provider || 'unknown', text: cleanText(transcript.text), segments: asArray(transcript.segments) }; } function normalizeData(data) { return { metadata: data.metadata || {}, videos: asArray(data.videos) .concat(asArray(data.accountVideos)) .concat(asArray(data.items).filter(item => item.sourceType === 'video')) .map(normalizeVideo), comments: asArray(data.comments).concat(asArray(data.replies)).map(normalizeComment), hashtags: asArray(data.hashtags), transcripts: asArray(data.transcripts).map(normalizeTranscript).filter(Boolean) }; } function videoScore(video) { return video.likeCount * 1 + video.commentCount * 4 + video.shareCount * 6 + video.playCount * 0.003; } function normalizePhrase(value) { return cleanText(value).toLowerCase(); } function topicPreferenceScore(video, profile, scriptMemory) { const memory = normalizeScriptMemory(scriptMemory || {}); const prefs = memory.topicPreferences || {}; const text = normalizePhrase(`${video.title} ${video.keyword} ${video.author}`); let score = 0; Object.entries(prefs.keepPhrases || {}).forEach(([phrase, weight]) => { if (phrase && text.includes(normalizePhrase(phrase))) score += Number(weight || 1) * 1200; }); Object.entries(prefs.dropPhrases || {}).forEach(([phrase, weight]) => { if (phrase && text.includes(normalizePhrase(phrase))) score -= Number(weight || 1) * 2000; }); asArray(profile.forbiddenTopics).forEach(phrase => { if (phrase && text.includes(normalizePhrase(phrase))) score -= 3000; }); asArray(memory.sessions).forEach(session => { if (session.sourceAwemeId && session.sourceAwemeId === video.id) score -= 1600; }); return score; } function inferHookType(text) { const t = cleanText(text); if (/[??]|为什么|怎么|如何|到底|有没有/.test(t)) return '问题钩子'; if (/别再|不要|千万|避坑|踩雷|错了|误区/.test(t)) return '避坑钩子'; if (/我发现|亲测|真实|讲个|经历|以前|后来/.test(t)) return '经历钩子'; if (/\d|一招|三点|5个|10个|清单/.test(t)) return '清单钩子'; if (/但是|其实|反而|不是.*而是/.test(t)) return '反转钩子'; if (/爆|火|涨粉|成交|转化|收入|结果/.test(t)) return '结果钩子'; return '场景钩子'; } function inferConflict(text) { const t = cleanText(text); if (/避坑|踩雷|别买|别再|不要/.test(t)) return '用户怕踩坑,需要一个更简单的判断标准'; if (/焦虑|担心|纠结|不敢|怕/.test(t)) return '用户在风险和收益之间犹豫'; if (/贵|便宜|性价比|价格/.test(t)) return '用户在价格和价值感之间犹豫'; if (/没用|无效|失败|翻车/.test(t)) return '用户担心结果不可控,需要证明边界'; if (/但是|其实|反而|不是/.test(t)) return '适合用反转打破原有认知'; return '把用户已有认知和真实结果拉开差距'; } function commentsForVideo(comments, videoId, totalVideos) { const linked = comments.filter(comment => comment.parentId && comment.parentId === videoId); if (linked.length) return { scope: 'linked', comments: linked }; if (totalVideos === 1 && comments.length) return { scope: 'single_video_unlinked', comments }; return { scope: 'unlinked_ignored', comments: [] }; } function topComments(comments, limit = 3) { return comments .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2)) .slice(0, limit); } function questionComments(comments, limit = 4) { return comments .filter(comment => /[??]|怎么|哪里|多少|能不能|有没有|适合|为什么|求|链接/.test(comment.text)) .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2)) .slice(0, limit); } function countBy(values) { const counts = new Map(); values.filter(Boolean).forEach(value => counts.set(value, (counts.get(value) || 0) + 1)); return [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label))); } function transcriptForVideo(transcripts, videoId) { return transcripts.find(item => item.awemeId && item.awemeId === videoId) || undefined; } function splitTranscriptSentences(text) { return cleanText(text) .replace(/([。!?!?;;])/g, '$1\n') .split(/\n+/) .map(cleanText) .filter(sentence => sentence.length >= 6); } function uniqueTexts(items) { const seen = new Set(); return items.filter(item => { const key = cleanText(item); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function scoreReusableSentence(sentence) { const text = cleanText(sentence); let score = 0; if (/不是|而是|其实|记住|核心|关键|重点|结论|真正|只需要|不要|别/.test(text)) score += 4; if (/第一|第二|第三|首先|其次|最后|\d/.test(text)) score += 2; if (/比如|案例|举个|身边|以前|后来|结果/.test(text)) score += 2; if (/为什么|怎么|如何|到底|是不是/.test(text)) score += 1; if (text.length >= 14 && text.length <= 70) score += 2; if (text.length > 110) score -= 2; return score; } function transcriptStructure(sentences) { if (!sentences.length) return []; const labels = ['开场钩子', '问题铺开', '证明与案例', '结论与行动']; const ranges = [ [0, 0.18], [0.18, 0.46], [0.46, 0.78], [0.78, 1] ]; return ranges.map(([startRatio, endRatio], index) => { const start = Math.min(sentences.length - 1, Math.floor(sentences.length * startRatio)); const end = Math.max(start + 1, Math.ceil(sentences.length * endRatio)); const chunk = sentences.slice(start, end); return { label: labels[index], summary: truncate(chunk.join(''), 82) }; }).filter(item => item.summary); } function inferReusableTactics(text) { const tactics = []; if (/想象成|就像|好比|比如|比喻|员工|老师|医生|工具/.test(text)) { tactics.push('用一个大众熟悉的类比解释陌生概念,先降低理解门槛。'); } if (/不是[\s\S]{0,16}而是|别|不要|误区|踩坑|翻车|无效/.test(text)) { tactics.push('用“错误认知 vs 正确做法”制造反差,适合做开头冲突。'); } if (/第一|第二|第三|1\.|2\.|3\.|三个|五个|步骤|路径/.test(text)) { tactics.push('把复杂问题拆成步骤或清单,让观众觉得能照着做。'); } if (/案例|举个|身边|我见过|客户|老板|团队|去年|后来|结果/.test(text)) { tactics.push('用具体人或具体场景证明观点,比单纯讲道理更容易被信任。'); } if (/评论|留言|下期|你最|你会|你是不是|你只要/.test(text)) { tactics.push('结尾留一个低门槛问题,方便评论区继续收集选题。'); } return tactics.length ? tactics : ['保留“先给判断、再解释原因、最后给行动”的口播骨架。']; } function shortProfileField(value, fallback, maxLength = 36) { const parts = splitList(value); const text = parts.length ? parts.slice(0, 3).join('、') : cleanText(value); return truncate(text || fallback, maxLength); } function buildAdaptationGuidance(profile) { const industry = shortProfileField(profile.industry, '当前行业/品类'); const audience = shortProfileField(profile.targetAudience, '目标人群'); const positioning = profile.accountPositioning ? '当前账号定位' : '账号定位'; const goal = profile.conversionGoal ? '当前转化目标' : '转化目标'; return [ `把原视频里的具体概念替换成「${industry}」里的真实问题,不直接照搬原账号语境。`, `开头先点破「${audience}」最容易误判的一件事,再给你的判断。`, `用「${positioning}」能讲清楚的案例做证明,避免变成泛知识科普。`, `结尾动作服务「${goal}」,可以引导评论、咨询、收藏或下一条追问。` ]; } function buildTranscriptInsights(transcript, profile) { const text = cleanText(transcript?.text); if (!text) return undefined; const sentences = splitTranscriptSentences(text); const topLines = uniqueTexts([...sentences] .sort((a, b) => scoreReusableSentence(b) - scoreReusableSentence(a)) .slice(0, 8)) .slice(0, 4) .map(item => truncate(item, 72)); return { provider: transcript.provider || 'unknown', textLength: text.length, openingExcerpt: truncate(text, 96), structure: transcriptStructure(sentences), reusableTactics: inferReusableTactics(text), reusableLines: topLines, adaptationGuidance: buildAdaptationGuidance(profile), avoidCopying: [ '不要复刻原作者人设、口头禅和私域承诺,只迁移结构。', '不要把原视频案例当成自己的案例,必须替换成当前账号能验证的事实。', '不要因为有逐字稿就逐句改写,优先抽取钩子、转折、证明方式和结尾动作。' ] }; } function profileListText(value, fallback, limit = 2) { const parts = splitList(value); if (parts.length) return parts.slice(0, limit).join('、'); return cleanText(value) || fallback; } function scriptDeliveryContext(profile, topic = '') { const industry = profileListText(profile.industry, topic || '当前赛道', 2); const audience = profileListText(profile.targetAudience, '目标用户', 2); const positioning = cleanText(profile.accountPositioning) || '专业内容账号'; const goal = profileListText(profile.conversionGoal, '评论区互动和咨询转化', 1); const offer = profileListText(profile.coreOffer, '你的方法论、产品或服务', 1); const joined = `${industry} ${audience} ${positioning} ${goal} ${offer} ${topic}`; const isB2B = /B2B|b2b|企业|老板|决策|获客|咨询|线索|SaaS|saas/.test(joined); const isCommerce = /电商|消费|品牌|零售|店铺|投放|私域|转化|复购|客单/.test(joined); const isEducation = /教育|课程|培训|学习|老师|知识|社群|训练营/.test(joined); const isLocal = /门店|本地|到店|餐饮|同城|探店|预约/.test(joined); const userSignal = isB2B ? '决策顾虑、预算疑问和信任门槛' : isCommerce ? '购买犹豫、评论追问和复购障碍' : isEducation ? '学习卡点、理解误区和行动阻力' : isLocal ? '到店顾虑、选择理由和预约阻力' : '真实疑问、行动阻力和反馈信号'; const actionSurface = isB2B ? '内容、销售沟通和服务包装' : isCommerce ? '脚本、货品表达和转化动作' : isEducation ? '选题、课程表达和学习路径' : isLocal ? '内容、门店卖点和预约动作' : '内容表达、交付动作和下一步转化'; const proofSource = isB2B ? '客户问题、销售反馈或真实案例' : isCommerce ? '评论、成交反馈或用户使用场景' : isEducation ? '学员问题、练习结果或前后对比' : isLocal ? '顾客评价、到店反馈或服务场景' : '评论、案例、数据或用户反馈'; const scenarioLabel = isB2B ? '同样是做企业获客' : isCommerce ? '同样是做内容转化' : isEducation ? '同样是做知识内容' : isLocal ? '同样是做本地获客' : `同样是做${topic || industry}`; const actionQuestion = isB2B ? '这条内容能不能帮你判断客户为什么还没下决策' : isCommerce ? '这条内容能不能帮你判断用户为什么还没下单' : isEducation ? '这条内容能不能帮你判断用户到底卡在哪一步' : isLocal ? '这条内容能不能帮你判断用户为什么还没预约或到店' : '这条内容能不能帮你判断用户为什么还没有行动'; return { industry, audience, positioning, goal, offer, userSignal, actionSurface, proofSource, scenarioLabel, actionQuestion }; } function buildDraftScript({ profile, video, conflict, questions, transcriptInsights }) { if (!transcriptInsights) return undefined; const topic = video.keyword || splitList(profile.keywords)[0] || profileListText(profile.industry, '当前赛道', 1); const ctx = scriptDeliveryContext(profile, topic); const commentQuestion = truncate(questions[0]?.text || '你现在最想解决哪一步?', 34); const title = `别再照抄爆款,先把「${topic}」变成可执行判断`; const segments = [ { label: '开头钩子', text: `${ctx.audience}做${topic},最危险的不是没追到热点,而是把别人的热闹误判成自己的机会。看起来在学爆款,实际是在替别人验证内容。` }, { label: '反差判断', text: `真正能复用的不是原视频那句话,而是它背后的三件事:它击中了谁的误判,它用了什么证据让人相信,最后把观众带到哪一个动作。` }, { label: '方法拆解', text: `拿${ctx.industry}来说,第一步不是先追热点,而是先找${ctx.audience}最近反复出现的${ctx.userSignal};第二步,把这个信号翻译成一个明确判断;第三步,用${ctx.proofSource}来证明,而不是只喊一句口号。` }, { label: '账号改写', text: `所以这条内容不要讲成泛泛的${topic}科普,而要讲成:为什么很多人看起来在行动,实际只是在复制动作;为什么${ctx.offer}要先找到真实用户信号,再决定${ctx.actionSurface}。` }, { label: '结尾转化', text: `如果你也有一个方向,看起来能讲但不知道怎么讲,评论区告诉我「${commentQuestion}」,我可以按这个框架帮你拆成一条更容易被理解、被收藏、被咨询的口播。` } ]; return { status: 'draft', sourceAwemeId: video.id, title, theme: `${ctx.industry} / ${ctx.audience} / ${topic}`, estimatedDurationSec: 70, rewriteBasis: [ '沿用原视频“先解释概念误区,再拆步骤,再给行动”的结构。', '保留类比、反差、清单、案例证明四类爆款打法。', '替换原视频具体人设、案例和承诺,改成当前账号 profile 中的行业、人群、定位和转化目标。' ], segments, fullText: segments.map(item => item.text).join('\n\n'), cta: `围绕「${ctx.goal}」收口,不做泛娱乐或纯搬运。` }; } function profileMatchNote(profile, video) { const keywordHit = profile.keywords.find(keyword => { const pattern = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); return pattern.test(`${video.keyword} ${video.title}`); }); if (keywordHit) return `匹配监听关键词「${keywordHit}」`; if (video.keyword) return `来自采集关键词「${video.keyword}」,未命中 profile 关键词文本`; return '未标注关键词,需要人工确认是否贴合账号方向'; } function confidenceForTopic(profile, video, commentScope, transcript) { let score = 0; const reasons = []; if (video.likeCount || video.commentCount || video.shareCount || video.playCount) { score += 1; reasons.push('有互动数据'); } if (commentScope.scope === 'linked' && commentScope.comments.length >= 3) { score += 2; reasons.push('有匹配评论'); } else if (commentScope.comments.length) { score += 1; reasons.push('评论未带视频关联'); } if (transcript?.text) { score += 2; reasons.push('有逐字稿'); } if (profile.keywords.length && profileMatchNote(profile, video).startsWith('匹配')) { score += 1; reasons.push('贴合监听关键词'); } const label = score >= 5 ? '高' : score >= 3 ? '中' : '低'; return { label, score, reason: reasons.join('、') || '证据不足' }; } function commentScopeText(scope) { if (scope === 'linked') return '评论已按视频 ID 匹配'; if (scope === 'single_video_unlinked') return '单视频样本,评论未带视频 ID,按同一视频处理'; return '评论未匹配到该视频,未纳入本选题证据'; } function buildEvidenceChain(profile, video, commentScope, transcript) { const stats = [ video.playCount ? `播放 ${video.playCount}` : '', video.likeCount ? `点赞 ${video.likeCount}` : '', video.commentCount ? `评论 ${video.commentCount}` : '', video.shareCount ? `分享 ${video.shareCount}` : '' ].filter(Boolean).join(' / ') || '互动数据未提供'; return [ `来源视频:${video.author || '未知作者'} / ${video.id} / ${stats}`, `Profile 匹配:${profileMatchNote(profile, video)}`, `评论口径:${commentScopeText(commentScope.scope)},可用评论 ${commentScope.comments.length} 条`, `逐字稿:${transcript?.text ? `已提供(${transcript.provider || 'unknown'})` : '未提供,脚本结构基于描述和评论推断'}` ]; } function buildTrendSignals(profile, data) { const keywordCounts = countBy(data.videos.map(video => video.keyword).concat(data.comments.map(comment => comment.keyword))).slice(0, 8); const hashtagCounts = countBy(data.videos.flatMap(video => video.hashtags)).slice(0, 8); const topAuthors = countBy(data.videos.map(video => video.author)).slice(0, 6); const signals = []; if (keywordCounts[0]) signals.push(`关键词「${keywordCounts[0].label}」样本最多,适合优先拆解选题。`); if (hashtagCounts[0]) signals.push(`话题「${hashtagCounts[0].label}」出现频率最高,可作为发布标签候选。`); if (topAuthors[0]) signals.push(`账号「${topAuthors[0].label}」在样本中出现最多,建议纳入对标观察。`); if (!signals.length && profile.keywords.length) signals.push(`已建立 ${profile.keywords.length} 个监听关键词,下一步需要采集抖音样本。`); return { keywordCounts, hashtagCounts, topAuthors, signals }; } function buildTopicIdea(video, comments, index, totalVideos, transcripts, profile) { const commentScope = commentsForVideo(comments, video.id, totalVideos); const transcript = transcriptForVideo(transcripts, video.id); const transcriptInsights = buildTranscriptInsights(transcript, profile); const hookType = inferHookType(video.title); const conflict = inferConflict(video.title); const scopedTopComments = topComments(commentScope.comments); const questions = questionComments(scopedTopComments.length ? scopedTopComments : commentScope.comments); const confidence = confidenceForTopic(profile, video, commentScope, transcript); const evidence = [ video.playCount ? `播放 ${video.playCount}` : '', video.likeCount ? `点赞 ${video.likeCount}` : '', video.commentCount ? `评论 ${video.commentCount}` : '', video.shareCount ? `分享 ${video.shareCount}` : '' ].filter(Boolean).join(' / ') || '互动数据不足'; const title = video.title ? `把「${truncate(video.title, 32)}」改成你的账号口播` : `围绕「${video.keyword || `选题${index + 1}`}」做一条口播`; return { priority: index < 3 ? 'P0' : index < 6 ? 'P1' : 'P2', title, sourceVideo: { id: video.id, author: video.author, keyword: video.keyword, url: video.url, evidence }, hookType, angle: conflict, scriptFrame: [ `开头:先抛出一个具体判断,参考「${truncate(video.title, 42)}」。`, `中段:解释为什么用户会卡在「${conflict}」这里。`, '证明:用 2-3 个案例、数字或反例支撑,不要只喊口号。', `互动:结尾问「${truncate(questions[0]?.text || '你最想解决哪一步?', 36)}」引导评论。` ], commentTriggers: questions.map(comment => comment.text), transcriptInsights, draftScript: buildDraftScript({ profile, video, conflict, questions, transcriptInsights }), evidenceChain: buildEvidenceChain(profile, video, commentScope, transcript), confidence, riskNotes: [ ...(transcript?.text ? [] : ['未提供逐字稿,不能还原完整原文,只能拆结构。']), ...(commentScope.comments.length ? [] : ['未匹配到该视频评论,建议补抓评论后再作为正式脚本。']), ...(commentScope.scope === 'single_video_unlinked' ? ['评论缺少视频 ID,仅因样本只有一条视频而暂时归并。'] : []) ] }; } function nextQuestionsForMissingFields(profile) { const questionMap = { industry: '这个口播账号服务什么行业或品类?', accountPositioning: '账号定位更接近老板 IP、专家号、测评号、品牌号,还是别的形态?', targetAudience: '你最想吸引的人是谁?请给一个具体人群和他们最常见的痛点。', conversionGoal: '这个账号优先追求涨粉、咨询、卖课、卖货,还是品牌认知?', keywords: '先给我 3-5 个想长期监听的行业词、痛点词或对标账号。' }; return requiredProfileFields(profile) .filter(item => !item.ok) .map(item => questionMap[item.field]) .filter(Boolean); } function buildPrecisionCheck(profile, data, topicIdeas) { const transcriptCount = data.transcripts.filter(item => item.text).length; const highConfidenceCount = topicIdeas.filter(item => item.confidence.label === '高').length; const mediumConfidenceCount = topicIdeas.filter(item => item.confidence.label === '中').length; const profileComplete = requiredProfileFields(profile).every(item => item.ok); return { profileComplete, sampleScope: `${data.videos.length} 条视频 / ${data.comments.length} 条评论 / ${transcriptCount} 条逐字稿`, confidenceSummary: `${highConfidenceCount} 条高置信 / ${mediumConfidenceCount} 条中置信 / ${Math.max(0, topicIdeas.length - highConfidenceCount - mediumConfidenceCount)} 条低置信`, evidenceRules: [ '每条选题优先只使用能按 aweme_id 匹配到该视频的评论。', '没有逐字稿时只做结构推断,不把视频描述当完整口播稿。', '日报给出候选方向和证据链,最终取舍留给用户确认。' ], nextQuestions: nextQuestionsForMissingFields(profile) }; } function buildQualityGate(profile, data, topicIdeas) { const transcriptCount = data.transcripts.filter(item => item.text).length; const commentMatchedCount = data.comments.filter(comment => comment.parentId).length; const sampleEnough = data.videos.length >= 3; const commentCoverage = data.videos.length ? commentMatchedCount / data.videos.length : 0; const transcriptCoverage = data.videos.length ? transcriptCount / data.videos.length : 0; const profileComplete = requiredProfileFields(profile).every(item => item.ok); const issues = []; if (!data.videos.length) issues.push('缺少视频样本,不能生成有效日报。'); if (!profileComplete) issues.push('profile 关键字段未补齐,成稿只能做弱适配。'); if (data.videos.length && !sampleEnough) issues.push('视频样本少于 3 条,选题排序稳定性偏弱。'); if (data.videos.length && !data.comments.length) issues.push('缺少评论样本,用户问题和互动钩子偏弱。'); if (data.videos.length && transcriptCoverage === 0) issues.push('没有逐字稿,脚本只能基于标题、描述和评论做结构推断。'); const level = !data.videos.length ? 'fail' : issues.length ? 'warn' : 'pass'; return { level, sampleEnough, profileComplete, transcriptCoverage: Number(transcriptCoverage.toFixed(2)), commentCoverage: Number(commentCoverage.toFixed(2)), issues, recommendation: level === 'fail' ? '先补采抖音视频样本,再生成日报和脚本。' : level === 'warn' ? '可以进入共创,但需要在正文中保留风险说明,并优先补评论或逐字稿。' : '样本、评论和逐字稿信号满足本轮成稿要求。' }; } function buildReport(profile, data, options = {}) { const scriptMemory = options.scriptMemory || {}; const sortedVideos = [...data.videos].sort((a, b) => { const bScore = videoScore(b) + topicPreferenceScore(b, profile, scriptMemory); const aScore = videoScore(a) + topicPreferenceScore(a, profile, scriptMemory); return bScore - aScore; }); const selected = sortedVideos.slice(0, Math.max(1, profile.dailyOutputCount || 8)); const topicIdeas = selected.map((video, index) => buildTopicIdea(video, data.comments, index, data.videos.length, data.transcripts, profile)); const trends = buildTrendSignals(profile, data); const warnings = []; const missing = requiredProfileFields(profile).filter(item => !item.ok).map(item => item.field); if (missing.length) warnings.push(`profile 缺少字段:${missing.join(', ')}`); if (!data.videos.length) warnings.push('未提供抖音视频样本,日报只能输出监听配置和采集建议。'); if (data.videos.length && !data.comments.length) warnings.push('缺少评论样本,评论触发点和用户问题会偏弱。'); const qualityGate = buildQualityGate(profile, data, topicIdeas); qualityGate.issues.forEach(issue => { if (!warnings.includes(issue)) warnings.push(issue); }); const status = data.videos.length ? 'ok' : 'needs_data'; return { status, generatedAt: new Date().toISOString(), profile, metadata: { ...(data.metadata || {}), scriptMemory: options.scriptMemorySummary || undefined }, summary: { videoCount: data.videos.length, commentCount: data.comments.length, hashtagCount: data.hashtags.length, transcriptCount: data.transcripts.filter(item => item.text).length, transcriptInsightCount: topicIdeas.filter(item => item.transcriptInsights).length, draftScriptCount: topicIdeas.filter(item => item.draftScript).length, topicIdeaCount: topicIdeas.length, keywordCount: profile.keywords.length, referenceAccountCount: profile.referenceAccounts.length }, oneLineJudgement: data.videos.length ? `今天优先围绕「${topicIdeas[0]?.angle || profile.industry || '核心行业'}」做一条有冲突、有证据、有评论钩子的口播。` : `先补齐「${profile.keywords.slice(0, 3).join('、') || profile.industry || '行业关键词'}」的抖音样本,再生成正式稿件日报。`, trendSignals: trends, precisionCheck: buildPrecisionCheck(profile, data, topicIdeas), qualityGate, topVideos: selected.map(video => ({ id: video.id, title: video.title, author: video.author, keyword: video.keyword, score: Math.round(videoScore(video)), likeCount: video.likeCount, commentCount: video.commentCount, shareCount: video.shareCount, playCount: video.playCount, url: video.url })), topicIdeas, calibrationQuestions: [ '这 5-10 条选题里,哪 3 条最像你的账号能讲的内容?', '哪些方向不要再出现?我会写入禁区并在下次降权。', '你希望下一版口播更偏老板视角、专家视角、测评视角,还是故事视角?', '是否要新增/删除监听关键词或对标账号?' ], warnings }; } function requiredProfileFields(profile) { return [ { field: 'industry', ok: Boolean(profile.industry) }, { field: 'accountPositioning', ok: Boolean(profile.accountPositioning) }, { field: 'targetAudience', ok: Boolean(profile.targetAudience) }, { field: 'conversionGoal', ok: Boolean(profile.conversionGoal) }, { field: 'keywords', ok: profile.keywords.length > 0 } ]; } function renderProfile(profile) { return [ `- 项目:${profile.projectName}`, `- 行业/品类:${profile.industry || '待补充'}`, `- 账号定位:${profile.accountPositioning || '待补充'}`, `- 目标人群:${profile.targetAudience || '待补充'}`, `- 转化目标:${profile.conversionGoal || '待补充'}`, `- 核心产品/服务:${profile.coreOffer || '未填写'}`, `- 内容栏目:${profile.contentPillars.join('、') || '待补充'}`, `- 监听关键词:${profile.keywords.join('、') || '待补充'}`, `- 对标账号:${profile.referenceAccounts.join('、') || '待补充'}`, `- 禁区:${profile.forbiddenTopics.join('、') || '未填写'}` ].join('\n'); } function renderMarkdown(report) { const lines = []; lines.push('# 抖音口播每日稿件日报'); lines.push(''); lines.push(`生成时间:${report.generatedAt}`); lines.push(''); lines.push('## 账号配置'); lines.push(''); lines.push(renderProfile(report.profile)); lines.push(''); lines.push('## 今日一句话判断'); lines.push(''); lines.push(report.oneLineJudgement); lines.push(''); lines.push('## 判断口径与置信度'); lines.push(''); lines.push(`- 样本口径:${report.precisionCheck.sampleScope}`); lines.push(`- 选题置信度:${report.precisionCheck.confidenceSummary}`); report.precisionCheck.evidenceRules.forEach(rule => lines.push(`- ${rule}`)); if (report.precisionCheck.nextQuestions.length) { lines.push(''); lines.push('待补资料:'); report.precisionCheck.nextQuestions.forEach(question => lines.push(`- ${question}`)); } lines.push(''); lines.push('## 趋势信号'); lines.push(''); if (report.trendSignals.signals.length) { report.trendSignals.signals.forEach(signal => lines.push(`- ${signal}`)); } else { lines.push('- 暂无趋势信号,需要先采集抖音样本。'); } lines.push(''); lines.push('## 今日选题池'); lines.push(''); if (!report.topicIdeas.length) { lines.push('暂无可生成选题。请先运行 `douyin-speaking-keyword-monitor` 或提供抖音采集数据。'); lines.push(''); } report.topicIdeas.forEach((idea, index) => { lines.push(`### ${index + 1}. [${idea.priority}] ${idea.title}`); lines.push(''); lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.keyword || '未标注关键词'} / ${idea.sourceVideo.evidence}`); if (idea.sourceVideo.url) lines.push(`- 链接:${idea.sourceVideo.url}`); lines.push(`- 钩子类型:${idea.hookType}`); lines.push(`- 选题角度:${idea.angle}`); lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`); lines.push(''); lines.push('证据链:'); idea.evidenceChain.forEach(evidence => lines.push(`- ${evidence}`)); lines.push(''); lines.push('口播框架:'); idea.scriptFrame.forEach(step => lines.push(`- ${step}`)); if (idea.transcriptInsights) { lines.push(''); lines.push('逐字稿拆解:'); lines.push(`- 开头原型:${idea.transcriptInsights.openingExcerpt}`); lines.push(`- 文本长度:${idea.transcriptInsights.textLength} 字 / ${idea.transcriptInsights.provider}`); if (idea.transcriptInsights.structure.length) { lines.push('- 结构节奏:'); idea.transcriptInsights.structure.forEach(item => lines.push(` - ${item.label}:${item.summary}`)); } if (idea.transcriptInsights.reusableTactics.length) { lines.push('- 可复用打法:'); idea.transcriptInsights.reusableTactics.forEach(item => lines.push(` - ${item}`)); } if (idea.transcriptInsights.reusableLines.length) { lines.push('- 可借鉴表达:'); idea.transcriptInsights.reusableLines.forEach(item => lines.push(` - ${item}`)); } lines.push('- 改写到当前账号:'); idea.transcriptInsights.adaptationGuidance.forEach(item => lines.push(` - ${item}`)); lines.push('- 不建议照搬:'); idea.transcriptInsights.avoidCopying.forEach(item => lines.push(` - ${item}`)); } if (idea.draftScript) { lines.push(''); lines.push('对应自己主题的口播稿草案:'); lines.push(`- 标题:${idea.draftScript.title}`); lines.push(`- 主题:${idea.draftScript.theme}`); lines.push(`- 预计时长:${idea.draftScript.estimatedDurationSec} 秒`); lines.push('- 改写依据:'); idea.draftScript.rewriteBasis.forEach(item => lines.push(` - ${item}`)); lines.push(''); lines.push(idea.draftScript.fullText); lines.push(''); lines.push(`- 收口:${idea.draftScript.cta}`); } if (idea.commentTriggers.length) { lines.push(''); lines.push('评论触发点:'); idea.commentTriggers.forEach(text => lines.push(`- ${text}`)); } if (idea.riskNotes.length) { lines.push(''); lines.push('风险提醒:'); idea.riskNotes.forEach(text => lines.push(`- ${text}`)); } lines.push(''); }); lines.push('## 明日监听建议'); lines.push(''); lines.push('- 保留今天表现最好的 3 个关键词,新增 1-2 个评论里出现的追问词。'); lines.push('- 对 P0 选题对应视频补抓评论和热评回复。'); lines.push('- 选 1 条高价值视频进入逐字稿转写,再做更细的口播拆解。'); lines.push(''); if (report.warnings.length) { lines.push('## 审计提醒'); lines.push(''); report.warnings.forEach(warning => lines.push(`- ${warning}`)); lines.push(''); } lines.push('## 校准问题'); lines.push(''); report.calibrationQuestions.forEach(question => lines.push(`- ${question}`)); return lines.join('\n'); } function buildAudit(report) { return { status: report.status, generatedAt: report.generatedAt, warnings: report.warnings, autoTranscript: report.metadata?.autoTranscript || {}, scriptMemory: report.metadata?.scriptMemory || {}, qualityGate: report.qualityGate, precisionCheck: report.precisionCheck, profileCompleteness: requiredProfileFields(report.profile).map(item => ({ field: item.field, ok: item.ok })), sampleCounts: report.summary }; } function renderChatMarkdown(report, mdPath) { const lines = []; const topIdeas = report.topicIdeas.slice(0, 5); const remaining = Math.max(0, report.topicIdeas.length - topIdeas.length); lines.push('# 抖音口播每日稿件日报'); lines.push(''); lines.push('## 今日一句话判断'); lines.push(''); lines.push(report.oneLineJudgement); lines.push(''); lines.push('## 判断口径'); lines.push(''); lines.push(`- 样本:${report.summary.videoCount} 条视频 / ${report.summary.commentCount} 条评论 / ${report.summary.transcriptCount} 条逐字稿`); lines.push(`- 选题:${report.topicIdeas.length} 条(本次直接展示 Top ${topIdeas.length}${remaining ? `,另有 ${remaining} 条已写入本地备份` : ''})`); lines.push(`- 置信度:${report.precisionCheck.confidenceSummary}`); lines.push(`- 质量门禁:${report.qualityGate.level}(${report.qualityGate.recommendation})`); if (asArray(report.metadata?.scriptMemory?.summary).length) { lines.push(`- 历史偏好:${report.metadata.scriptMemory.summary.join(';')}`); } if (report.trendSignals.signals.length) { lines.push(''); lines.push('## 趋势信号'); lines.push(''); report.trendSignals.signals.slice(0, 3).forEach(signal => lines.push(`- ${signal}`)); } if (topIdeas.length) { lines.push(''); lines.push('## 今日热点选题池'); lines.push(''); topIdeas.forEach((idea, index) => { lines.push(`### ${index + 1}. [${idea.priority}] ${idea.title}`); lines.push(''); lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.evidence}`); lines.push(`- 钩子类型:${idea.hookType}`); lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`); lines.push(`- 可讲方向:${idea.angle}`); if (idea.transcriptInsights) { lines.push(`- 逐字稿:已就绪(${idea.transcriptInsights.textLength} 字,${idea.transcriptInsights.provider}),可直接生成脚本`); } else { lines.push('- 逐字稿:未补,适合先确认选题再转写/扩写'); } if (idea.commentTriggers[0]) { lines.push(`- 评论信号:${truncate(idea.commentTriggers[0], 72)}`); } lines.push(''); }); } lines.push(''); lines.push('## 下一步操作'); lines.push(''); lines.push('- 回复「选题 2」:我会展开该选题的逐字稿拆解、借鉴点和完整口播稿。'); lines.push('- 回复「保留 1,2,3」:我会把这些方向写入偏好,下次排序加权。'); lines.push('- 回复「不要 某方向」:我会写入禁区,下次降权。'); lines.push(''); lines.push('## 建议校准'); lines.push(''); lines.push('1. Top 5 里先选哪 1 条进入脚本稿?'); lines.push('2. 下一版更偏老板视角、专家视角、案例拆解,还是产品转化?'); lines.push('3. 要不要新增/删除监听关键词或对标账号?'); if (mdPath) { lines.push(''); lines.push('## 本地备份'); lines.push(''); lines.push(`完整日报已同步保存:${mdPath}`); } return lines.join('\n'); } function renderSelectedTopicDraft(report, selectedIndex, mdPath) { const idea = report.topicIdeas[selectedIndex - 1]; const lines = []; if (!idea) { return `未找到第 ${selectedIndex} 条选题。当前日报共有 ${report.topicIdeas.length} 条选题,请回复有效序号。`; } lines.push(`# 选题 ${selectedIndex} 脚本稿件`); lines.push(''); lines.push(`## 选题`); lines.push(''); lines.push(`- 标题:${idea.title}`); lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.evidence}`); lines.push(`- 链接:${idea.sourceVideo.url || '未提供'}`); lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`); lines.push(`- 可讲方向:${idea.angle}`); lines.push(''); lines.push('## 借鉴点'); lines.push(''); if (idea.transcriptInsights) { lines.push(`- 逐字稿:已提供(${idea.transcriptInsights.provider},${idea.transcriptInsights.textLength} 字)`); lines.push(`- 开头原型:${idea.transcriptInsights.openingExcerpt}`); if (idea.transcriptInsights.structure.length) { lines.push('- 结构节奏:'); idea.transcriptInsights.structure.slice(0, 4).forEach(item => lines.push(` - ${item.label}:${item.summary}`)); } if (idea.transcriptInsights.reusableTactics.length) { lines.push('- 可复用打法:'); idea.transcriptInsights.reusableTactics.slice(0, 4).forEach(item => lines.push(` - ${item}`)); } if (idea.transcriptInsights.reusableLines.length) { lines.push('- 可借鉴表达:'); idea.transcriptInsights.reusableLines.slice(0, 3).forEach(item => lines.push(` - ${item}`)); } } else { lines.push('- 该选题还没有逐字稿,下面先给结构稿;正式拍摄前建议补转写。'); idea.scriptFrame.slice(0, 4).forEach(step => lines.push(`- ${step}`)); } lines.push(''); lines.push('## 初版口播稿'); lines.push(''); if (idea.draftScript) { lines.push(`### ${idea.draftScript.title}`); lines.push(''); lines.push(`- 主题:${idea.draftScript.theme}`); lines.push(`- 预计时长:${idea.draftScript.estimatedDurationSec} 秒`); lines.push('- 改写依据:'); idea.draftScript.rewriteBasis.forEach(item => lines.push(` - ${item}`)); lines.push(''); lines.push(idea.draftScript.fullText); lines.push(''); lines.push(`收口:${idea.draftScript.cta}`); } else { lines.push(idea.scriptFrame.join('\n')); } if (idea.commentTriggers.length) { lines.push(''); lines.push('## 可用评论钩子'); lines.push(''); idea.commentTriggers.slice(0, 3).forEach(text => lines.push(`- ${text}`)); } if (idea.riskNotes.length) { lines.push(''); lines.push('## 风险提醒'); lines.push(''); idea.riskNotes.forEach(text => lines.push(`- ${text}`)); } lines.push(''); lines.push('## 请确认'); lines.push(''); lines.push('1. 这个方向是否保留?'); lines.push('2. 开头要更强冲突、更专业,还是更像老板聊天?'); lines.push('3. 是否需要我继续改成 30 秒、60 秒或 90 秒版本?'); if (mdPath) { lines.push(''); lines.push(`日报备份:${mdPath}`); } return lines.join('\n'); } function fallbackScriptForIdea(profile, idea) { const topic = resolveScriptTopicKeyword(profile, idea); const ctx = scriptDeliveryContext(profile, topic); const sourceTitle = cleanText(idea?.sourceVideo?.title || idea?.title || '这条爆款'); const hook = `${ctx.audience}做${topic},最容易误判的不是热点本身,而是把别人内容里的结论,直接当成自己的动作。`; return [ hook, `这条爆款真正值得借鉴的,不是它讲了「${truncate(sourceTitle, 34)}」,而是它先制造了一个反差:观众以为自己懂了,结果发现真正的问题在另一个地方。`, `换到${ctx.industry},我们要讲的不是泛泛的${topic}科普,而是先指出一个错误动作:很多人看起来在跟热点,实际上只是在复制标题、复制动作、复制别人的判断。`, `正确做法可以拆成三步:第一,找到${ctx.audience}最近反复出现的${ctx.userSignal};第二,把这个信号翻译成一个明确判断;第三,用${ctx.proofSource}证明它。`, `所以这条稿子可以这样收:如果你也有一个方向,看起来能讲但不知道怎么讲,评论区告诉我你现在卡在哪一步,我会按这个框架帮你拆成能被理解、被收藏、被咨询的口播。`, `收口:围绕「${ctx.goal}」收口,不做泛娱乐或纯搬运。` ].join('\n\n'); } function baseScriptForIdea(idea, profile = {}) { if (!idea) return ''; if (idea.draftScript?.fullText) return idea.draftScript.fullText; const frame = asArray(idea.scriptFrame).map(item => String(item || '').trim()).filter(Boolean); if (!frame.length) return fallbackScriptForIdea(profile, idea); return [ fallbackScriptForIdea(profile, idea), '结构备忘:', ...frame.map(item => `- ${item}`) ].join('\n\n'); } function baseTitleForIdea(idea, profile = {}) { if (idea?.draftScript?.title) return idea.draftScript.title; const topic = resolveScriptTopicKeyword(profile, idea); return topic ? `别再照抄爆款,先把「${topic}」变成可执行判断` : idea?.title || '口播稿'; } function resolveScriptTopicKeyword(profile, idea) { const raw = cleanText( idea?.draftScript?.theme || idea?.sourceVideo?.keyword || splitList(profile?.keywords)[0] || profileListText(profile?.industry, '', 1) || idea?.topicAngle || idea?.angle || '这个方向' ); const themeParts = raw.split('/').map(item => cleanText(item)).filter(Boolean); return themeParts.slice(-1)[0] || raw; } function scriptTopicText(session, fallback = '这个方向') { return cleanText( session?.topicKeyword || session?.sourceEvidence?.keyword || session?.profileSnapshot?.primaryKeyword || profileListText(session?.profileSnapshot?.industry, '', 1) || session?.topicAngle || fallback ); } function scriptParagraphs(text) { return String(text || '') .split(/\n\s*\n/) .map(item => cleanText(item)) .filter(Boolean); } function makeQualityGateBlockedSession({ profile, report, selectedTopicIndex, scriptMemory, scriptMemoryPath }) { const now = new Date().toISOString(); const memorySummary = scriptMemorySummary(scriptMemory || {}, scriptMemoryPath || ''); const issues = asArray(report.qualityGate?.issues); const repairLines = [ '质量门禁未通过,先不生成正式口播稿。', '', '当前缺口:', ...(issues.length ? issues.map(item => `- ${item}`) : ['- 当前样本不足,无法形成可靠选题。']), '', '建议先补齐:', '- 至少 3 条候选视频样本', '- 每条 P0/P1 候选视频尽量有评论样本', '- 至少 1 条高价值视频逐字稿', '- profile 中的项目、行业、目标人群、转化目标、关键词', '', '补齐后再回复“重新跑日报”或重新选择选题进入脚本共创。' ].join('\n'); return { version: 1, sessionId: `script_blocked_${selectedTopicIndex || Date.now()}`, projectName: profile.projectName, status: 'blocked', createdAt: now, updatedAt: now, selectedTopicIndex, sourceAwemeId: '', topicTitle: '质量门禁未通过,暂不生成正式稿', sourceEvidence: {}, profileSnapshot: { industry: profile.industry, accountPositioning: profile.accountPositioning, targetAudience: profile.targetAudience, conversionGoal: profile.conversionGoal, coreOffer: profile.coreOffer, primaryKeyword: profile.keywords[0] || '' }, transcriptInsights: null, commentTriggers: [], riskNotes: issues, qualityGate: report.qualityGate, memorySnapshot: memorySummary, versions: [{ version: 'v0', status: 'blocked', title: '先补数据,再生成正式稿', script: repairLines, strategy: 'qualityGate.fail', changeNotes: ['已阻断正式脚本生成,避免把低证据内容包装成可交付稿件。'], createdAt: now }], feedbackTurns: [], finalScript: null, memoryWrites: {} }; } function makeScriptSession({ profile, report, idea, selectedTopicIndex, existingSession, scriptMemory, scriptMemoryPath }) { const now = new Date().toISOString(); const memorySummary = scriptMemorySummary(scriptMemory || {}, scriptMemoryPath || ''); if (existingSession?.sessionId) { return { ...existingSession, topicKeyword: existingSession.topicKeyword || resolveScriptTopicKeyword(profile, idea), memorySnapshot: existingSession.memorySnapshot || memorySummary, qualityGate: existingSession.qualityGate || report.qualityGate, updatedAt: now }; } if (!idea && report.qualityGate?.level === 'fail') { return makeQualityGateBlockedSession({ profile, report, selectedTopicIndex, scriptMemory, scriptMemoryPath }); } if (!idea) { return { version: 1, sessionId: `script_missing_${selectedTopicIndex || Date.now()}`, projectName: profile.projectName, status: 'error', createdAt: now, updatedAt: now, selectedTopicIndex, sourceAwemeId: '', topicTitle: `未找到第 ${selectedTopicIndex} 条选题`, sourceEvidence: {}, profileSnapshot: {}, transcriptInsights: null, commentTriggers: [], riskNotes: [`当前日报共有 ${report.topicIdeas.length} 条选题,请传入有效 selectedTopicIndex。`], versions: [{ version: 'v0', status: 'error', title: '未找到选题', script: `未找到第 ${selectedTopicIndex} 条选题。当前日报共有 ${report.topicIdeas.length} 条选题,请重新选择。`, strategy: 'invalid selectedTopicIndex', changeNotes: [], createdAt: now }], feedbackTurns: [], finalScript: null, memoryWrites: {} }; } const sessionId = `script_${idea.sourceVideo.id || selectedTopicIndex}_${Date.now()}`; const baseVersion = { version: 'v0', status: 'draft', title: baseTitleForIdea(idea, profile), script: baseScriptForIdea(idea, profile), cta: idea.draftScript?.cta || '', strategy: '基于日报选题、来源视频结构、评论信号和当前账号 profile 生成的初版共创稿。', changeNotes: [ '迁移原视频结构和证明方式,不复刻原作者人设。', idea.transcriptInsights ? '已引用逐字稿结构拆解。' : '缺少逐字稿,当前为结构推断稿。' ], createdAt: now }; const session = { version: 1, sessionId, projectName: profile.projectName, status: 'draft', createdAt: now, updatedAt: now, selectedTopicIndex, sourceAwemeId: idea.sourceVideo.id, topicTitle: idea.title, topicKeyword: resolveScriptTopicKeyword(profile, idea), topicAngle: idea.angle, sourceEvidence: { author: idea.sourceVideo.author, url: idea.sourceVideo.url, evidence: idea.sourceVideo.evidence, confidence: idea.confidence }, profileSnapshot: { industry: profile.industry, accountPositioning: profile.accountPositioning, targetAudience: profile.targetAudience, conversionGoal: profile.conversionGoal, coreOffer: profile.coreOffer, primaryKeyword: resolveScriptTopicKeyword(profile, idea), tone: profile.tone }, transcriptInsights: idea.transcriptInsights || null, commentTriggers: idea.commentTriggers || [], riskNotes: idea.riskNotes || [], qualityGate: report.qualityGate, memorySnapshot: memorySummary, versions: [baseVersion], feedbackTurns: [], finalScript: null, memoryWrites: {} }; const memoryPreference = preferenceFromScriptMemory(scriptMemory || {}); if (memoryPreference.summary.length) { baseVersion.script = reviseScriptText(session, memoryPreference); baseVersion.strategy += ` 已参考脚本偏好记忆:${memoryPreference.summary.join('、')}。`; baseVersion.changeNotes.push(...memoryPreference.summary); } return session; } function latestScriptVersion(session) { return asArray(session.versions).filter(Boolean).slice(-1)[0] || {}; } function nextVersionName(session) { return `v${asArray(session.versions).length}`; } function parseScriptFeedback(feedback) { const text = cleanText(feedback); const preference = { raw: text, tone: '', hookStrength: '', duration: '', ctaStyle: '', add: [], avoid: [], summary: [] }; if (/老板|经营者|决策/i.test(text)) { preference.tone = '老板视角'; preference.summary.push('更偏老板/经营决策视角'); } else if (/专家|专业|顾问|方法论/i.test(text)) { preference.tone = '专家视角'; preference.summary.push('更偏专家/方法论视角'); } else if (/案例|场景|具体|B2B|b2b/i.test(text)) { preference.tone = '案例拆解'; } if (/案例|场景|具体|B2B|b2b|客户|行业/i.test(text)) { if (!preference.add.includes('具体场景')) preference.add.push('具体场景'); if (!preference.summary.includes('增加具体场景或案例感')) preference.summary.push('增加具体场景或案例感'); } if (/狠|冲突|尖锐|抓人|痛点|开头/i.test(text)) { preference.hookStrength = 'strong'; preference.summary.push('开头冲突更强'); } if (/30\s*秒|30s|更短|短一点/i.test(text)) { preference.duration = '30s'; preference.summary.push('压缩成短稿'); } else if (/90\s*秒|90s|更长|展开/i.test(text)) { preference.duration = '90s'; preference.summary.push('展开成更完整版本'); } else if (/60\s*秒|60s/i.test(text)) { preference.duration = '60s'; preference.summary.push('控制在 60 秒左右'); } if (/转化|咨询|预约|成交|产品|服务|私域/i.test(text)) { preference.ctaStyle = '产品转化'; preference.summary.push('结尾更偏产品/咨询转化'); } if (/不要|去掉|别|不想|太泛|泛泛|空/i.test(text)) { preference.avoid.push('泛泛表达'); preference.summary.push('减少泛泛表达'); } if (!preference.summary.length) preference.summary.push('按用户反馈收紧表达和结构'); return preference; } function revisedHook(session, preference) { const topic = scriptTopicText(session); const ctx = scriptDeliveryContext(session.profileSnapshot || {}, topic); if (preference.hookStrength === 'strong') { return `${ctx.audience}做${topic},最危险的不是没追热点,而是把热闹误判成机会。看起来在追爆款,实际是在替别人验证内容。`; } if (preference.tone === '老板视角') { return `老板看${topic},先别问工具好不好用,先问它能不能帮你少做一次错误决策。`; } if (preference.tone === '专家视角') { return `判断${topic}值不值得做,不能只看播放量,要先看它背后的误判、证据和用户行动。`; } return ''; } function reviseScriptText(session, preference) { const latest = latestScriptVersion(session); const base = scriptParagraphs(latest.script); const profileSnapshot = session.profileSnapshot || {}; const topic = scriptTopicText(session); const ctx = scriptDeliveryContext(profileSnapshot, topic); const offer = profileListText(profileSnapshot.coreOffer, '你的方法论、产品或服务', 1); const hook = revisedHook(session, preference) || base[0] || ''; const paragraphs = [hook, ...base.slice(1)]; if (preference.tone === '老板视角') { if (!paragraphs.some(item => /老板要的不是/.test(item))) { paragraphs.splice(1, 0, `老板要的不是多一个内容动作,而是少一次无效试错:${ctx.actionQuestion},下一步该怎么验证。`); } } if (preference.tone === '专家视角') { if (!paragraphs.some(item => /三步判断/.test(item))) { paragraphs.splice(1, 0, `可以用一个三步判断:第一,看它击中了什么错误认知;第二,看它用了什么证据建立信任;第三,看它把用户推向了什么行动。`); } } if (preference.tone === '案例拆解' || preference.add.includes('具体场景')) { if (!paragraphs.some(item => /^举个场景/.test(item))) { const insertAt = Math.min(preference.tone === '老板视角' ? 2 : 1, paragraphs.length); paragraphs.splice(insertAt, 0, `举个场景:${ctx.scenarioLabel},一种做法只看爆款标题,另一种做法先看${ctx.userSignal},再决定内容角度,最后产出的结果完全不同。`); } } if (preference.ctaStyle === '产品转化') { paragraphs[paragraphs.length - 1] = `如果你也想把${ctx.audience}的真实问题变成可执行的选题、内容和转化判断,可以先从一次小样本诊断开始。评论区告诉我你现在卡在哪一步,我会按这个框架帮你拆。`; } if (preference.avoid.includes('泛泛表达')) { if (!paragraphs.some(item => /不要停在/.test(item))) { paragraphs.push(`这条内容不要停在“要做${offer}”这种口号上,要落到一个具体判断:今天到底该保留哪个选题、删掉哪个方向、用哪条证据说服用户。`); } } if (preference.duration === '30s') { return [paragraphs[0], paragraphs.find(item => /三步|第一|判断|证据/.test(item)) || paragraphs[1], paragraphs[paragraphs.length - 1]] .filter(Boolean) .join('\n\n'); } if (preference.duration === '90s') { paragraphs.splice(Math.min(3, paragraphs.length), 0, `这里真正的关键,不是“照着爆款写一句类似的话”,而是把爆款背后的结构翻译成你自己的业务证据。没有证据,就只是模仿;有证据,才可能变成信任。`); } return paragraphs.filter(Boolean).join('\n\n'); } function applyFeedbackToSession(session, feedback) { if (session.status === 'blocked') { session.updatedAt = new Date().toISOString(); return session; } const preference = parseScriptFeedback(feedback); const version = nextVersionName(session); const revisedScript = reviseScriptText(session, preference); const now = new Date().toISOString(); session.feedbackTurns = asArray(session.feedbackTurns); session.versions = asArray(session.versions); session.feedbackTurns.push({ turn: session.feedbackTurns.length + 1, userFeedback: cleanText(feedback), parsedPreference: preference, appliedVersion: version, createdAt: now }); session.versions.push({ version, status: 'draft', title: latestScriptVersion(session).title || '共创稿', script: revisedScript, cta: latestScriptVersion(session).cta || '', strategy: `根据用户反馈调整:${preference.summary.join('、')}`, changeNotes: preference.summary, createdAt: now }); session.status = 'draft'; session.updatedAt = now; return session; } function defaultScriptMemoryPath(args) { const configured = args.scriptMemory || args['script-memory']; return path.resolve(hasConcreteArg(configured) ? configured : path.join('memory', 'douyin-speaking-script-memory.json')); } function normalizeScriptMemory(memory) { const normalized = memory && typeof memory === 'object' ? { ...memory } : {}; normalized.sessions = asArray(normalized.sessions); normalized.preferenceSignals = normalized.preferenceSignals || {}; normalized.preferenceSignals.tones = normalized.preferenceSignals.tones || {}; normalized.preferenceSignals.hookStrength = normalized.preferenceSignals.hookStrength || {}; normalized.preferenceSignals.ctaStyles = normalized.preferenceSignals.ctaStyles || {}; normalized.preferenceSignals.avoid = normalized.preferenceSignals.avoid || {}; normalized.preferenceSignals.add = normalized.preferenceSignals.add || {}; normalized.topicPreferences = normalized.topicPreferences || {}; normalized.topicPreferences.keepPhrases = normalized.topicPreferences.keepPhrases || {}; normalized.topicPreferences.dropPhrases = normalized.topicPreferences.dropPhrases || {}; return normalized; } function readScriptMemory(memoryPath) { return normalizeScriptMemory(readJsonMaybe(memoryPath)); } function topPreference(bucket) { const entries = Object.entries(bucket || {}).filter(([, value]) => Number(value) > 0); entries.sort((a, b) => Number(b[1]) - Number(a[1])); return entries[0]?.[0] || ''; } function preferenceFromScriptMemory(memory) { const normalized = normalizeScriptMemory(memory); const preference = { raw: 'script-memory', tone: topPreference(normalized.preferenceSignals.tones), hookStrength: topPreference(normalized.preferenceSignals.hookStrength), duration: '', ctaStyle: topPreference(normalized.preferenceSignals.ctaStyles), add: topPreference(normalized.preferenceSignals.add) ? [topPreference(normalized.preferenceSignals.add)] : [], avoid: topPreference(normalized.preferenceSignals.avoid) ? [topPreference(normalized.preferenceSignals.avoid)] : [], summary: [] }; if (preference.tone) preference.summary.push(`沿用历史偏好:${preference.tone}`); if (preference.hookStrength) preference.summary.push('沿用历史偏好:开头冲突更强'); if (preference.ctaStyle) preference.summary.push(`沿用历史偏好:${preference.ctaStyle}`); if (preference.add.length) preference.summary.push(`沿用历史偏好:增加${preference.add.join('、')}`); if (preference.avoid.length) preference.summary.push(`沿用历史偏好:避免${preference.avoid.join('、')}`); return preference; } function scriptMemorySummary(memory, memoryPath) { const normalized = normalizeScriptMemory(memory); const preference = preferenceFromScriptMemory(normalized); const keepCount = Object.keys(normalized.topicPreferences.keepPhrases || {}).length; const dropCount = Object.keys(normalized.topicPreferences.dropPhrases || {}).length; return { path: memoryPath || '', sessionCount: normalized.sessions.length, preference, summary: [ ...preference.summary, ...(keepCount ? [`保留方向 ${keepCount} 个`] : []), ...(dropCount ? [`降权方向 ${dropCount} 个`] : []) ], topicPreferences: normalized.topicPreferences }; } function defaultHistoryMemoryPath(args) { const configured = args.historyMemory || args['history-memory']; return path.resolve(hasConcreteArg(configured) ? configured : path.join('memory', 'douyin-speaking-history.json')); } function normalizeHistoryMemory(memory) { const normalized = memory && typeof memory === 'object' ? { ...memory } : {}; normalized.runs = asArray(normalized.runs); return { version: normalized.version || 1, updatedAt: normalized.updatedAt || '', runs: normalized.runs }; } function writeRunHistory({ historyPath, profile, report, outputRoot, selectedTopicIndex, scriptSession }) { const resolved = path.resolve(historyPath); ensureDir(path.dirname(resolved)); const history = normalizeHistoryMemory(readJsonMaybe(resolved)); const topIdeas = report.topicIdeas.slice(0, 8).map((idea, index) => ({ index: index + 1, priority: idea.priority, awemeId: idea.sourceVideo.id, title: idea.title, author: idea.sourceVideo.author, confidence: idea.confidence.label, hasTranscript: Boolean(idea.transcriptInsights), evidence: idea.sourceVideo.evidence })); history.runs.push({ runId: `${slugify(profile.projectName)}_${Date.now()}`, generatedAt: report.generatedAt, projectName: profile.projectName, outputDir: outputRoot, summary: report.summary, qualityGate: report.qualityGate, oneLineJudgement: report.oneLineJudgement, selectedTopicIndex: selectedTopicIndex || 0, scriptSessionStatus: scriptSession?.status || '', scriptVersion: latestScriptVersion(scriptSession || {}).version || '', finalized: scriptSession?.status === 'finalized', topIdeas }); history.runs = history.runs.slice(-60); history.updatedAt = new Date().toISOString(); fs.writeFileSync(resolved, JSON.stringify(history, null, 2), 'utf8'); return resolved; } function writeScriptMemory(session, memoryPath) { const resolved = path.resolve(memoryPath); ensureDir(path.dirname(resolved)); const memory = normalizeScriptMemory(readJsonMaybe(resolved) || { version: 1, updatedAt: '', sessions: [], preferenceSignals: { tones: {}, hookStrength: {}, ctaStyles: {}, add: {}, avoid: {} } }); const latestFeedback = asArray(session.feedbackTurns).slice(-1)[0]?.parsedPreference || {}; const bump = (bucket, key) => { if (!key) return; bucket[key] = (bucket[key] || 0) + 1; }; bump(memory.preferenceSignals.tones, latestFeedback.tone); bump(memory.preferenceSignals.hookStrength, latestFeedback.hookStrength); bump(memory.preferenceSignals.ctaStyles, latestFeedback.ctaStyle); asArray(latestFeedback.add).forEach(item => bump(memory.preferenceSignals.add, item)); asArray(latestFeedback.avoid).forEach(item => bump(memory.preferenceSignals.avoid, item)); memory.sessions = asArray(memory.sessions); memory.sessions.push({ sessionId: session.sessionId, finalizedAt: new Date().toISOString(), projectName: session.projectName, selectedTopicIndex: session.selectedTopicIndex, sourceAwemeId: session.sourceAwemeId, topicTitle: session.topicTitle, finalVersion: session.finalScript?.version || latestScriptVersion(session).version, feedbackCount: asArray(session.feedbackTurns).length, preference: latestFeedback }); memory.sessions = memory.sessions.slice(-80); memory.updatedAt = new Date().toISOString(); fs.writeFileSync(resolved, JSON.stringify(memory, null, 2), 'utf8'); return resolved; } function bumpPhrase(bucket, phrase, amount = 1) { const key = cleanText(phrase); if (!key || key.length < 2) return; bucket[key] = (bucket[key] || 0) + amount; } function writeTopicPreferenceMemory({ report, command, memoryPath }) { if (!command?.keepIndexes?.length && !command?.dropText) return null; const resolved = path.resolve(memoryPath); ensureDir(path.dirname(resolved)); const memory = normalizeScriptMemory(readJsonMaybe(resolved) || {}); const writes = { kept: [], dropped: [] }; command.keepIndexes.forEach(index => { const idea = report.topicIdeas[index - 1]; if (!idea) return; const phrases = [ idea.sourceVideo.keyword, idea.sourceVideo.author, truncate(idea.title.replace(/^把「|」改成你的账号口播$/g, ''), 24) ].filter(Boolean); phrases.forEach(phrase => bumpPhrase(memory.topicPreferences.keepPhrases, phrase, 1)); writes.kept.push({ index, title: idea.title, author: idea.sourceVideo.author }); }); if (command.dropText) { bumpPhrase(memory.topicPreferences.dropPhrases, command.dropText, 1); writes.dropped.push(command.dropText); } memory.updatedAt = new Date().toISOString(); fs.writeFileSync(resolved, JSON.stringify(memory, null, 2), 'utf8'); return { path: resolved, ...writes }; } function renderTopicPreferenceReceipt(writeResult) { if (!writeResult) return ''; const lines = ['# 偏好已记录', '']; if (writeResult.kept.length) { lines.push(`- 保留方向:${writeResult.kept.map(item => `选题${item.index}`).join('、')}`); } if (writeResult.dropped.length) { lines.push(`- 下次降权/禁区:${writeResult.dropped.join('、')}`); } lines.push('- 下一次日报排序会参考这些偏好。'); lines.push(''); return lines.join('\n'); } function buildScriptFinalQualityGate(session, script) { const issues = []; const warnings = []; const profileText = normalizePhrase(Object.values(session.profileSnapshot || {}).join(' ')); const text = cleanText(script); const lowered = normalizePhrase(text); if (!text || text.length < 80) issues.push('稿件过短,不能作为正式交付稿。'); if (/voc\.market|voc 用户洞察|voc用户洞察|voc 洞察/i.test(text) && !/voc/.test(profileText)) { issues.push('疑似泄漏测试项目或 VOC 专属话术。'); } if (/B2B|b2b/.test(text) && !/b2b|企业|老板|决策|获客|咨询/.test(profileText)) { warnings.push('出现 B2B 表达,但当前 profile 未明确 B2B 场景。'); } if (!/举个|比如|场景|第一|第二|第三|案例/.test(text)) warnings.push('缺少具体场景或步骤,容易显得泛。'); if (!/评论区|告诉我|回复|私信|预约|咨询|下一步/.test(text)) warnings.push('缺少清晰互动或转化动作。'); const evidenceText = normalizePhrase([ profileText, session.topicTitle, session.topicKeyword, session.topicAngle, session.sourceEvidence?.evidence, session.transcriptInsights?.opening, session.transcriptInsights?.proof, ...asArray(session.commentTriggers) ].filter(Boolean).join(' ')); const hasSpecificBusinessClaim = /(真实|最近服务|服务过|我们服务|客户案例|案例中|这个客户|该客户|我们有个客户).{0,100}(投流|成本.{0,12}(降|下降)|转化.{0,12}(翻|提升)|\d+(?:万|%|%|倍))/.test(text); const hasVerifiedCaseSource = /(真实客户案例|客户案例数据|已验证案例|案例库|verified case)/i.test(evidenceText); if (hasSpecificBusinessClaim && !hasVerifiedCaseSource) { issues.push('出现未证实的真实客户案例或精确经营数据,请改成“假设场景”或补充证据后再定稿。'); } const genericHits = ['更容易被理解', '被收藏', '被咨询', '不要只喊口号'].filter(item => lowered.includes(normalizePhrase(item))); if (genericHits.length >= 2) warnings.push('存在较多模板化表达,建议再换成账号自己的业务语言。'); return { level: issues.length ? 'fail' : warnings.length ? 'warn' : 'pass', issues, warnings, recommendation: issues.length ? '先修改稿件再定稿,避免把低质量或错账号内容写入记忆。' : warnings.length ? '可以定稿,但建议下一轮继续补具体场景、口语化和转化动作。' : '定稿质量通过,可沉淀到记忆。' }; } function finalizeScriptSession(session, memoryPath) { if (session.status === 'blocked') { session.updatedAt = new Date().toISOString(); return session; } const latest = latestScriptVersion(session); const finalQualityGate = buildScriptFinalQualityGate(session, latest.script || ''); session.finalQualityGate = finalQualityGate; if (finalQualityGate.level === 'fail') { session.status = 'needs_revision'; session.updatedAt = new Date().toISOString(); return session; } session.status = 'finalized'; session.finalScript = { version: latest.version, title: latest.title, script: latest.script, cta: latest.cta, finalizedAt: new Date().toISOString() }; const memoryFile = writeScriptMemory(session, memoryPath); session.memoryWrites = { scriptMemoryPath: memoryFile, preferenceSummary: asArray(session.feedbackTurns).slice(-1)[0]?.parsedPreference || {} }; session.updatedAt = new Date().toISOString(); return session; } function firstSentence(text) { const cleaned = cleanText(text); const match = cleaned.match(/^(.+?[。!?!?])/); return match ? match[1] : cleaned; } function compactScriptVersion(script, targetSec = 30) { const paragraphs = scriptParagraphs(script); if (!paragraphs.length) return ''; if (targetSec <= 30) { const middle = paragraphs.find(item => /三步|第一|第二|第三|判断|证据|动作/.test(item)) || paragraphs[1] || ''; const ending = paragraphs.slice().reverse().find(item => /评论区|如果你|可以|告诉我|收/.test(item)) || paragraphs[paragraphs.length - 1]; return [firstSentence(paragraphs[0]), firstSentence(middle), firstSentence(ending)] .filter(Boolean) .join('\n\n'); } if (targetSec <= 60) { return paragraphs.slice(0, Math.min(4, paragraphs.length)).join('\n\n'); } return paragraphs.join('\n\n'); } function buildPublishingAids(session, script) { const topic = scriptTopicText(session); const ctx = scriptDeliveryContext(session.profileSnapshot || {}, topic); return { titles: [ `别再照抄爆款,先把「${topic}」讲成你的判断`, `${ctx.audience}真正需要的不是热点,是下一步动作`, `为什么你追了热点,内容还是没有结果` ], shootingTips: [ '开头 3 秒只讲一个判断,不解释背景。', `中段用一个${ctx.proofSource}证明,不堆概念。`, '结尾只留一个动作,让用户知道下一步怎么回复。' ], commentGuide: `你现在更卡在选题、证据,还是${ctx.goal}?`, short30: compactScriptVersion(script, 30), short60: compactScriptVersion(script, 60) }; } function renderScriptSession(session, { mdPath, sessionPath, finalized = false }) { const latest = latestScriptVersion(session); const lines = []; if (finalized) { lines.push('# 定稿稿件'); } else { lines.push(`# 选题 ${session.selectedTopicIndex} 共创稿 ${latest.version || 'v0'}`); } lines.push(''); lines.push('## 共创状态'); lines.push(''); const statusLabel = session.status === 'finalized' ? '已定稿' : session.status === 'blocked' ? '已阻断' : session.status === 'needs_revision' ? '待修改' : '共创中'; lines.push(`- 状态:${statusLabel}`); lines.push(`- 选题:${session.topicTitle}`); lines.push(`- 来源:${session.sourceEvidence?.author || '未知作者'} / ${session.sourceEvidence?.evidence || ''}`); lines.push(`- 置信度:${session.sourceEvidence?.confidence?.label || '未标注'}`); if (session.qualityGate?.level) { lines.push(`- 质量门禁:${session.qualityGate.level}(${session.qualityGate.recommendation || '已完成审计'})`); } if (session.transcriptInsights) { lines.push(`- 逐字稿:已提供(${session.transcriptInsights.provider},${session.transcriptInsights.textLength} 字)`); } else { lines.push('- 逐字稿:未提供,当前稿件为结构推断稿'); } if (asArray(session.feedbackTurns).length) { const lastTurn = asArray(session.feedbackTurns).slice(-1)[0]; lines.push(''); lines.push('## 本轮理解'); lines.push(''); lines.push(`- 用户反馈:${lastTurn.userFeedback}`); asArray(lastTurn.parsedPreference?.summary).forEach(item => lines.push(`- ${item}`)); } if (!asArray(session.feedbackTurns).length && asArray(session.memorySnapshot?.summary).length) { lines.push(''); lines.push('## 历史偏好'); lines.push(''); asArray(session.memorySnapshot.summary).forEach(item => lines.push(`- ${item}`)); } lines.push(''); lines.push(finalized ? '## 最终口播稿' : '## 当前稿件'); lines.push(''); lines.push(`### ${latest.title || session.topicTitle}`); lines.push(''); lines.push(latest.script || ''); if (latest.cta) { lines.push(''); lines.push(`收口:${latest.cta}`); } if (latest.changeNotes?.length) { lines.push(''); lines.push('## 改动说明'); lines.push(''); latest.changeNotes.forEach(item => lines.push(`- ${item}`)); } if (session.finalQualityGate?.level) { lines.push(''); lines.push('## 定稿自检'); lines.push(''); lines.push(`- 结果:${session.finalQualityGate.level}`); session.finalQualityGate.issues?.forEach(item => lines.push(`- 必改:${item}`)); session.finalQualityGate.warnings?.forEach(item => lines.push(`- 建议:${item}`)); lines.push(`- 建议动作:${session.finalQualityGate.recommendation}`); } if (finalized) { const aids = buildPublishingAids(session, latest.script || ''); lines.push(''); lines.push('## 发布辅助'); lines.push(''); aids.titles.forEach((title, index) => lines.push(`- 标题备选 ${index + 1}:${title}`)); aids.shootingTips.forEach(tip => lines.push(`- 拍摄提示:${tip}`)); lines.push(`- 评论区引导:${aids.commentGuide}`); if (aids.short30) { lines.push(''); lines.push('## 30秒压缩版'); lines.push(''); lines.push(aids.short30); } if (aids.short60 && aids.short60 !== aids.short30) { lines.push(''); lines.push('## 60秒提词器版'); lines.push(''); lines.push(aids.short60); } lines.push(''); lines.push('## 已沉淀'); lines.push(''); lines.push(`- 共创轮次:${asArray(session.feedbackTurns).length}`); if (session.memoryWrites?.scriptMemoryPath) lines.push(`- 脚本偏好记忆:${session.memoryWrites.scriptMemoryPath}`); } else { const topic = scriptTopicText(session); lines.push(''); lines.push('## 下一轮你可以这样回'); lines.push(''); lines.push('- “开头更狠,老板视角”'); lines.push(`- “太泛了,加一个具体${topic}场景”`); lines.push('- “压成 30 秒,并偏产品转化”'); lines.push('- “定稿”'); } lines.push(''); lines.push('## 本地沉淀'); lines.push(''); if (sessionPath) lines.push(`- 共创会话:${sessionPath}`); if (mdPath) lines.push(`- 日报备份:${mdPath}`); return lines.join('\n'); } function topicIdeasPreview(report) { return report.topicIdeas.slice(0, 5).map((idea, index) => ({ index: index + 1, priority: idea.priority, title: idea.title, author: idea.sourceVideo.author, aweme_id: idea.sourceVideo.id, evidence: idea.sourceVideo.evidence, confidence: idea.confidence.label, angle: idea.angle, transcript: idea.transcriptInsights ? { provider: idea.transcriptInsights.provider, textLength: idea.transcriptInsights.textLength, openingExcerpt: idea.transcriptInsights.openingExcerpt, firstReusableTactic: idea.transcriptInsights.reusableTactics[0] || '' } : undefined, draftScript: idea.draftScript ? { title: idea.draftScript.title, theme: idea.draftScript.theme, estimatedDurationSec: idea.draftScript.estimatedDurationSec, fullText: idea.draftScript.fullText } : undefined, firstCommentTrigger: idea.commentTriggers[0] || '' })); } function runReportWithArgs(args) { const naturalCommand = parseNaturalCommand(args.message || args.userMessage || args['user-message'] || args.intent || args.command); const profile = loadProfile(args); const date = args.date || new Date().toISOString().slice(0, 10); const outputRoot = path.resolve(args.output || path.join( process.cwd(), 'openclaw-voc-output', 'douyin-speaking-daily', slugify(profile.projectName), date )); ensureDir(outputRoot); const explicitInput = hasConcreteArg(args.input) ? args.input : ''; const autoInput = !explicitInput && naturalCommand.isContinuation ? findLatestFileByName('runner-raw-input.json', outputSearchRoots()) : ''; const inputData = explicitInput || autoInput ? loadInput(explicitInput || autoInput) : {}; const normalized = normalizeData(inputData); const scriptMemoryPathForRun = defaultScriptMemoryPath(args); const scriptMemory = readScriptMemory(scriptMemoryPathForRun); const report = buildReport(profile, normalized, { scriptMemorySummary: scriptMemorySummary(scriptMemory, scriptMemoryPathForRun), scriptMemory }); const topicPreferenceWrite = writeTopicPreferenceMemory({ report, command: naturalCommand, memoryPath: scriptMemoryPathForRun }); const rawDir = path.join(outputRoot, 'raw'); ensureDir(rawDir); const jsonPath = path.join(outputRoot, 'daily-report.json'); const mdPath = path.join(outputRoot, 'daily-report.md'); const auditPath = path.join(outputRoot, 'audit.json'); const rawInputPath = path.join(rawDir, 'input.json'); const rawVideosPath = path.join(rawDir, 'keyword-videos.json'); const rawAccountVideosPath = path.join(rawDir, 'account-videos.json'); const rawCommentsPath = path.join(rawDir, 'comments.json'); const fullReportMarkdown = renderMarkdown(report); const explicitScriptSessionInput = args.scriptSession || args['script-session']; const outputScriptSessionPath = path.join(outputRoot, 'script-session.json'); const feedback = cleanText(args.feedback || args.userFeedback || args['user-feedback'] || naturalCommand.feedback); const feedbackIsFinalize = /^(定稿|确认定稿|final|finalize)$/i.test(feedback); const shouldFinalize = boolArg(args.finalize || args.final, false) || feedbackIsFinalize || naturalCommand.finalize; const shouldReuseOutputSession = !hasConcreteArg(explicitScriptSessionInput) && (feedback || shouldFinalize) && fs.existsSync(outputScriptSessionPath); const latestScriptSessionPath = !hasConcreteArg(explicitScriptSessionInput) && (feedback || shouldFinalize) && !shouldReuseOutputSession && naturalCommand.isContinuation ? findLatestFileByName('script-session.json', outputSearchRoots()) : ''; const scriptSessionInput = shouldReuseOutputSession ? outputScriptSessionPath : (explicitScriptSessionInput || latestScriptSessionPath); const existingScriptSession = readJsonMaybe(scriptSessionInput); const explicitSelectedTopicIndex = toIndex(args.selectedTopicIndex || args['selected-topic-index'] || args.topicIndex || args['topic-index'] || naturalCommand.selectedTopicIndex); const selectedTopicIndex = explicitSelectedTopicIndex || toIndex(existingScriptSession?.selectedTopicIndex); const shouldReuseExistingSession = Boolean(existingScriptSession?.sessionId) && (!explicitSelectedTopicIndex || toIndex(existingScriptSession.selectedTopicIndex) === explicitSelectedTopicIndex); const effectiveExistingScriptSession = shouldReuseExistingSession ? existingScriptSession : null; const selectedIdea = selectedTopicIndex ? report.topicIdeas[selectedTopicIndex - 1] : undefined; const scriptMode = !naturalCommand.preferenceOnly && Boolean(selectedTopicIndex || effectiveExistingScriptSession); const scriptSessionPath = scriptMode ? path.resolve(hasConcreteArg(explicitScriptSessionInput) && shouldReuseExistingSession ? explicitScriptSessionInput : outputScriptSessionPath) : ''; let scriptSession = null; if (scriptMode) { scriptSession = makeScriptSession({ profile, report, idea: selectedIdea, selectedTopicIndex, existingSession: effectiveExistingScriptSession, scriptMemory, scriptMemoryPath: scriptMemoryPathForRun }); if (feedback && !feedbackIsFinalize) { scriptSession = applyFeedbackToSession(scriptSession, feedback); } if (shouldFinalize) { scriptSession = finalizeScriptSession(scriptSession, scriptMemoryPathForRun); } } const preferenceReceipt = renderTopicPreferenceReceipt(topicPreferenceWrite); const chatMarkdownBody = scriptMode ? renderScriptSession(scriptSession, { mdPath, sessionPath: scriptSessionPath, finalized: scriptSession.status === 'finalized' }) : naturalCommand.preferenceOnly ? '' : renderChatMarkdown(report, mdPath); const chatMarkdown = preferenceReceipt ? [preferenceReceipt, chatMarkdownBody].filter(Boolean).join('\n') : chatMarkdownBody; const selectedScriptPath = selectedTopicIndex ? path.join(outputRoot, `selected-topic-${selectedTopicIndex}-script.md`) : ''; const selectedScriptJsonPath = selectedTopicIndex ? path.join(outputRoot, `selected-topic-${selectedTopicIndex}-script.json`) : ''; fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8'); fs.writeFileSync(mdPath, fullReportMarkdown, 'utf8'); fs.writeFileSync(auditPath, JSON.stringify(buildAudit(report), null, 2), 'utf8'); fs.writeFileSync(rawInputPath, JSON.stringify(inputData, null, 2), 'utf8'); fs.writeFileSync(rawVideosPath, JSON.stringify(normalized.videos, null, 2), 'utf8'); fs.writeFileSync(rawAccountVideosPath, JSON.stringify(asArray(inputData.accountVideos), null, 2), 'utf8'); fs.writeFileSync(rawCommentsPath, JSON.stringify(normalized.comments, null, 2), 'utf8'); if (selectedTopicIndex) { fs.writeFileSync(selectedScriptPath, chatMarkdown, 'utf8'); if (scriptSessionPath) { ensureDir(path.dirname(scriptSessionPath)); fs.writeFileSync(scriptSessionPath, JSON.stringify(scriptSession, null, 2), 'utf8'); } fs.writeFileSync(selectedScriptJsonPath, JSON.stringify({ selectedTopicIndex, topic: selectedIdea || null, scriptSession, markdown: chatMarkdown }, null, 2), 'utf8'); } const shouldWriteHistory = boolArg(args.writeHistory || args['write-history'], true); const historyMemoryPath = shouldWriteHistory ? writeRunHistory({ historyPath: defaultHistoryMemoryPath(args), profile, report, outputRoot, selectedTopicIndex, scriptSession }) : ''; const result = { status: report.status, assistantMessage: chatMarkdown, markdown: chatMarkdown, outputDir: outputRoot, files: [ mdPath, jsonPath, auditPath, rawInputPath, rawVideosPath, rawAccountVideosPath, rawCommentsPath, ...(selectedTopicIndex ? [selectedScriptPath, selectedScriptJsonPath] : []), ...(scriptSessionPath ? [scriptSessionPath] : []), ...(scriptSession?.memoryWrites?.scriptMemoryPath ? [scriptSession.memoryWrites.scriptMemoryPath] : []), ...(historyMemoryPath ? [historyMemoryPath] : []) ], summary: report.summary, qualityGate: report.qualityGate, scriptMemorySignals: report.metadata?.scriptMemory || {}, oneLineJudgement: report.oneLineJudgement, topicIdeaCount: report.topicIdeas.length, precisionLevel: report.precisionCheck.confidenceSummary, calibrationPrompt: '请直接回复:保留的选题序号、要降权/禁区的方向、下一版口播视角。', topicIdeasPreview: topicIdeasPreview(report), selectedTopicIndex, selectedTopic: selectedIdea ? topicIdeasPreview({ ...report, topicIdeas: [selectedIdea] })[0] : undefined, selectedScriptPath, scriptSessionPath, scriptSessionStatus: scriptSession?.status || '', scriptVersion: latestScriptVersion(scriptSession || {}).version || '', scriptMemoryPath: scriptSession?.memoryWrites?.scriptMemoryPath || '', historyMemoryPath, naturalCommand, topicPreferenceMemoryPath: topicPreferenceWrite?.path || '', autoResolvedInputPath: autoInput, autoResolvedScriptSessionPath: latestScriptSessionPath, calibrationQuestions: report.calibrationQuestions, fullReportPath: mdPath, warningCount: report.warnings.length }; return result; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } console.log(JSON.stringify(runReportWithArgs(args), null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { loadProfile, loadInput, normalizeData, buildReport, renderMarkdown, renderChatMarkdown, renderSelectedTopicDraft, makeScriptSession, applyFeedbackToSession, finalizeScriptSession, renderScriptSession, buildAudit, runReportWithArgs };