| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267 |
- #!/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 <profile.json> --input <douyin-mini-voc-output|dir> [--output <out-dir>]',
- ' node scripts/tools/douyin-speaking-daily-report.js --project <name> --industry <name> --keywords "kw1,kw2" [--input <raw.json>]',
- '',
- '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
- };
|