douyin-speaking-daily-report.js 95 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. function parseArgs(argv) {
  5. const args = {};
  6. for (let i = 0; i < argv.length; i++) {
  7. const token = argv[i];
  8. if (!token.startsWith('--')) continue;
  9. const eq = token.indexOf('=');
  10. if (eq >= 0) {
  11. args[token.slice(2, eq)] = token.slice(eq + 1);
  12. } else {
  13. const key = token.slice(2);
  14. const next = argv[i + 1];
  15. if (next && !next.startsWith('--')) {
  16. args[key] = next;
  17. i++;
  18. } else {
  19. args[key] = true;
  20. }
  21. }
  22. }
  23. return args;
  24. }
  25. function usage() {
  26. return [
  27. 'Usage:',
  28. ' node scripts/tools/douyin-speaking-daily-report.js --profile <profile.json> --input <douyin-mini-voc-output|dir> [--output <out-dir>]',
  29. ' node scripts/tools/douyin-speaking-daily-report.js --project <name> --industry <name> --keywords "kw1,kw2" [--input <raw.json>]',
  30. '',
  31. 'Outputs:',
  32. ' daily-report.md',
  33. ' daily-report.json',
  34. ' audit.json'
  35. ].join('\n');
  36. }
  37. function ensureDir(dirPath) {
  38. fs.mkdirSync(dirPath, { recursive: true });
  39. }
  40. function readJson(filePath) {
  41. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  42. }
  43. function asArray(value) {
  44. if (!value) return [];
  45. return Array.isArray(value) ? value : [value];
  46. }
  47. function splitList(value) {
  48. if (!value) return [];
  49. if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
  50. const text = String(value).trim();
  51. if (!text) return [];
  52. if (text.startsWith('[')) {
  53. try {
  54. const parsed = JSON.parse(text);
  55. return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : [];
  56. } catch {
  57. return [];
  58. }
  59. }
  60. return text.split(/[,,;;|、]/).map(item => item.trim()).filter(Boolean);
  61. }
  62. function cleanText(value) {
  63. return String(value || '').replace(/\s+/g, ' ').trim();
  64. }
  65. function truncate(value, length = 88) {
  66. const text = cleanText(value);
  67. return text.length > length ? `${text.slice(0, length - 1)}…` : text;
  68. }
  69. function toNumber(value) {
  70. const number = Number(value || 0);
  71. return Number.isFinite(number) ? number : 0;
  72. }
  73. function toIndex(value) {
  74. const number = Number(value || 0);
  75. return Number.isFinite(number) ? Math.max(0, Math.round(number)) : 0;
  76. }
  77. function boolArg(value, fallback = false) {
  78. if (value === undefined || value === null || value === '') return fallback;
  79. if (typeof value === 'boolean') return value;
  80. return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
  81. }
  82. function hasConcreteArg(value) {
  83. if (value === undefined || value === null) return false;
  84. const text = String(value).trim();
  85. return Boolean(text) && !/^\{\{[^}]+\}$/.test(text);
  86. }
  87. function readJsonMaybe(filePath) {
  88. if (!hasConcreteArg(filePath)) return undefined;
  89. const resolved = path.resolve(filePath);
  90. if (!fs.existsSync(resolved)) return undefined;
  91. return readJson(resolved);
  92. }
  93. function defaultProfilePath() {
  94. return path.join(process.cwd(), 'memory', 'douyin-speaking-profile.json');
  95. }
  96. function findLatestFileByName(fileName, roots = []) {
  97. const queue = roots
  98. .map(root => path.resolve(root))
  99. .filter(root => fs.existsSync(root));
  100. let latest = null;
  101. while (queue.length) {
  102. const dir = queue.shift();
  103. let entries = [];
  104. try {
  105. entries = fs.readdirSync(dir, { withFileTypes: true });
  106. } catch {
  107. continue;
  108. }
  109. for (const entry of entries) {
  110. const full = path.join(dir, entry.name);
  111. if (entry.isDirectory()) {
  112. if (!['node_modules', '.git', 'dist'].includes(entry.name)) queue.push(full);
  113. } else if (entry.isFile() && entry.name === fileName) {
  114. const stat = fs.statSync(full);
  115. if (!latest || stat.mtimeMs > latest.mtimeMs) latest = { path: full, mtimeMs: stat.mtimeMs };
  116. }
  117. }
  118. }
  119. return latest?.path || '';
  120. }
  121. function outputSearchRoots() {
  122. return [
  123. path.join(process.cwd(), 'outputs'),
  124. path.join(process.cwd(), 'openclaw-voc-output'),
  125. path.join(process.cwd(), 'memory')
  126. ];
  127. }
  128. function parseIndexList(text) {
  129. return String(text || '')
  130. .replace(/选题|第|条|个/g, '')
  131. .split(/[,,、\s和与&+]+/)
  132. .map(item => Number(item))
  133. .filter(number => Number.isFinite(number) && number > 0)
  134. .map(number => Math.round(number));
  135. }
  136. function parseNaturalCommand(text) {
  137. const raw = cleanText(text);
  138. const command = {
  139. raw,
  140. selectedTopicIndex: 0,
  141. feedback: '',
  142. finalize: false,
  143. keepIndexes: [],
  144. dropText: '',
  145. transcriptRequested: false,
  146. isContinuation: false,
  147. preferenceOnly: false
  148. };
  149. if (!raw) return command;
  150. const topicMatch = raw.match(/(?:选题|第)\s*(\d+)\s*(?:条|个)?|topic\s*(\d+)/i);
  151. if (topicMatch) command.selectedTopicIndex = Number(topicMatch[1] || topicMatch[2] || 0);
  152. command.finalize = /^(定稿|确认定稿|final|finalize)$/i.test(raw) || /(^|[,,。\s])定稿($|[,,。\s])/.test(raw);
  153. command.transcriptRequested = /转写|逐字稿|补 transcript|transcript/i.test(raw);
  154. const keepMatch = raw.match(/保留\s*((?:选题|第|topic)?\s*\d+(?:\s*(?:[,,、和与&+]|\s+)\s*(?:选题|第|topic)?\s*\d+)*)/i);
  155. if (keepMatch) command.keepIndexes = parseIndexList(keepMatch[1]);
  156. const dropMatch = raw.match(/(?:不要|降权|禁区|排除)\s*([^,。;;]+)/);
  157. if (dropMatch) command.dropText = cleanText(dropMatch[1]);
  158. if (command.keepIndexes.length && /^保留/.test(raw)) {
  159. command.selectedTopicIndex = 0;
  160. }
  161. const isPureSelect = command.selectedTopicIndex && /^(选题|第|topic)/i.test(raw) && !/[,,。;;]/.test(raw.replace(/选题\s*\d+|第\s*\d+\s*条|topic\s*\d+/ig, ''));
  162. const hasPreferenceSignal = Boolean(command.keepIndexes.length || command.dropText);
  163. const hasFeedbackSignal = /改稿|调整|开头|老板|专家|案例|场景|具体|太泛|压成|短版|转化|更狠|更短|更长|口语|定稿前|去掉/.test(raw);
  164. if (!command.finalize && hasFeedbackSignal && !isPureSelect && !hasPreferenceSignal) {
  165. command.feedback = raw.replace(/^改稿[::]?/i, '').trim();
  166. }
  167. command.isContinuation = Boolean(command.selectedTopicIndex || command.feedback || command.finalize || command.transcriptRequested || command.keepIndexes.length || command.dropText);
  168. command.preferenceOnly = Boolean((command.keepIndexes.length || command.dropText) && !command.selectedTopicIndex && !command.feedback && !command.finalize);
  169. return command;
  170. }
  171. function slugify(value) {
  172. return String(value || 'douyin-speaking-daily')
  173. .trim()
  174. .replace(/[\\/:*?"<>|\s]+/g, '-')
  175. .replace(/-+/g, '-')
  176. .replace(/^-|-$/g, '') || 'douyin-speaking-daily';
  177. }
  178. function loadProfile(args) {
  179. const profilePath = hasConcreteArg(args.profile)
  180. ? path.resolve(args.profile)
  181. : fs.existsSync(defaultProfilePath())
  182. ? defaultProfilePath()
  183. : '';
  184. const fileProfile = profilePath ? readJson(profilePath) : {};
  185. const inlineProfile = {
  186. projectName: args.project || args.projectName || args['project-name'],
  187. industry: args.industry,
  188. accountPositioning: args.positioning || args.accountPositioning || args['account-positioning'],
  189. targetAudience: args.audience || args.targetAudience || args['target-audience'],
  190. conversionGoal: args.goal || args.conversionGoal || args['conversion-goal'],
  191. coreOffer: args.offer || args.coreOffer || args['core-offer'],
  192. contentPillars: splitList(args.pillars || args.contentPillars || args['content-pillars']),
  193. keywords: splitList(args.keywords),
  194. referenceAccounts: splitList(args.accounts || args.referenceAccounts || args['reference-accounts']),
  195. forbiddenTopics: splitList(args.forbidden || args.forbiddenTopics || args['forbidden-topics']),
  196. tone: args.tone,
  197. dailyOutputCount: Number(args.count || args.dailyOutputCount || args['daily-output-count'] || 8)
  198. };
  199. const profile = { ...fileProfile };
  200. Object.entries(inlineProfile).forEach(([key, value]) => {
  201. if (Array.isArray(value)) {
  202. if (value.length) profile[key] = value;
  203. } else if (value !== undefined && value !== null && value !== '' && !(typeof value === 'number' && Number.isNaN(value))) {
  204. profile[key] = value;
  205. }
  206. });
  207. profile.projectName = profile.projectName || 'douyin-speaking-daily';
  208. profile.keywords = splitList(profile.keywords);
  209. profile.referenceAccounts = splitList(profile.referenceAccounts);
  210. profile.contentPillars = splitList(profile.contentPillars);
  211. profile.forbiddenTopics = splitList(profile.forbiddenTopics);
  212. profile.dailyOutputCount = Number(profile.dailyOutputCount || 8);
  213. return profile;
  214. }
  215. function loadInput(inputPath) {
  216. if (!inputPath) return {};
  217. const absolute = path.resolve(inputPath);
  218. const stat = fs.statSync(absolute);
  219. if (stat.isDirectory()) {
  220. const preferred = [
  221. 'douyin-mini-voc.json',
  222. 'raw.json',
  223. '_merged.json',
  224. 'keyword-videos.json',
  225. 'videos.json',
  226. 'daily-report.json'
  227. ].map(name => path.join(absolute, name));
  228. const found = preferred.find(filePath => fs.existsSync(filePath));
  229. if (found) return readJson(found);
  230. const merged = {};
  231. fs.readdirSync(absolute)
  232. .filter(name => name.endsWith('.json'))
  233. .forEach(name => Object.assign(merged, mergeData(merged, readJson(path.join(absolute, name)))));
  234. return merged;
  235. }
  236. return readJson(absolute);
  237. }
  238. function mergeData(left, right) {
  239. return {
  240. metadata: { ...(left.metadata || {}), ...(right.metadata || {}) },
  241. videos: [
  242. ...asArray(left.videos),
  243. ...asArray(right.videos),
  244. ...asArray(right.accountVideos),
  245. ...asArray(right.items).filter(item => item.sourceType === 'video')
  246. ],
  247. comments: [...asArray(left.comments), ...asArray(right.comments)],
  248. replies: [...asArray(left.replies), ...asArray(right.replies)],
  249. hashtags: [...asArray(left.hashtags), ...asArray(right.hashtags)],
  250. transcripts: [...asArray(left.transcripts), ...asArray(right.transcripts)]
  251. };
  252. }
  253. function normalizeVideo(video, index) {
  254. const raw = video.raw || video;
  255. const statistics = video.statistics || raw.statistics || raw.stats || {};
  256. const author = video.author || raw.author || {};
  257. const hashtags = asArray(video.hashtags || raw.cha_list || raw.text_extra)
  258. .map(item => item.cha_name || item.hashtag_name || item.name || item.hashtag || item)
  259. .map(cleanText)
  260. .filter(Boolean);
  261. return {
  262. id: cleanText(video.aweme_id || video.awemeId || video.id || video.productId || raw.aweme_id || raw.awemeId || `video_${index}`),
  263. keyword: cleanText(video.keyword || raw.keyword),
  264. title: cleanText(video.title || video.desc || video.content || raw.desc || raw.title),
  265. author: cleanText(typeof author === 'string' ? author : author.nickname || author.name || video.authorName),
  266. likeCount: toNumber(video.likeCount ?? video.digg_count ?? statistics.digg_count),
  267. commentCount: toNumber(video.commentCount ?? video.comment_count ?? statistics.comment_count),
  268. shareCount: toNumber(video.shareCount ?? video.share_count ?? statistics.share_count),
  269. playCount: toNumber(video.playCount ?? video.play_count ?? statistics.play_count),
  270. publishTime: video.publishTime || raw.create_time || raw.publish_time,
  271. url: video.url || video.share_url || raw.share_url,
  272. hashtags,
  273. raw
  274. };
  275. }
  276. function normalizeComment(comment, index) {
  277. const raw = comment.raw || comment;
  278. const user = comment.user || raw.user || {};
  279. return {
  280. id: cleanText(comment.cid || comment.commentId || comment.id || raw.cid || raw.id || `comment_${index}`),
  281. parentId: cleanText(comment.parentId || comment.aweme_id || raw.aweme_id || raw.item_id),
  282. keyword: cleanText(comment.keyword || raw.keyword),
  283. text: cleanText(comment.text || comment.content || raw.text || raw.content),
  284. author: cleanText(comment.author || user.nickname || user.name),
  285. likeCount: toNumber(comment.likeCount ?? comment.digg_count ?? raw.digg_count),
  286. replyCount: toNumber(comment.replyCount ?? comment.reply_comment_total ?? raw.reply_comment_total),
  287. raw
  288. };
  289. }
  290. function normalizeTranscript(transcript, index) {
  291. if (!transcript) return undefined;
  292. if (typeof transcript === 'string') {
  293. return { id: `transcript_${index}`, awemeId: '', text: cleanText(transcript), segments: [] };
  294. }
  295. return {
  296. id: transcript.id || `transcript_${index}`,
  297. awemeId: cleanText(transcript.awemeId || transcript.aweme_id || transcript.videoId || transcript.itemId),
  298. sourceUrl: transcript.sourceUrl || transcript.url || '',
  299. provider: transcript.provider || 'unknown',
  300. text: cleanText(transcript.text),
  301. segments: asArray(transcript.segments)
  302. };
  303. }
  304. function normalizeData(data) {
  305. return {
  306. metadata: data.metadata || {},
  307. videos: asArray(data.videos)
  308. .concat(asArray(data.accountVideos))
  309. .concat(asArray(data.items).filter(item => item.sourceType === 'video'))
  310. .map(normalizeVideo),
  311. comments: asArray(data.comments).concat(asArray(data.replies)).map(normalizeComment),
  312. hashtags: asArray(data.hashtags),
  313. transcripts: asArray(data.transcripts).map(normalizeTranscript).filter(Boolean)
  314. };
  315. }
  316. function videoScore(video) {
  317. return video.likeCount * 1 + video.commentCount * 4 + video.shareCount * 6 + video.playCount * 0.003;
  318. }
  319. function normalizePhrase(value) {
  320. return cleanText(value).toLowerCase();
  321. }
  322. function topicPreferenceScore(video, profile, scriptMemory) {
  323. const memory = normalizeScriptMemory(scriptMemory || {});
  324. const prefs = memory.topicPreferences || {};
  325. const text = normalizePhrase(`${video.title} ${video.keyword} ${video.author}`);
  326. let score = 0;
  327. Object.entries(prefs.keepPhrases || {}).forEach(([phrase, weight]) => {
  328. if (phrase && text.includes(normalizePhrase(phrase))) score += Number(weight || 1) * 1200;
  329. });
  330. Object.entries(prefs.dropPhrases || {}).forEach(([phrase, weight]) => {
  331. if (phrase && text.includes(normalizePhrase(phrase))) score -= Number(weight || 1) * 2000;
  332. });
  333. asArray(profile.forbiddenTopics).forEach(phrase => {
  334. if (phrase && text.includes(normalizePhrase(phrase))) score -= 3000;
  335. });
  336. asArray(memory.sessions).forEach(session => {
  337. if (session.sourceAwemeId && session.sourceAwemeId === video.id) score -= 1600;
  338. });
  339. return score;
  340. }
  341. function inferHookType(text) {
  342. const t = cleanText(text);
  343. if (/[??]|为什么|怎么|如何|到底|有没有/.test(t)) return '问题钩子';
  344. if (/别再|不要|千万|避坑|踩雷|错了|误区/.test(t)) return '避坑钩子';
  345. if (/我发现|亲测|真实|讲个|经历|以前|后来/.test(t)) return '经历钩子';
  346. if (/\d|一招|三点|5个|10个|清单/.test(t)) return '清单钩子';
  347. if (/但是|其实|反而|不是.*而是/.test(t)) return '反转钩子';
  348. if (/爆|火|涨粉|成交|转化|收入|结果/.test(t)) return '结果钩子';
  349. return '场景钩子';
  350. }
  351. function inferConflict(text) {
  352. const t = cleanText(text);
  353. if (/避坑|踩雷|别买|别再|不要/.test(t)) return '用户怕踩坑,需要一个更简单的判断标准';
  354. if (/焦虑|担心|纠结|不敢|怕/.test(t)) return '用户在风险和收益之间犹豫';
  355. if (/贵|便宜|性价比|价格/.test(t)) return '用户在价格和价值感之间犹豫';
  356. if (/没用|无效|失败|翻车/.test(t)) return '用户担心结果不可控,需要证明边界';
  357. if (/但是|其实|反而|不是/.test(t)) return '适合用反转打破原有认知';
  358. return '把用户已有认知和真实结果拉开差距';
  359. }
  360. function commentsForVideo(comments, videoId, totalVideos) {
  361. const linked = comments.filter(comment => comment.parentId && comment.parentId === videoId);
  362. if (linked.length) return { scope: 'linked', comments: linked };
  363. if (totalVideos === 1 && comments.length) return { scope: 'single_video_unlinked', comments };
  364. return { scope: 'unlinked_ignored', comments: [] };
  365. }
  366. function topComments(comments, limit = 3) {
  367. return comments
  368. .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2))
  369. .slice(0, limit);
  370. }
  371. function questionComments(comments, limit = 4) {
  372. return comments
  373. .filter(comment => /[??]|怎么|哪里|多少|能不能|有没有|适合|为什么|求|链接/.test(comment.text))
  374. .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2))
  375. .slice(0, limit);
  376. }
  377. function countBy(values) {
  378. const counts = new Map();
  379. values.filter(Boolean).forEach(value => counts.set(value, (counts.get(value) || 0) + 1));
  380. return [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
  381. }
  382. function transcriptForVideo(transcripts, videoId) {
  383. return transcripts.find(item => item.awemeId && item.awemeId === videoId) || undefined;
  384. }
  385. function splitTranscriptSentences(text) {
  386. return cleanText(text)
  387. .replace(/([。!?!?;;])/g, '$1\n')
  388. .split(/\n+/)
  389. .map(cleanText)
  390. .filter(sentence => sentence.length >= 6);
  391. }
  392. function uniqueTexts(items) {
  393. const seen = new Set();
  394. return items.filter(item => {
  395. const key = cleanText(item);
  396. if (!key || seen.has(key)) return false;
  397. seen.add(key);
  398. return true;
  399. });
  400. }
  401. function scoreReusableSentence(sentence) {
  402. const text = cleanText(sentence);
  403. let score = 0;
  404. if (/不是|而是|其实|记住|核心|关键|重点|结论|真正|只需要|不要|别/.test(text)) score += 4;
  405. if (/第一|第二|第三|首先|其次|最后|\d/.test(text)) score += 2;
  406. if (/比如|案例|举个|身边|以前|后来|结果/.test(text)) score += 2;
  407. if (/为什么|怎么|如何|到底|是不是/.test(text)) score += 1;
  408. if (text.length >= 14 && text.length <= 70) score += 2;
  409. if (text.length > 110) score -= 2;
  410. return score;
  411. }
  412. function transcriptStructure(sentences) {
  413. if (!sentences.length) return [];
  414. const labels = ['开场钩子', '问题铺开', '证明与案例', '结论与行动'];
  415. const ranges = [
  416. [0, 0.18],
  417. [0.18, 0.46],
  418. [0.46, 0.78],
  419. [0.78, 1]
  420. ];
  421. return ranges.map(([startRatio, endRatio], index) => {
  422. const start = Math.min(sentences.length - 1, Math.floor(sentences.length * startRatio));
  423. const end = Math.max(start + 1, Math.ceil(sentences.length * endRatio));
  424. const chunk = sentences.slice(start, end);
  425. return {
  426. label: labels[index],
  427. summary: truncate(chunk.join(''), 82)
  428. };
  429. }).filter(item => item.summary);
  430. }
  431. function inferReusableTactics(text) {
  432. const tactics = [];
  433. if (/想象成|就像|好比|比如|比喻|员工|老师|医生|工具/.test(text)) {
  434. tactics.push('用一个大众熟悉的类比解释陌生概念,先降低理解门槛。');
  435. }
  436. if (/不是[\s\S]{0,16}而是|别|不要|误区|踩坑|翻车|无效/.test(text)) {
  437. tactics.push('用“错误认知 vs 正确做法”制造反差,适合做开头冲突。');
  438. }
  439. if (/第一|第二|第三|1\.|2\.|3\.|三个|五个|步骤|路径/.test(text)) {
  440. tactics.push('把复杂问题拆成步骤或清单,让观众觉得能照着做。');
  441. }
  442. if (/案例|举个|身边|我见过|客户|老板|团队|去年|后来|结果/.test(text)) {
  443. tactics.push('用具体人或具体场景证明观点,比单纯讲道理更容易被信任。');
  444. }
  445. if (/评论|留言|下期|你最|你会|你是不是|你只要/.test(text)) {
  446. tactics.push('结尾留一个低门槛问题,方便评论区继续收集选题。');
  447. }
  448. return tactics.length ? tactics : ['保留“先给判断、再解释原因、最后给行动”的口播骨架。'];
  449. }
  450. function shortProfileField(value, fallback, maxLength = 36) {
  451. const parts = splitList(value);
  452. const text = parts.length ? parts.slice(0, 3).join('、') : cleanText(value);
  453. return truncate(text || fallback, maxLength);
  454. }
  455. function buildAdaptationGuidance(profile) {
  456. const industry = shortProfileField(profile.industry, '当前行业/品类');
  457. const audience = shortProfileField(profile.targetAudience, '目标人群');
  458. const positioning = profile.accountPositioning ? '当前账号定位' : '账号定位';
  459. const goal = profile.conversionGoal ? '当前转化目标' : '转化目标';
  460. return [
  461. `把原视频里的具体概念替换成「${industry}」里的真实问题,不直接照搬原账号语境。`,
  462. `开头先点破「${audience}」最容易误判的一件事,再给你的判断。`,
  463. `用「${positioning}」能讲清楚的案例做证明,避免变成泛知识科普。`,
  464. `结尾动作服务「${goal}」,可以引导评论、咨询、收藏或下一条追问。`
  465. ];
  466. }
  467. function buildTranscriptInsights(transcript, profile) {
  468. const text = cleanText(transcript?.text);
  469. if (!text) return undefined;
  470. const sentences = splitTranscriptSentences(text);
  471. const topLines = uniqueTexts([...sentences]
  472. .sort((a, b) => scoreReusableSentence(b) - scoreReusableSentence(a))
  473. .slice(0, 8))
  474. .slice(0, 4)
  475. .map(item => truncate(item, 72));
  476. return {
  477. provider: transcript.provider || 'unknown',
  478. textLength: text.length,
  479. openingExcerpt: truncate(text, 96),
  480. structure: transcriptStructure(sentences),
  481. reusableTactics: inferReusableTactics(text),
  482. reusableLines: topLines,
  483. adaptationGuidance: buildAdaptationGuidance(profile),
  484. avoidCopying: [
  485. '不要复刻原作者人设、口头禅和私域承诺,只迁移结构。',
  486. '不要把原视频案例当成自己的案例,必须替换成当前账号能验证的事实。',
  487. '不要因为有逐字稿就逐句改写,优先抽取钩子、转折、证明方式和结尾动作。'
  488. ]
  489. };
  490. }
  491. function profileListText(value, fallback, limit = 2) {
  492. const parts = splitList(value);
  493. if (parts.length) return parts.slice(0, limit).join('、');
  494. return cleanText(value) || fallback;
  495. }
  496. function scriptDeliveryContext(profile, topic = '') {
  497. const industry = profileListText(profile.industry, topic || '当前赛道', 2);
  498. const audience = profileListText(profile.targetAudience, '目标用户', 2);
  499. const positioning = cleanText(profile.accountPositioning) || '专业内容账号';
  500. const goal = profileListText(profile.conversionGoal, '评论区互动和咨询转化', 1);
  501. const offer = profileListText(profile.coreOffer, '你的方法论、产品或服务', 1);
  502. const joined = `${industry} ${audience} ${positioning} ${goal} ${offer} ${topic}`;
  503. const isB2B = /B2B|b2b|企业|老板|决策|获客|咨询|线索|SaaS|saas/.test(joined);
  504. const isCommerce = /电商|消费|品牌|零售|店铺|投放|私域|转化|复购|客单/.test(joined);
  505. const isEducation = /教育|课程|培训|学习|老师|知识|社群|训练营/.test(joined);
  506. const isLocal = /门店|本地|到店|餐饮|同城|探店|预约/.test(joined);
  507. const userSignal = isB2B
  508. ? '决策顾虑、预算疑问和信任门槛'
  509. : isCommerce
  510. ? '购买犹豫、评论追问和复购障碍'
  511. : isEducation
  512. ? '学习卡点、理解误区和行动阻力'
  513. : isLocal
  514. ? '到店顾虑、选择理由和预约阻力'
  515. : '真实疑问、行动阻力和反馈信号';
  516. const actionSurface = isB2B
  517. ? '内容、销售沟通和服务包装'
  518. : isCommerce
  519. ? '脚本、货品表达和转化动作'
  520. : isEducation
  521. ? '选题、课程表达和学习路径'
  522. : isLocal
  523. ? '内容、门店卖点和预约动作'
  524. : '内容表达、交付动作和下一步转化';
  525. const proofSource = isB2B
  526. ? '客户问题、销售反馈或真实案例'
  527. : isCommerce
  528. ? '评论、成交反馈或用户使用场景'
  529. : isEducation
  530. ? '学员问题、练习结果或前后对比'
  531. : isLocal
  532. ? '顾客评价、到店反馈或服务场景'
  533. : '评论、案例、数据或用户反馈';
  534. const scenarioLabel = isB2B
  535. ? '同样是做企业获客'
  536. : isCommerce
  537. ? '同样是做内容转化'
  538. : isEducation
  539. ? '同样是做知识内容'
  540. : isLocal
  541. ? '同样是做本地获客'
  542. : `同样是做${topic || industry}`;
  543. const actionQuestion = isB2B
  544. ? '这条内容能不能帮你判断客户为什么还没下决策'
  545. : isCommerce
  546. ? '这条内容能不能帮你判断用户为什么还没下单'
  547. : isEducation
  548. ? '这条内容能不能帮你判断用户到底卡在哪一步'
  549. : isLocal
  550. ? '这条内容能不能帮你判断用户为什么还没预约或到店'
  551. : '这条内容能不能帮你判断用户为什么还没有行动';
  552. return {
  553. industry,
  554. audience,
  555. positioning,
  556. goal,
  557. offer,
  558. userSignal,
  559. actionSurface,
  560. proofSource,
  561. scenarioLabel,
  562. actionQuestion
  563. };
  564. }
  565. function buildDraftScript({ profile, video, conflict, questions, transcriptInsights }) {
  566. if (!transcriptInsights) return undefined;
  567. const topic = video.keyword || splitList(profile.keywords)[0] || profileListText(profile.industry, '当前赛道', 1);
  568. const ctx = scriptDeliveryContext(profile, topic);
  569. const commentQuestion = truncate(questions[0]?.text || '你现在最想解决哪一步?', 34);
  570. const title = `别再照抄爆款,先把「${topic}」变成可执行判断`;
  571. const segments = [
  572. {
  573. label: '开头钩子',
  574. text: `${ctx.audience}做${topic},最危险的不是没追到热点,而是把别人的热闹误判成自己的机会。看起来在学爆款,实际是在替别人验证内容。`
  575. },
  576. {
  577. label: '反差判断',
  578. text: `真正能复用的不是原视频那句话,而是它背后的三件事:它击中了谁的误判,它用了什么证据让人相信,最后把观众带到哪一个动作。`
  579. },
  580. {
  581. label: '方法拆解',
  582. text: `拿${ctx.industry}来说,第一步不是先追热点,而是先找${ctx.audience}最近反复出现的${ctx.userSignal};第二步,把这个信号翻译成一个明确判断;第三步,用${ctx.proofSource}来证明,而不是只喊一句口号。`
  583. },
  584. {
  585. label: '账号改写',
  586. text: `所以这条内容不要讲成泛泛的${topic}科普,而要讲成:为什么很多人看起来在行动,实际只是在复制动作;为什么${ctx.offer}要先找到真实用户信号,再决定${ctx.actionSurface}。`
  587. },
  588. {
  589. label: '结尾转化',
  590. text: `如果你也有一个方向,看起来能讲但不知道怎么讲,评论区告诉我「${commentQuestion}」,我可以按这个框架帮你拆成一条更容易被理解、被收藏、被咨询的口播。`
  591. }
  592. ];
  593. return {
  594. status: 'draft',
  595. sourceAwemeId: video.id,
  596. title,
  597. theme: `${ctx.industry} / ${ctx.audience} / ${topic}`,
  598. estimatedDurationSec: 70,
  599. rewriteBasis: [
  600. '沿用原视频“先解释概念误区,再拆步骤,再给行动”的结构。',
  601. '保留类比、反差、清单、案例证明四类爆款打法。',
  602. '替换原视频具体人设、案例和承诺,改成当前账号 profile 中的行业、人群、定位和转化目标。'
  603. ],
  604. segments,
  605. fullText: segments.map(item => item.text).join('\n\n'),
  606. cta: `围绕「${ctx.goal}」收口,不做泛娱乐或纯搬运。`
  607. };
  608. }
  609. function profileMatchNote(profile, video) {
  610. const keywordHit = profile.keywords.find(keyword => {
  611. const pattern = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
  612. return pattern.test(`${video.keyword} ${video.title}`);
  613. });
  614. if (keywordHit) return `匹配监听关键词「${keywordHit}」`;
  615. if (video.keyword) return `来自采集关键词「${video.keyword}」,未命中 profile 关键词文本`;
  616. return '未标注关键词,需要人工确认是否贴合账号方向';
  617. }
  618. function confidenceForTopic(profile, video, commentScope, transcript) {
  619. let score = 0;
  620. const reasons = [];
  621. if (video.likeCount || video.commentCount || video.shareCount || video.playCount) {
  622. score += 1;
  623. reasons.push('有互动数据');
  624. }
  625. if (commentScope.scope === 'linked' && commentScope.comments.length >= 3) {
  626. score += 2;
  627. reasons.push('有匹配评论');
  628. } else if (commentScope.comments.length) {
  629. score += 1;
  630. reasons.push('评论未带视频关联');
  631. }
  632. if (transcript?.text) {
  633. score += 2;
  634. reasons.push('有逐字稿');
  635. }
  636. if (profile.keywords.length && profileMatchNote(profile, video).startsWith('匹配')) {
  637. score += 1;
  638. reasons.push('贴合监听关键词');
  639. }
  640. const label = score >= 5 ? '高' : score >= 3 ? '中' : '低';
  641. return { label, score, reason: reasons.join('、') || '证据不足' };
  642. }
  643. function commentScopeText(scope) {
  644. if (scope === 'linked') return '评论已按视频 ID 匹配';
  645. if (scope === 'single_video_unlinked') return '单视频样本,评论未带视频 ID,按同一视频处理';
  646. return '评论未匹配到该视频,未纳入本选题证据';
  647. }
  648. function buildEvidenceChain(profile, video, commentScope, transcript) {
  649. const stats = [
  650. video.playCount ? `播放 ${video.playCount}` : '',
  651. video.likeCount ? `点赞 ${video.likeCount}` : '',
  652. video.commentCount ? `评论 ${video.commentCount}` : '',
  653. video.shareCount ? `分享 ${video.shareCount}` : ''
  654. ].filter(Boolean).join(' / ') || '互动数据未提供';
  655. return [
  656. `来源视频:${video.author || '未知作者'} / ${video.id} / ${stats}`,
  657. `Profile 匹配:${profileMatchNote(profile, video)}`,
  658. `评论口径:${commentScopeText(commentScope.scope)},可用评论 ${commentScope.comments.length} 条`,
  659. `逐字稿:${transcript?.text ? `已提供(${transcript.provider || 'unknown'})` : '未提供,脚本结构基于描述和评论推断'}`
  660. ];
  661. }
  662. function buildTrendSignals(profile, data) {
  663. const keywordCounts = countBy(data.videos.map(video => video.keyword).concat(data.comments.map(comment => comment.keyword))).slice(0, 8);
  664. const hashtagCounts = countBy(data.videos.flatMap(video => video.hashtags)).slice(0, 8);
  665. const topAuthors = countBy(data.videos.map(video => video.author)).slice(0, 6);
  666. const signals = [];
  667. if (keywordCounts[0]) signals.push(`关键词「${keywordCounts[0].label}」样本最多,适合优先拆解选题。`);
  668. if (hashtagCounts[0]) signals.push(`话题「${hashtagCounts[0].label}」出现频率最高,可作为发布标签候选。`);
  669. if (topAuthors[0]) signals.push(`账号「${topAuthors[0].label}」在样本中出现最多,建议纳入对标观察。`);
  670. if (!signals.length && profile.keywords.length) signals.push(`已建立 ${profile.keywords.length} 个监听关键词,下一步需要采集抖音样本。`);
  671. return { keywordCounts, hashtagCounts, topAuthors, signals };
  672. }
  673. function buildTopicIdea(video, comments, index, totalVideos, transcripts, profile) {
  674. const commentScope = commentsForVideo(comments, video.id, totalVideos);
  675. const transcript = transcriptForVideo(transcripts, video.id);
  676. const transcriptInsights = buildTranscriptInsights(transcript, profile);
  677. const hookType = inferHookType(video.title);
  678. const conflict = inferConflict(video.title);
  679. const scopedTopComments = topComments(commentScope.comments);
  680. const questions = questionComments(scopedTopComments.length ? scopedTopComments : commentScope.comments);
  681. const confidence = confidenceForTopic(profile, video, commentScope, transcript);
  682. const evidence = [
  683. video.playCount ? `播放 ${video.playCount}` : '',
  684. video.likeCount ? `点赞 ${video.likeCount}` : '',
  685. video.commentCount ? `评论 ${video.commentCount}` : '',
  686. video.shareCount ? `分享 ${video.shareCount}` : ''
  687. ].filter(Boolean).join(' / ') || '互动数据不足';
  688. const title = video.title
  689. ? `把「${truncate(video.title, 32)}」改成你的账号口播`
  690. : `围绕「${video.keyword || `选题${index + 1}`}」做一条口播`;
  691. return {
  692. priority: index < 3 ? 'P0' : index < 6 ? 'P1' : 'P2',
  693. title,
  694. sourceVideo: {
  695. id: video.id,
  696. author: video.author,
  697. keyword: video.keyword,
  698. url: video.url,
  699. evidence
  700. },
  701. hookType,
  702. angle: conflict,
  703. scriptFrame: [
  704. `开头:先抛出一个具体判断,参考「${truncate(video.title, 42)}」。`,
  705. `中段:解释为什么用户会卡在「${conflict}」这里。`,
  706. '证明:用 2-3 个案例、数字或反例支撑,不要只喊口号。',
  707. `互动:结尾问「${truncate(questions[0]?.text || '你最想解决哪一步?', 36)}」引导评论。`
  708. ],
  709. commentTriggers: questions.map(comment => comment.text),
  710. transcriptInsights,
  711. draftScript: buildDraftScript({ profile, video, conflict, questions, transcriptInsights }),
  712. evidenceChain: buildEvidenceChain(profile, video, commentScope, transcript),
  713. confidence,
  714. riskNotes: [
  715. ...(transcript?.text ? [] : ['未提供逐字稿,不能还原完整原文,只能拆结构。']),
  716. ...(commentScope.comments.length ? [] : ['未匹配到该视频评论,建议补抓评论后再作为正式脚本。']),
  717. ...(commentScope.scope === 'single_video_unlinked' ? ['评论缺少视频 ID,仅因样本只有一条视频而暂时归并。'] : [])
  718. ]
  719. };
  720. }
  721. function nextQuestionsForMissingFields(profile) {
  722. const questionMap = {
  723. industry: '这个口播账号服务什么行业或品类?',
  724. accountPositioning: '账号定位更接近老板 IP、专家号、测评号、品牌号,还是别的形态?',
  725. targetAudience: '你最想吸引的人是谁?请给一个具体人群和他们最常见的痛点。',
  726. conversionGoal: '这个账号优先追求涨粉、咨询、卖课、卖货,还是品牌认知?',
  727. keywords: '先给我 3-5 个想长期监听的行业词、痛点词或对标账号。'
  728. };
  729. return requiredProfileFields(profile)
  730. .filter(item => !item.ok)
  731. .map(item => questionMap[item.field])
  732. .filter(Boolean);
  733. }
  734. function buildPrecisionCheck(profile, data, topicIdeas) {
  735. const transcriptCount = data.transcripts.filter(item => item.text).length;
  736. const highConfidenceCount = topicIdeas.filter(item => item.confidence.label === '高').length;
  737. const mediumConfidenceCount = topicIdeas.filter(item => item.confidence.label === '中').length;
  738. const profileComplete = requiredProfileFields(profile).every(item => item.ok);
  739. return {
  740. profileComplete,
  741. sampleScope: `${data.videos.length} 条视频 / ${data.comments.length} 条评论 / ${transcriptCount} 条逐字稿`,
  742. confidenceSummary: `${highConfidenceCount} 条高置信 / ${mediumConfidenceCount} 条中置信 / ${Math.max(0, topicIdeas.length - highConfidenceCount - mediumConfidenceCount)} 条低置信`,
  743. evidenceRules: [
  744. '每条选题优先只使用能按 aweme_id 匹配到该视频的评论。',
  745. '没有逐字稿时只做结构推断,不把视频描述当完整口播稿。',
  746. '日报给出候选方向和证据链,最终取舍留给用户确认。'
  747. ],
  748. nextQuestions: nextQuestionsForMissingFields(profile)
  749. };
  750. }
  751. function buildQualityGate(profile, data, topicIdeas) {
  752. const transcriptCount = data.transcripts.filter(item => item.text).length;
  753. const commentMatchedCount = data.comments.filter(comment => comment.parentId).length;
  754. const sampleEnough = data.videos.length >= 3;
  755. const commentCoverage = data.videos.length ? commentMatchedCount / data.videos.length : 0;
  756. const transcriptCoverage = data.videos.length ? transcriptCount / data.videos.length : 0;
  757. const profileComplete = requiredProfileFields(profile).every(item => item.ok);
  758. const issues = [];
  759. if (!data.videos.length) issues.push('缺少视频样本,不能生成有效日报。');
  760. if (!profileComplete) issues.push('profile 关键字段未补齐,成稿只能做弱适配。');
  761. if (data.videos.length && !sampleEnough) issues.push('视频样本少于 3 条,选题排序稳定性偏弱。');
  762. if (data.videos.length && !data.comments.length) issues.push('缺少评论样本,用户问题和互动钩子偏弱。');
  763. if (data.videos.length && transcriptCoverage === 0) issues.push('没有逐字稿,脚本只能基于标题、描述和评论做结构推断。');
  764. const level = !data.videos.length ? 'fail' : issues.length ? 'warn' : 'pass';
  765. return {
  766. level,
  767. sampleEnough,
  768. profileComplete,
  769. transcriptCoverage: Number(transcriptCoverage.toFixed(2)),
  770. commentCoverage: Number(commentCoverage.toFixed(2)),
  771. issues,
  772. recommendation: level === 'fail'
  773. ? '先补采抖音视频样本,再生成日报和脚本。'
  774. : level === 'warn'
  775. ? '可以进入共创,但需要在正文中保留风险说明,并优先补评论或逐字稿。'
  776. : '样本、评论和逐字稿信号满足本轮成稿要求。'
  777. };
  778. }
  779. function buildReport(profile, data, options = {}) {
  780. const scriptMemory = options.scriptMemory || {};
  781. const sortedVideos = [...data.videos].sort((a, b) => {
  782. const bScore = videoScore(b) + topicPreferenceScore(b, profile, scriptMemory);
  783. const aScore = videoScore(a) + topicPreferenceScore(a, profile, scriptMemory);
  784. return bScore - aScore;
  785. });
  786. const selected = sortedVideos.slice(0, Math.max(1, profile.dailyOutputCount || 8));
  787. const topicIdeas = selected.map((video, index) => buildTopicIdea(video, data.comments, index, data.videos.length, data.transcripts, profile));
  788. const trends = buildTrendSignals(profile, data);
  789. const warnings = [];
  790. const missing = requiredProfileFields(profile).filter(item => !item.ok).map(item => item.field);
  791. if (missing.length) warnings.push(`profile 缺少字段:${missing.join(', ')}`);
  792. if (!data.videos.length) warnings.push('未提供抖音视频样本,日报只能输出监听配置和采集建议。');
  793. if (data.videos.length && !data.comments.length) warnings.push('缺少评论样本,评论触发点和用户问题会偏弱。');
  794. const qualityGate = buildQualityGate(profile, data, topicIdeas);
  795. qualityGate.issues.forEach(issue => {
  796. if (!warnings.includes(issue)) warnings.push(issue);
  797. });
  798. const status = data.videos.length ? 'ok' : 'needs_data';
  799. return {
  800. status,
  801. generatedAt: new Date().toISOString(),
  802. profile,
  803. metadata: {
  804. ...(data.metadata || {}),
  805. scriptMemory: options.scriptMemorySummary || undefined
  806. },
  807. summary: {
  808. videoCount: data.videos.length,
  809. commentCount: data.comments.length,
  810. hashtagCount: data.hashtags.length,
  811. transcriptCount: data.transcripts.filter(item => item.text).length,
  812. transcriptInsightCount: topicIdeas.filter(item => item.transcriptInsights).length,
  813. draftScriptCount: topicIdeas.filter(item => item.draftScript).length,
  814. topicIdeaCount: topicIdeas.length,
  815. keywordCount: profile.keywords.length,
  816. referenceAccountCount: profile.referenceAccounts.length
  817. },
  818. oneLineJudgement: data.videos.length
  819. ? `今天优先围绕「${topicIdeas[0]?.angle || profile.industry || '核心行业'}」做一条有冲突、有证据、有评论钩子的口播。`
  820. : `先补齐「${profile.keywords.slice(0, 3).join('、') || profile.industry || '行业关键词'}」的抖音样本,再生成正式稿件日报。`,
  821. trendSignals: trends,
  822. precisionCheck: buildPrecisionCheck(profile, data, topicIdeas),
  823. qualityGate,
  824. topVideos: selected.map(video => ({
  825. id: video.id,
  826. title: video.title,
  827. author: video.author,
  828. keyword: video.keyword,
  829. score: Math.round(videoScore(video)),
  830. likeCount: video.likeCount,
  831. commentCount: video.commentCount,
  832. shareCount: video.shareCount,
  833. playCount: video.playCount,
  834. url: video.url
  835. })),
  836. topicIdeas,
  837. calibrationQuestions: [
  838. '这 5-10 条选题里,哪 3 条最像你的账号能讲的内容?',
  839. '哪些方向不要再出现?我会写入禁区并在下次降权。',
  840. '你希望下一版口播更偏老板视角、专家视角、测评视角,还是故事视角?',
  841. '是否要新增/删除监听关键词或对标账号?'
  842. ],
  843. warnings
  844. };
  845. }
  846. function requiredProfileFields(profile) {
  847. return [
  848. { field: 'industry', ok: Boolean(profile.industry) },
  849. { field: 'accountPositioning', ok: Boolean(profile.accountPositioning) },
  850. { field: 'targetAudience', ok: Boolean(profile.targetAudience) },
  851. { field: 'conversionGoal', ok: Boolean(profile.conversionGoal) },
  852. { field: 'keywords', ok: profile.keywords.length > 0 }
  853. ];
  854. }
  855. function renderProfile(profile) {
  856. return [
  857. `- 项目:${profile.projectName}`,
  858. `- 行业/品类:${profile.industry || '待补充'}`,
  859. `- 账号定位:${profile.accountPositioning || '待补充'}`,
  860. `- 目标人群:${profile.targetAudience || '待补充'}`,
  861. `- 转化目标:${profile.conversionGoal || '待补充'}`,
  862. `- 核心产品/服务:${profile.coreOffer || '未填写'}`,
  863. `- 内容栏目:${profile.contentPillars.join('、') || '待补充'}`,
  864. `- 监听关键词:${profile.keywords.join('、') || '待补充'}`,
  865. `- 对标账号:${profile.referenceAccounts.join('、') || '待补充'}`,
  866. `- 禁区:${profile.forbiddenTopics.join('、') || '未填写'}`
  867. ].join('\n');
  868. }
  869. function renderMarkdown(report) {
  870. const lines = [];
  871. lines.push('# 抖音口播每日稿件日报');
  872. lines.push('');
  873. lines.push(`生成时间:${report.generatedAt}`);
  874. lines.push('');
  875. lines.push('## 账号配置');
  876. lines.push('');
  877. lines.push(renderProfile(report.profile));
  878. lines.push('');
  879. lines.push('## 今日一句话判断');
  880. lines.push('');
  881. lines.push(report.oneLineJudgement);
  882. lines.push('');
  883. lines.push('## 判断口径与置信度');
  884. lines.push('');
  885. lines.push(`- 样本口径:${report.precisionCheck.sampleScope}`);
  886. lines.push(`- 选题置信度:${report.precisionCheck.confidenceSummary}`);
  887. report.precisionCheck.evidenceRules.forEach(rule => lines.push(`- ${rule}`));
  888. if (report.precisionCheck.nextQuestions.length) {
  889. lines.push('');
  890. lines.push('待补资料:');
  891. report.precisionCheck.nextQuestions.forEach(question => lines.push(`- ${question}`));
  892. }
  893. lines.push('');
  894. lines.push('## 趋势信号');
  895. lines.push('');
  896. if (report.trendSignals.signals.length) {
  897. report.trendSignals.signals.forEach(signal => lines.push(`- ${signal}`));
  898. } else {
  899. lines.push('- 暂无趋势信号,需要先采集抖音样本。');
  900. }
  901. lines.push('');
  902. lines.push('## 今日选题池');
  903. lines.push('');
  904. if (!report.topicIdeas.length) {
  905. lines.push('暂无可生成选题。请先运行 `douyin-speaking-keyword-monitor` 或提供抖音采集数据。');
  906. lines.push('');
  907. }
  908. report.topicIdeas.forEach((idea, index) => {
  909. lines.push(`### ${index + 1}. [${idea.priority}] ${idea.title}`);
  910. lines.push('');
  911. lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.keyword || '未标注关键词'} / ${idea.sourceVideo.evidence}`);
  912. if (idea.sourceVideo.url) lines.push(`- 链接:${idea.sourceVideo.url}`);
  913. lines.push(`- 钩子类型:${idea.hookType}`);
  914. lines.push(`- 选题角度:${idea.angle}`);
  915. lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`);
  916. lines.push('');
  917. lines.push('证据链:');
  918. idea.evidenceChain.forEach(evidence => lines.push(`- ${evidence}`));
  919. lines.push('');
  920. lines.push('口播框架:');
  921. idea.scriptFrame.forEach(step => lines.push(`- ${step}`));
  922. if (idea.transcriptInsights) {
  923. lines.push('');
  924. lines.push('逐字稿拆解:');
  925. lines.push(`- 开头原型:${idea.transcriptInsights.openingExcerpt}`);
  926. lines.push(`- 文本长度:${idea.transcriptInsights.textLength} 字 / ${idea.transcriptInsights.provider}`);
  927. if (idea.transcriptInsights.structure.length) {
  928. lines.push('- 结构节奏:');
  929. idea.transcriptInsights.structure.forEach(item => lines.push(` - ${item.label}:${item.summary}`));
  930. }
  931. if (idea.transcriptInsights.reusableTactics.length) {
  932. lines.push('- 可复用打法:');
  933. idea.transcriptInsights.reusableTactics.forEach(item => lines.push(` - ${item}`));
  934. }
  935. if (idea.transcriptInsights.reusableLines.length) {
  936. lines.push('- 可借鉴表达:');
  937. idea.transcriptInsights.reusableLines.forEach(item => lines.push(` - ${item}`));
  938. }
  939. lines.push('- 改写到当前账号:');
  940. idea.transcriptInsights.adaptationGuidance.forEach(item => lines.push(` - ${item}`));
  941. lines.push('- 不建议照搬:');
  942. idea.transcriptInsights.avoidCopying.forEach(item => lines.push(` - ${item}`));
  943. }
  944. if (idea.draftScript) {
  945. lines.push('');
  946. lines.push('对应自己主题的口播稿草案:');
  947. lines.push(`- 标题:${idea.draftScript.title}`);
  948. lines.push(`- 主题:${idea.draftScript.theme}`);
  949. lines.push(`- 预计时长:${idea.draftScript.estimatedDurationSec} 秒`);
  950. lines.push('- 改写依据:');
  951. idea.draftScript.rewriteBasis.forEach(item => lines.push(` - ${item}`));
  952. lines.push('');
  953. lines.push(idea.draftScript.fullText);
  954. lines.push('');
  955. lines.push(`- 收口:${idea.draftScript.cta}`);
  956. }
  957. if (idea.commentTriggers.length) {
  958. lines.push('');
  959. lines.push('评论触发点:');
  960. idea.commentTriggers.forEach(text => lines.push(`- ${text}`));
  961. }
  962. if (idea.riskNotes.length) {
  963. lines.push('');
  964. lines.push('风险提醒:');
  965. idea.riskNotes.forEach(text => lines.push(`- ${text}`));
  966. }
  967. lines.push('');
  968. });
  969. lines.push('## 明日监听建议');
  970. lines.push('');
  971. lines.push('- 保留今天表现最好的 3 个关键词,新增 1-2 个评论里出现的追问词。');
  972. lines.push('- 对 P0 选题对应视频补抓评论和热评回复。');
  973. lines.push('- 选 1 条高价值视频进入逐字稿转写,再做更细的口播拆解。');
  974. lines.push('');
  975. if (report.warnings.length) {
  976. lines.push('## 审计提醒');
  977. lines.push('');
  978. report.warnings.forEach(warning => lines.push(`- ${warning}`));
  979. lines.push('');
  980. }
  981. lines.push('## 校准问题');
  982. lines.push('');
  983. report.calibrationQuestions.forEach(question => lines.push(`- ${question}`));
  984. return lines.join('\n');
  985. }
  986. function buildAudit(report) {
  987. return {
  988. status: report.status,
  989. generatedAt: report.generatedAt,
  990. warnings: report.warnings,
  991. autoTranscript: report.metadata?.autoTranscript || {},
  992. scriptMemory: report.metadata?.scriptMemory || {},
  993. qualityGate: report.qualityGate,
  994. precisionCheck: report.precisionCheck,
  995. profileCompleteness: requiredProfileFields(report.profile).map(item => ({
  996. field: item.field,
  997. ok: item.ok
  998. })),
  999. sampleCounts: report.summary
  1000. };
  1001. }
  1002. function renderChatMarkdown(report, mdPath) {
  1003. const lines = [];
  1004. const topIdeas = report.topicIdeas.slice(0, 5);
  1005. const remaining = Math.max(0, report.topicIdeas.length - topIdeas.length);
  1006. lines.push('# 抖音口播每日稿件日报');
  1007. lines.push('');
  1008. lines.push('## 今日一句话判断');
  1009. lines.push('');
  1010. lines.push(report.oneLineJudgement);
  1011. lines.push('');
  1012. lines.push('## 判断口径');
  1013. lines.push('');
  1014. lines.push(`- 样本:${report.summary.videoCount} 条视频 / ${report.summary.commentCount} 条评论 / ${report.summary.transcriptCount} 条逐字稿`);
  1015. lines.push(`- 选题:${report.topicIdeas.length} 条(本次直接展示 Top ${topIdeas.length}${remaining ? `,另有 ${remaining} 条已写入本地备份` : ''})`);
  1016. lines.push(`- 置信度:${report.precisionCheck.confidenceSummary}`);
  1017. lines.push(`- 质量门禁:${report.qualityGate.level}(${report.qualityGate.recommendation})`);
  1018. if (asArray(report.metadata?.scriptMemory?.summary).length) {
  1019. lines.push(`- 历史偏好:${report.metadata.scriptMemory.summary.join(';')}`);
  1020. }
  1021. if (report.trendSignals.signals.length) {
  1022. lines.push('');
  1023. lines.push('## 趋势信号');
  1024. lines.push('');
  1025. report.trendSignals.signals.slice(0, 3).forEach(signal => lines.push(`- ${signal}`));
  1026. }
  1027. if (topIdeas.length) {
  1028. lines.push('');
  1029. lines.push('## 今日热点选题池');
  1030. lines.push('');
  1031. topIdeas.forEach((idea, index) => {
  1032. lines.push(`### ${index + 1}. [${idea.priority}] ${idea.title}`);
  1033. lines.push('');
  1034. lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.evidence}`);
  1035. lines.push(`- 钩子类型:${idea.hookType}`);
  1036. lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`);
  1037. lines.push(`- 可讲方向:${idea.angle}`);
  1038. if (idea.transcriptInsights) {
  1039. lines.push(`- 逐字稿:已就绪(${idea.transcriptInsights.textLength} 字,${idea.transcriptInsights.provider}),可直接生成脚本`);
  1040. } else {
  1041. lines.push('- 逐字稿:未补,适合先确认选题再转写/扩写');
  1042. }
  1043. if (idea.commentTriggers[0]) {
  1044. lines.push(`- 评论信号:${truncate(idea.commentTriggers[0], 72)}`);
  1045. }
  1046. lines.push('');
  1047. });
  1048. }
  1049. lines.push('');
  1050. lines.push('## 下一步操作');
  1051. lines.push('');
  1052. lines.push('- 回复「选题 2」:我会展开该选题的逐字稿拆解、借鉴点和完整口播稿。');
  1053. lines.push('- 回复「保留 1,2,3」:我会把这些方向写入偏好,下次排序加权。');
  1054. lines.push('- 回复「不要 某方向」:我会写入禁区,下次降权。');
  1055. lines.push('');
  1056. lines.push('## 建议校准');
  1057. lines.push('');
  1058. lines.push('1. Top 5 里先选哪 1 条进入脚本稿?');
  1059. lines.push('2. 下一版更偏老板视角、专家视角、案例拆解,还是产品转化?');
  1060. lines.push('3. 要不要新增/删除监听关键词或对标账号?');
  1061. if (mdPath) {
  1062. lines.push('');
  1063. lines.push('## 本地备份');
  1064. lines.push('');
  1065. lines.push(`完整日报已同步保存:${mdPath}`);
  1066. }
  1067. return lines.join('\n');
  1068. }
  1069. function renderSelectedTopicDraft(report, selectedIndex, mdPath) {
  1070. const idea = report.topicIdeas[selectedIndex - 1];
  1071. const lines = [];
  1072. if (!idea) {
  1073. return `未找到第 ${selectedIndex} 条选题。当前日报共有 ${report.topicIdeas.length} 条选题,请回复有效序号。`;
  1074. }
  1075. lines.push(`# 选题 ${selectedIndex} 脚本稿件`);
  1076. lines.push('');
  1077. lines.push(`## 选题`);
  1078. lines.push('');
  1079. lines.push(`- 标题:${idea.title}`);
  1080. lines.push(`- 来源:${idea.sourceVideo.author || '未知作者'} / ${idea.sourceVideo.evidence}`);
  1081. lines.push(`- 链接:${idea.sourceVideo.url || '未提供'}`);
  1082. lines.push(`- 置信度:${idea.confidence.label}(${idea.confidence.reason})`);
  1083. lines.push(`- 可讲方向:${idea.angle}`);
  1084. lines.push('');
  1085. lines.push('## 借鉴点');
  1086. lines.push('');
  1087. if (idea.transcriptInsights) {
  1088. lines.push(`- 逐字稿:已提供(${idea.transcriptInsights.provider},${idea.transcriptInsights.textLength} 字)`);
  1089. lines.push(`- 开头原型:${idea.transcriptInsights.openingExcerpt}`);
  1090. if (idea.transcriptInsights.structure.length) {
  1091. lines.push('- 结构节奏:');
  1092. idea.transcriptInsights.structure.slice(0, 4).forEach(item => lines.push(` - ${item.label}:${item.summary}`));
  1093. }
  1094. if (idea.transcriptInsights.reusableTactics.length) {
  1095. lines.push('- 可复用打法:');
  1096. idea.transcriptInsights.reusableTactics.slice(0, 4).forEach(item => lines.push(` - ${item}`));
  1097. }
  1098. if (idea.transcriptInsights.reusableLines.length) {
  1099. lines.push('- 可借鉴表达:');
  1100. idea.transcriptInsights.reusableLines.slice(0, 3).forEach(item => lines.push(` - ${item}`));
  1101. }
  1102. } else {
  1103. lines.push('- 该选题还没有逐字稿,下面先给结构稿;正式拍摄前建议补转写。');
  1104. idea.scriptFrame.slice(0, 4).forEach(step => lines.push(`- ${step}`));
  1105. }
  1106. lines.push('');
  1107. lines.push('## 初版口播稿');
  1108. lines.push('');
  1109. if (idea.draftScript) {
  1110. lines.push(`### ${idea.draftScript.title}`);
  1111. lines.push('');
  1112. lines.push(`- 主题:${idea.draftScript.theme}`);
  1113. lines.push(`- 预计时长:${idea.draftScript.estimatedDurationSec} 秒`);
  1114. lines.push('- 改写依据:');
  1115. idea.draftScript.rewriteBasis.forEach(item => lines.push(` - ${item}`));
  1116. lines.push('');
  1117. lines.push(idea.draftScript.fullText);
  1118. lines.push('');
  1119. lines.push(`收口:${idea.draftScript.cta}`);
  1120. } else {
  1121. lines.push(idea.scriptFrame.join('\n'));
  1122. }
  1123. if (idea.commentTriggers.length) {
  1124. lines.push('');
  1125. lines.push('## 可用评论钩子');
  1126. lines.push('');
  1127. idea.commentTriggers.slice(0, 3).forEach(text => lines.push(`- ${text}`));
  1128. }
  1129. if (idea.riskNotes.length) {
  1130. lines.push('');
  1131. lines.push('## 风险提醒');
  1132. lines.push('');
  1133. idea.riskNotes.forEach(text => lines.push(`- ${text}`));
  1134. }
  1135. lines.push('');
  1136. lines.push('## 请确认');
  1137. lines.push('');
  1138. lines.push('1. 这个方向是否保留?');
  1139. lines.push('2. 开头要更强冲突、更专业,还是更像老板聊天?');
  1140. lines.push('3. 是否需要我继续改成 30 秒、60 秒或 90 秒版本?');
  1141. if (mdPath) {
  1142. lines.push('');
  1143. lines.push(`日报备份:${mdPath}`);
  1144. }
  1145. return lines.join('\n');
  1146. }
  1147. function fallbackScriptForIdea(profile, idea) {
  1148. const topic = resolveScriptTopicKeyword(profile, idea);
  1149. const ctx = scriptDeliveryContext(profile, topic);
  1150. const sourceTitle = cleanText(idea?.sourceVideo?.title || idea?.title || '这条爆款');
  1151. const hook = `${ctx.audience}做${topic},最容易误判的不是热点本身,而是把别人内容里的结论,直接当成自己的动作。`;
  1152. return [
  1153. hook,
  1154. `这条爆款真正值得借鉴的,不是它讲了「${truncate(sourceTitle, 34)}」,而是它先制造了一个反差:观众以为自己懂了,结果发现真正的问题在另一个地方。`,
  1155. `换到${ctx.industry},我们要讲的不是泛泛的${topic}科普,而是先指出一个错误动作:很多人看起来在跟热点,实际上只是在复制标题、复制动作、复制别人的判断。`,
  1156. `正确做法可以拆成三步:第一,找到${ctx.audience}最近反复出现的${ctx.userSignal};第二,把这个信号翻译成一个明确判断;第三,用${ctx.proofSource}证明它。`,
  1157. `所以这条稿子可以这样收:如果你也有一个方向,看起来能讲但不知道怎么讲,评论区告诉我你现在卡在哪一步,我会按这个框架帮你拆成能被理解、被收藏、被咨询的口播。`,
  1158. `收口:围绕「${ctx.goal}」收口,不做泛娱乐或纯搬运。`
  1159. ].join('\n\n');
  1160. }
  1161. function baseScriptForIdea(idea, profile = {}) {
  1162. if (!idea) return '';
  1163. if (idea.draftScript?.fullText) return idea.draftScript.fullText;
  1164. const frame = asArray(idea.scriptFrame).map(item => String(item || '').trim()).filter(Boolean);
  1165. if (!frame.length) return fallbackScriptForIdea(profile, idea);
  1166. return [
  1167. fallbackScriptForIdea(profile, idea),
  1168. '结构备忘:',
  1169. ...frame.map(item => `- ${item}`)
  1170. ].join('\n\n');
  1171. }
  1172. function baseTitleForIdea(idea, profile = {}) {
  1173. if (idea?.draftScript?.title) return idea.draftScript.title;
  1174. const topic = resolveScriptTopicKeyword(profile, idea);
  1175. return topic ? `别再照抄爆款,先把「${topic}」变成可执行判断` : idea?.title || '口播稿';
  1176. }
  1177. function resolveScriptTopicKeyword(profile, idea) {
  1178. const raw = cleanText(
  1179. idea?.draftScript?.theme
  1180. || idea?.sourceVideo?.keyword
  1181. || splitList(profile?.keywords)[0]
  1182. || profileListText(profile?.industry, '', 1)
  1183. || idea?.topicAngle
  1184. || idea?.angle
  1185. || '这个方向'
  1186. );
  1187. const themeParts = raw.split('/').map(item => cleanText(item)).filter(Boolean);
  1188. return themeParts.slice(-1)[0] || raw;
  1189. }
  1190. function scriptTopicText(session, fallback = '这个方向') {
  1191. return cleanText(
  1192. session?.topicKeyword
  1193. || session?.sourceEvidence?.keyword
  1194. || session?.profileSnapshot?.primaryKeyword
  1195. || profileListText(session?.profileSnapshot?.industry, '', 1)
  1196. || session?.topicAngle
  1197. || fallback
  1198. );
  1199. }
  1200. function scriptParagraphs(text) {
  1201. return String(text || '')
  1202. .split(/\n\s*\n/)
  1203. .map(item => cleanText(item))
  1204. .filter(Boolean);
  1205. }
  1206. function makeQualityGateBlockedSession({ profile, report, selectedTopicIndex, scriptMemory, scriptMemoryPath }) {
  1207. const now = new Date().toISOString();
  1208. const memorySummary = scriptMemorySummary(scriptMemory || {}, scriptMemoryPath || '');
  1209. const issues = asArray(report.qualityGate?.issues);
  1210. const repairLines = [
  1211. '质量门禁未通过,先不生成正式口播稿。',
  1212. '',
  1213. '当前缺口:',
  1214. ...(issues.length ? issues.map(item => `- ${item}`) : ['- 当前样本不足,无法形成可靠选题。']),
  1215. '',
  1216. '建议先补齐:',
  1217. '- 至少 3 条候选视频样本',
  1218. '- 每条 P0/P1 候选视频尽量有评论样本',
  1219. '- 至少 1 条高价值视频逐字稿',
  1220. '- profile 中的项目、行业、目标人群、转化目标、关键词',
  1221. '',
  1222. '补齐后再回复“重新跑日报”或重新选择选题进入脚本共创。'
  1223. ].join('\n');
  1224. return {
  1225. version: 1,
  1226. sessionId: `script_blocked_${selectedTopicIndex || Date.now()}`,
  1227. projectName: profile.projectName,
  1228. status: 'blocked',
  1229. createdAt: now,
  1230. updatedAt: now,
  1231. selectedTopicIndex,
  1232. sourceAwemeId: '',
  1233. topicTitle: '质量门禁未通过,暂不生成正式稿',
  1234. sourceEvidence: {},
  1235. profileSnapshot: {
  1236. industry: profile.industry,
  1237. accountPositioning: profile.accountPositioning,
  1238. targetAudience: profile.targetAudience,
  1239. conversionGoal: profile.conversionGoal,
  1240. coreOffer: profile.coreOffer,
  1241. primaryKeyword: profile.keywords[0] || ''
  1242. },
  1243. transcriptInsights: null,
  1244. commentTriggers: [],
  1245. riskNotes: issues,
  1246. qualityGate: report.qualityGate,
  1247. memorySnapshot: memorySummary,
  1248. versions: [{
  1249. version: 'v0',
  1250. status: 'blocked',
  1251. title: '先补数据,再生成正式稿',
  1252. script: repairLines,
  1253. strategy: 'qualityGate.fail',
  1254. changeNotes: ['已阻断正式脚本生成,避免把低证据内容包装成可交付稿件。'],
  1255. createdAt: now
  1256. }],
  1257. feedbackTurns: [],
  1258. finalScript: null,
  1259. memoryWrites: {}
  1260. };
  1261. }
  1262. function makeScriptSession({ profile, report, idea, selectedTopicIndex, existingSession, scriptMemory, scriptMemoryPath }) {
  1263. const now = new Date().toISOString();
  1264. const memorySummary = scriptMemorySummary(scriptMemory || {}, scriptMemoryPath || '');
  1265. if (existingSession?.sessionId) {
  1266. return {
  1267. ...existingSession,
  1268. topicKeyword: existingSession.topicKeyword || resolveScriptTopicKeyword(profile, idea),
  1269. memorySnapshot: existingSession.memorySnapshot || memorySummary,
  1270. qualityGate: existingSession.qualityGate || report.qualityGate,
  1271. updatedAt: now
  1272. };
  1273. }
  1274. if (!idea && report.qualityGate?.level === 'fail') {
  1275. return makeQualityGateBlockedSession({ profile, report, selectedTopicIndex, scriptMemory, scriptMemoryPath });
  1276. }
  1277. if (!idea) {
  1278. return {
  1279. version: 1,
  1280. sessionId: `script_missing_${selectedTopicIndex || Date.now()}`,
  1281. projectName: profile.projectName,
  1282. status: 'error',
  1283. createdAt: now,
  1284. updatedAt: now,
  1285. selectedTopicIndex,
  1286. sourceAwemeId: '',
  1287. topicTitle: `未找到第 ${selectedTopicIndex} 条选题`,
  1288. sourceEvidence: {},
  1289. profileSnapshot: {},
  1290. transcriptInsights: null,
  1291. commentTriggers: [],
  1292. riskNotes: [`当前日报共有 ${report.topicIdeas.length} 条选题,请传入有效 selectedTopicIndex。`],
  1293. versions: [{
  1294. version: 'v0',
  1295. status: 'error',
  1296. title: '未找到选题',
  1297. script: `未找到第 ${selectedTopicIndex} 条选题。当前日报共有 ${report.topicIdeas.length} 条选题,请重新选择。`,
  1298. strategy: 'invalid selectedTopicIndex',
  1299. changeNotes: [],
  1300. createdAt: now
  1301. }],
  1302. feedbackTurns: [],
  1303. finalScript: null,
  1304. memoryWrites: {}
  1305. };
  1306. }
  1307. const sessionId = `script_${idea.sourceVideo.id || selectedTopicIndex}_${Date.now()}`;
  1308. const baseVersion = {
  1309. version: 'v0',
  1310. status: 'draft',
  1311. title: baseTitleForIdea(idea, profile),
  1312. script: baseScriptForIdea(idea, profile),
  1313. cta: idea.draftScript?.cta || '',
  1314. strategy: '基于日报选题、来源视频结构、评论信号和当前账号 profile 生成的初版共创稿。',
  1315. changeNotes: [
  1316. '迁移原视频结构和证明方式,不复刻原作者人设。',
  1317. idea.transcriptInsights ? '已引用逐字稿结构拆解。' : '缺少逐字稿,当前为结构推断稿。'
  1318. ],
  1319. createdAt: now
  1320. };
  1321. const session = {
  1322. version: 1,
  1323. sessionId,
  1324. projectName: profile.projectName,
  1325. status: 'draft',
  1326. createdAt: now,
  1327. updatedAt: now,
  1328. selectedTopicIndex,
  1329. sourceAwemeId: idea.sourceVideo.id,
  1330. topicTitle: idea.title,
  1331. topicKeyword: resolveScriptTopicKeyword(profile, idea),
  1332. topicAngle: idea.angle,
  1333. sourceEvidence: {
  1334. author: idea.sourceVideo.author,
  1335. url: idea.sourceVideo.url,
  1336. evidence: idea.sourceVideo.evidence,
  1337. confidence: idea.confidence
  1338. },
  1339. profileSnapshot: {
  1340. industry: profile.industry,
  1341. accountPositioning: profile.accountPositioning,
  1342. targetAudience: profile.targetAudience,
  1343. conversionGoal: profile.conversionGoal,
  1344. coreOffer: profile.coreOffer,
  1345. primaryKeyword: resolveScriptTopicKeyword(profile, idea),
  1346. tone: profile.tone
  1347. },
  1348. transcriptInsights: idea.transcriptInsights || null,
  1349. commentTriggers: idea.commentTriggers || [],
  1350. riskNotes: idea.riskNotes || [],
  1351. qualityGate: report.qualityGate,
  1352. memorySnapshot: memorySummary,
  1353. versions: [baseVersion],
  1354. feedbackTurns: [],
  1355. finalScript: null,
  1356. memoryWrites: {}
  1357. };
  1358. const memoryPreference = preferenceFromScriptMemory(scriptMemory || {});
  1359. if (memoryPreference.summary.length) {
  1360. baseVersion.script = reviseScriptText(session, memoryPreference);
  1361. baseVersion.strategy += ` 已参考脚本偏好记忆:${memoryPreference.summary.join('、')}。`;
  1362. baseVersion.changeNotes.push(...memoryPreference.summary);
  1363. }
  1364. return session;
  1365. }
  1366. function latestScriptVersion(session) {
  1367. return asArray(session.versions).filter(Boolean).slice(-1)[0] || {};
  1368. }
  1369. function nextVersionName(session) {
  1370. return `v${asArray(session.versions).length}`;
  1371. }
  1372. function parseScriptFeedback(feedback) {
  1373. const text = cleanText(feedback);
  1374. const preference = {
  1375. raw: text,
  1376. tone: '',
  1377. hookStrength: '',
  1378. duration: '',
  1379. ctaStyle: '',
  1380. add: [],
  1381. avoid: [],
  1382. summary: []
  1383. };
  1384. if (/老板|经营者|决策/i.test(text)) {
  1385. preference.tone = '老板视角';
  1386. preference.summary.push('更偏老板/经营决策视角');
  1387. } else if (/专家|专业|顾问|方法论/i.test(text)) {
  1388. preference.tone = '专家视角';
  1389. preference.summary.push('更偏专家/方法论视角');
  1390. } else if (/案例|场景|具体|B2B|b2b/i.test(text)) {
  1391. preference.tone = '案例拆解';
  1392. }
  1393. if (/案例|场景|具体|B2B|b2b|客户|行业/i.test(text)) {
  1394. if (!preference.add.includes('具体场景')) preference.add.push('具体场景');
  1395. if (!preference.summary.includes('增加具体场景或案例感')) preference.summary.push('增加具体场景或案例感');
  1396. }
  1397. if (/狠|冲突|尖锐|抓人|痛点|开头/i.test(text)) {
  1398. preference.hookStrength = 'strong';
  1399. preference.summary.push('开头冲突更强');
  1400. }
  1401. if (/30\s*秒|30s|更短|短一点/i.test(text)) {
  1402. preference.duration = '30s';
  1403. preference.summary.push('压缩成短稿');
  1404. } else if (/90\s*秒|90s|更长|展开/i.test(text)) {
  1405. preference.duration = '90s';
  1406. preference.summary.push('展开成更完整版本');
  1407. } else if (/60\s*秒|60s/i.test(text)) {
  1408. preference.duration = '60s';
  1409. preference.summary.push('控制在 60 秒左右');
  1410. }
  1411. if (/转化|咨询|预约|成交|产品|服务|私域/i.test(text)) {
  1412. preference.ctaStyle = '产品转化';
  1413. preference.summary.push('结尾更偏产品/咨询转化');
  1414. }
  1415. if (/不要|去掉|别|不想|太泛|泛泛|空/i.test(text)) {
  1416. preference.avoid.push('泛泛表达');
  1417. preference.summary.push('减少泛泛表达');
  1418. }
  1419. if (!preference.summary.length) preference.summary.push('按用户反馈收紧表达和结构');
  1420. return preference;
  1421. }
  1422. function revisedHook(session, preference) {
  1423. const topic = scriptTopicText(session);
  1424. const ctx = scriptDeliveryContext(session.profileSnapshot || {}, topic);
  1425. if (preference.hookStrength === 'strong') {
  1426. return `${ctx.audience}做${topic},最危险的不是没追热点,而是把热闹误判成机会。看起来在追爆款,实际是在替别人验证内容。`;
  1427. }
  1428. if (preference.tone === '老板视角') {
  1429. return `老板看${topic},先别问工具好不好用,先问它能不能帮你少做一次错误决策。`;
  1430. }
  1431. if (preference.tone === '专家视角') {
  1432. return `判断${topic}值不值得做,不能只看播放量,要先看它背后的误判、证据和用户行动。`;
  1433. }
  1434. return '';
  1435. }
  1436. function reviseScriptText(session, preference) {
  1437. const latest = latestScriptVersion(session);
  1438. const base = scriptParagraphs(latest.script);
  1439. const profileSnapshot = session.profileSnapshot || {};
  1440. const topic = scriptTopicText(session);
  1441. const ctx = scriptDeliveryContext(profileSnapshot, topic);
  1442. const offer = profileListText(profileSnapshot.coreOffer, '你的方法论、产品或服务', 1);
  1443. const hook = revisedHook(session, preference) || base[0] || '';
  1444. const paragraphs = [hook, ...base.slice(1)];
  1445. if (preference.tone === '老板视角') {
  1446. if (!paragraphs.some(item => /老板要的不是/.test(item))) {
  1447. paragraphs.splice(1, 0, `老板要的不是多一个内容动作,而是少一次无效试错:${ctx.actionQuestion},下一步该怎么验证。`);
  1448. }
  1449. }
  1450. if (preference.tone === '专家视角') {
  1451. if (!paragraphs.some(item => /三步判断/.test(item))) {
  1452. paragraphs.splice(1, 0, `可以用一个三步判断:第一,看它击中了什么错误认知;第二,看它用了什么证据建立信任;第三,看它把用户推向了什么行动。`);
  1453. }
  1454. }
  1455. if (preference.tone === '案例拆解' || preference.add.includes('具体场景')) {
  1456. if (!paragraphs.some(item => /^举个场景/.test(item))) {
  1457. const insertAt = Math.min(preference.tone === '老板视角' ? 2 : 1, paragraphs.length);
  1458. paragraphs.splice(insertAt, 0, `举个场景:${ctx.scenarioLabel},一种做法只看爆款标题,另一种做法先看${ctx.userSignal},再决定内容角度,最后产出的结果完全不同。`);
  1459. }
  1460. }
  1461. if (preference.ctaStyle === '产品转化') {
  1462. paragraphs[paragraphs.length - 1] = `如果你也想把${ctx.audience}的真实问题变成可执行的选题、内容和转化判断,可以先从一次小样本诊断开始。评论区告诉我你现在卡在哪一步,我会按这个框架帮你拆。`;
  1463. }
  1464. if (preference.avoid.includes('泛泛表达')) {
  1465. if (!paragraphs.some(item => /不要停在/.test(item))) {
  1466. paragraphs.push(`这条内容不要停在“要做${offer}”这种口号上,要落到一个具体判断:今天到底该保留哪个选题、删掉哪个方向、用哪条证据说服用户。`);
  1467. }
  1468. }
  1469. if (preference.duration === '30s') {
  1470. return [paragraphs[0], paragraphs.find(item => /三步|第一|判断|证据/.test(item)) || paragraphs[1], paragraphs[paragraphs.length - 1]]
  1471. .filter(Boolean)
  1472. .join('\n\n');
  1473. }
  1474. if (preference.duration === '90s') {
  1475. paragraphs.splice(Math.min(3, paragraphs.length), 0, `这里真正的关键,不是“照着爆款写一句类似的话”,而是把爆款背后的结构翻译成你自己的业务证据。没有证据,就只是模仿;有证据,才可能变成信任。`);
  1476. }
  1477. return paragraphs.filter(Boolean).join('\n\n');
  1478. }
  1479. function applyFeedbackToSession(session, feedback) {
  1480. if (session.status === 'blocked') {
  1481. session.updatedAt = new Date().toISOString();
  1482. return session;
  1483. }
  1484. const preference = parseScriptFeedback(feedback);
  1485. const version = nextVersionName(session);
  1486. const revisedScript = reviseScriptText(session, preference);
  1487. const now = new Date().toISOString();
  1488. session.feedbackTurns = asArray(session.feedbackTurns);
  1489. session.versions = asArray(session.versions);
  1490. session.feedbackTurns.push({
  1491. turn: session.feedbackTurns.length + 1,
  1492. userFeedback: cleanText(feedback),
  1493. parsedPreference: preference,
  1494. appliedVersion: version,
  1495. createdAt: now
  1496. });
  1497. session.versions.push({
  1498. version,
  1499. status: 'draft',
  1500. title: latestScriptVersion(session).title || '共创稿',
  1501. script: revisedScript,
  1502. cta: latestScriptVersion(session).cta || '',
  1503. strategy: `根据用户反馈调整:${preference.summary.join('、')}`,
  1504. changeNotes: preference.summary,
  1505. createdAt: now
  1506. });
  1507. session.status = 'draft';
  1508. session.updatedAt = now;
  1509. return session;
  1510. }
  1511. function defaultScriptMemoryPath(args) {
  1512. const configured = args.scriptMemory || args['script-memory'];
  1513. return path.resolve(hasConcreteArg(configured) ? configured : path.join('memory', 'douyin-speaking-script-memory.json'));
  1514. }
  1515. function normalizeScriptMemory(memory) {
  1516. const normalized = memory && typeof memory === 'object' ? { ...memory } : {};
  1517. normalized.sessions = asArray(normalized.sessions);
  1518. normalized.preferenceSignals = normalized.preferenceSignals || {};
  1519. normalized.preferenceSignals.tones = normalized.preferenceSignals.tones || {};
  1520. normalized.preferenceSignals.hookStrength = normalized.preferenceSignals.hookStrength || {};
  1521. normalized.preferenceSignals.ctaStyles = normalized.preferenceSignals.ctaStyles || {};
  1522. normalized.preferenceSignals.avoid = normalized.preferenceSignals.avoid || {};
  1523. normalized.preferenceSignals.add = normalized.preferenceSignals.add || {};
  1524. normalized.topicPreferences = normalized.topicPreferences || {};
  1525. normalized.topicPreferences.keepPhrases = normalized.topicPreferences.keepPhrases || {};
  1526. normalized.topicPreferences.dropPhrases = normalized.topicPreferences.dropPhrases || {};
  1527. return normalized;
  1528. }
  1529. function readScriptMemory(memoryPath) {
  1530. return normalizeScriptMemory(readJsonMaybe(memoryPath));
  1531. }
  1532. function topPreference(bucket) {
  1533. const entries = Object.entries(bucket || {}).filter(([, value]) => Number(value) > 0);
  1534. entries.sort((a, b) => Number(b[1]) - Number(a[1]));
  1535. return entries[0]?.[0] || '';
  1536. }
  1537. function preferenceFromScriptMemory(memory) {
  1538. const normalized = normalizeScriptMemory(memory);
  1539. const preference = {
  1540. raw: 'script-memory',
  1541. tone: topPreference(normalized.preferenceSignals.tones),
  1542. hookStrength: topPreference(normalized.preferenceSignals.hookStrength),
  1543. duration: '',
  1544. ctaStyle: topPreference(normalized.preferenceSignals.ctaStyles),
  1545. add: topPreference(normalized.preferenceSignals.add) ? [topPreference(normalized.preferenceSignals.add)] : [],
  1546. avoid: topPreference(normalized.preferenceSignals.avoid) ? [topPreference(normalized.preferenceSignals.avoid)] : [],
  1547. summary: []
  1548. };
  1549. if (preference.tone) preference.summary.push(`沿用历史偏好:${preference.tone}`);
  1550. if (preference.hookStrength) preference.summary.push('沿用历史偏好:开头冲突更强');
  1551. if (preference.ctaStyle) preference.summary.push(`沿用历史偏好:${preference.ctaStyle}`);
  1552. if (preference.add.length) preference.summary.push(`沿用历史偏好:增加${preference.add.join('、')}`);
  1553. if (preference.avoid.length) preference.summary.push(`沿用历史偏好:避免${preference.avoid.join('、')}`);
  1554. return preference;
  1555. }
  1556. function scriptMemorySummary(memory, memoryPath) {
  1557. const normalized = normalizeScriptMemory(memory);
  1558. const preference = preferenceFromScriptMemory(normalized);
  1559. const keepCount = Object.keys(normalized.topicPreferences.keepPhrases || {}).length;
  1560. const dropCount = Object.keys(normalized.topicPreferences.dropPhrases || {}).length;
  1561. return {
  1562. path: memoryPath || '',
  1563. sessionCount: normalized.sessions.length,
  1564. preference,
  1565. summary: [
  1566. ...preference.summary,
  1567. ...(keepCount ? [`保留方向 ${keepCount} 个`] : []),
  1568. ...(dropCount ? [`降权方向 ${dropCount} 个`] : [])
  1569. ],
  1570. topicPreferences: normalized.topicPreferences
  1571. };
  1572. }
  1573. function defaultHistoryMemoryPath(args) {
  1574. const configured = args.historyMemory || args['history-memory'];
  1575. return path.resolve(hasConcreteArg(configured) ? configured : path.join('memory', 'douyin-speaking-history.json'));
  1576. }
  1577. function normalizeHistoryMemory(memory) {
  1578. const normalized = memory && typeof memory === 'object' ? { ...memory } : {};
  1579. normalized.runs = asArray(normalized.runs);
  1580. return {
  1581. version: normalized.version || 1,
  1582. updatedAt: normalized.updatedAt || '',
  1583. runs: normalized.runs
  1584. };
  1585. }
  1586. function writeRunHistory({ historyPath, profile, report, outputRoot, selectedTopicIndex, scriptSession }) {
  1587. const resolved = path.resolve(historyPath);
  1588. ensureDir(path.dirname(resolved));
  1589. const history = normalizeHistoryMemory(readJsonMaybe(resolved));
  1590. const topIdeas = report.topicIdeas.slice(0, 8).map((idea, index) => ({
  1591. index: index + 1,
  1592. priority: idea.priority,
  1593. awemeId: idea.sourceVideo.id,
  1594. title: idea.title,
  1595. author: idea.sourceVideo.author,
  1596. confidence: idea.confidence.label,
  1597. hasTranscript: Boolean(idea.transcriptInsights),
  1598. evidence: idea.sourceVideo.evidence
  1599. }));
  1600. history.runs.push({
  1601. runId: `${slugify(profile.projectName)}_${Date.now()}`,
  1602. generatedAt: report.generatedAt,
  1603. projectName: profile.projectName,
  1604. outputDir: outputRoot,
  1605. summary: report.summary,
  1606. qualityGate: report.qualityGate,
  1607. oneLineJudgement: report.oneLineJudgement,
  1608. selectedTopicIndex: selectedTopicIndex || 0,
  1609. scriptSessionStatus: scriptSession?.status || '',
  1610. scriptVersion: latestScriptVersion(scriptSession || {}).version || '',
  1611. finalized: scriptSession?.status === 'finalized',
  1612. topIdeas
  1613. });
  1614. history.runs = history.runs.slice(-60);
  1615. history.updatedAt = new Date().toISOString();
  1616. fs.writeFileSync(resolved, JSON.stringify(history, null, 2), 'utf8');
  1617. return resolved;
  1618. }
  1619. function writeScriptMemory(session, memoryPath) {
  1620. const resolved = path.resolve(memoryPath);
  1621. ensureDir(path.dirname(resolved));
  1622. const memory = normalizeScriptMemory(readJsonMaybe(resolved) || {
  1623. version: 1,
  1624. updatedAt: '',
  1625. sessions: [],
  1626. preferenceSignals: {
  1627. tones: {},
  1628. hookStrength: {},
  1629. ctaStyles: {},
  1630. add: {},
  1631. avoid: {}
  1632. }
  1633. });
  1634. const latestFeedback = asArray(session.feedbackTurns).slice(-1)[0]?.parsedPreference || {};
  1635. const bump = (bucket, key) => {
  1636. if (!key) return;
  1637. bucket[key] = (bucket[key] || 0) + 1;
  1638. };
  1639. bump(memory.preferenceSignals.tones, latestFeedback.tone);
  1640. bump(memory.preferenceSignals.hookStrength, latestFeedback.hookStrength);
  1641. bump(memory.preferenceSignals.ctaStyles, latestFeedback.ctaStyle);
  1642. asArray(latestFeedback.add).forEach(item => bump(memory.preferenceSignals.add, item));
  1643. asArray(latestFeedback.avoid).forEach(item => bump(memory.preferenceSignals.avoid, item));
  1644. memory.sessions = asArray(memory.sessions);
  1645. memory.sessions.push({
  1646. sessionId: session.sessionId,
  1647. finalizedAt: new Date().toISOString(),
  1648. projectName: session.projectName,
  1649. selectedTopicIndex: session.selectedTopicIndex,
  1650. sourceAwemeId: session.sourceAwemeId,
  1651. topicTitle: session.topicTitle,
  1652. finalVersion: session.finalScript?.version || latestScriptVersion(session).version,
  1653. feedbackCount: asArray(session.feedbackTurns).length,
  1654. preference: latestFeedback
  1655. });
  1656. memory.sessions = memory.sessions.slice(-80);
  1657. memory.updatedAt = new Date().toISOString();
  1658. fs.writeFileSync(resolved, JSON.stringify(memory, null, 2), 'utf8');
  1659. return resolved;
  1660. }
  1661. function bumpPhrase(bucket, phrase, amount = 1) {
  1662. const key = cleanText(phrase);
  1663. if (!key || key.length < 2) return;
  1664. bucket[key] = (bucket[key] || 0) + amount;
  1665. }
  1666. function writeTopicPreferenceMemory({ report, command, memoryPath }) {
  1667. if (!command?.keepIndexes?.length && !command?.dropText) return null;
  1668. const resolved = path.resolve(memoryPath);
  1669. ensureDir(path.dirname(resolved));
  1670. const memory = normalizeScriptMemory(readJsonMaybe(resolved) || {});
  1671. const writes = { kept: [], dropped: [] };
  1672. command.keepIndexes.forEach(index => {
  1673. const idea = report.topicIdeas[index - 1];
  1674. if (!idea) return;
  1675. const phrases = [
  1676. idea.sourceVideo.keyword,
  1677. idea.sourceVideo.author,
  1678. truncate(idea.title.replace(/^把「|」改成你的账号口播$/g, ''), 24)
  1679. ].filter(Boolean);
  1680. phrases.forEach(phrase => bumpPhrase(memory.topicPreferences.keepPhrases, phrase, 1));
  1681. writes.kept.push({ index, title: idea.title, author: idea.sourceVideo.author });
  1682. });
  1683. if (command.dropText) {
  1684. bumpPhrase(memory.topicPreferences.dropPhrases, command.dropText, 1);
  1685. writes.dropped.push(command.dropText);
  1686. }
  1687. memory.updatedAt = new Date().toISOString();
  1688. fs.writeFileSync(resolved, JSON.stringify(memory, null, 2), 'utf8');
  1689. return { path: resolved, ...writes };
  1690. }
  1691. function renderTopicPreferenceReceipt(writeResult) {
  1692. if (!writeResult) return '';
  1693. const lines = ['# 偏好已记录', ''];
  1694. if (writeResult.kept.length) {
  1695. lines.push(`- 保留方向:${writeResult.kept.map(item => `选题${item.index}`).join('、')}`);
  1696. }
  1697. if (writeResult.dropped.length) {
  1698. lines.push(`- 下次降权/禁区:${writeResult.dropped.join('、')}`);
  1699. }
  1700. lines.push('- 下一次日报排序会参考这些偏好。');
  1701. lines.push('');
  1702. return lines.join('\n');
  1703. }
  1704. function buildScriptFinalQualityGate(session, script) {
  1705. const issues = [];
  1706. const warnings = [];
  1707. const profileText = normalizePhrase(Object.values(session.profileSnapshot || {}).join(' '));
  1708. const text = cleanText(script);
  1709. const lowered = normalizePhrase(text);
  1710. if (!text || text.length < 80) issues.push('稿件过短,不能作为正式交付稿。');
  1711. if (/voc\.market|voc 用户洞察|voc用户洞察|voc 洞察/i.test(text) && !/voc/.test(profileText)) {
  1712. issues.push('疑似泄漏测试项目或 VOC 专属话术。');
  1713. }
  1714. if (/B2B|b2b/.test(text) && !/b2b|企业|老板|决策|获客|咨询/.test(profileText)) {
  1715. warnings.push('出现 B2B 表达,但当前 profile 未明确 B2B 场景。');
  1716. }
  1717. if (!/举个|比如|场景|第一|第二|第三|案例/.test(text)) warnings.push('缺少具体场景或步骤,容易显得泛。');
  1718. if (!/评论区|告诉我|回复|私信|预约|咨询|下一步/.test(text)) warnings.push('缺少清晰互动或转化动作。');
  1719. const evidenceText = normalizePhrase([
  1720. profileText,
  1721. session.topicTitle,
  1722. session.topicKeyword,
  1723. session.topicAngle,
  1724. session.sourceEvidence?.evidence,
  1725. session.transcriptInsights?.opening,
  1726. session.transcriptInsights?.proof,
  1727. ...asArray(session.commentTriggers)
  1728. ].filter(Boolean).join(' '));
  1729. const hasSpecificBusinessClaim = /(真实|最近服务|服务过|我们服务|客户案例|案例中|这个客户|该客户|我们有个客户).{0,100}(投流|成本.{0,12}(降|下降)|转化.{0,12}(翻|提升)|\d+(?:万|%|%|倍))/.test(text);
  1730. const hasVerifiedCaseSource = /(真实客户案例|客户案例数据|已验证案例|案例库|verified case)/i.test(evidenceText);
  1731. if (hasSpecificBusinessClaim && !hasVerifiedCaseSource) {
  1732. issues.push('出现未证实的真实客户案例或精确经营数据,请改成“假设场景”或补充证据后再定稿。');
  1733. }
  1734. const genericHits = ['更容易被理解', '被收藏', '被咨询', '不要只喊口号'].filter(item => lowered.includes(normalizePhrase(item)));
  1735. if (genericHits.length >= 2) warnings.push('存在较多模板化表达,建议再换成账号自己的业务语言。');
  1736. return {
  1737. level: issues.length ? 'fail' : warnings.length ? 'warn' : 'pass',
  1738. issues,
  1739. warnings,
  1740. recommendation: issues.length
  1741. ? '先修改稿件再定稿,避免把低质量或错账号内容写入记忆。'
  1742. : warnings.length
  1743. ? '可以定稿,但建议下一轮继续补具体场景、口语化和转化动作。'
  1744. : '定稿质量通过,可沉淀到记忆。'
  1745. };
  1746. }
  1747. function finalizeScriptSession(session, memoryPath) {
  1748. if (session.status === 'blocked') {
  1749. session.updatedAt = new Date().toISOString();
  1750. return session;
  1751. }
  1752. const latest = latestScriptVersion(session);
  1753. const finalQualityGate = buildScriptFinalQualityGate(session, latest.script || '');
  1754. session.finalQualityGate = finalQualityGate;
  1755. if (finalQualityGate.level === 'fail') {
  1756. session.status = 'needs_revision';
  1757. session.updatedAt = new Date().toISOString();
  1758. return session;
  1759. }
  1760. session.status = 'finalized';
  1761. session.finalScript = {
  1762. version: latest.version,
  1763. title: latest.title,
  1764. script: latest.script,
  1765. cta: latest.cta,
  1766. finalizedAt: new Date().toISOString()
  1767. };
  1768. const memoryFile = writeScriptMemory(session, memoryPath);
  1769. session.memoryWrites = {
  1770. scriptMemoryPath: memoryFile,
  1771. preferenceSummary: asArray(session.feedbackTurns).slice(-1)[0]?.parsedPreference || {}
  1772. };
  1773. session.updatedAt = new Date().toISOString();
  1774. return session;
  1775. }
  1776. function firstSentence(text) {
  1777. const cleaned = cleanText(text);
  1778. const match = cleaned.match(/^(.+?[。!?!?])/);
  1779. return match ? match[1] : cleaned;
  1780. }
  1781. function compactScriptVersion(script, targetSec = 30) {
  1782. const paragraphs = scriptParagraphs(script);
  1783. if (!paragraphs.length) return '';
  1784. if (targetSec <= 30) {
  1785. const middle = paragraphs.find(item => /三步|第一|第二|第三|判断|证据|动作/.test(item)) || paragraphs[1] || '';
  1786. const ending = paragraphs.slice().reverse().find(item => /评论区|如果你|可以|告诉我|收/.test(item)) || paragraphs[paragraphs.length - 1];
  1787. return [firstSentence(paragraphs[0]), firstSentence(middle), firstSentence(ending)]
  1788. .filter(Boolean)
  1789. .join('\n\n');
  1790. }
  1791. if (targetSec <= 60) {
  1792. return paragraphs.slice(0, Math.min(4, paragraphs.length)).join('\n\n');
  1793. }
  1794. return paragraphs.join('\n\n');
  1795. }
  1796. function buildPublishingAids(session, script) {
  1797. const topic = scriptTopicText(session);
  1798. const ctx = scriptDeliveryContext(session.profileSnapshot || {}, topic);
  1799. return {
  1800. titles: [
  1801. `别再照抄爆款,先把「${topic}」讲成你的判断`,
  1802. `${ctx.audience}真正需要的不是热点,是下一步动作`,
  1803. `为什么你追了热点,内容还是没有结果`
  1804. ],
  1805. shootingTips: [
  1806. '开头 3 秒只讲一个判断,不解释背景。',
  1807. `中段用一个${ctx.proofSource}证明,不堆概念。`,
  1808. '结尾只留一个动作,让用户知道下一步怎么回复。'
  1809. ],
  1810. commentGuide: `你现在更卡在选题、证据,还是${ctx.goal}?`,
  1811. short30: compactScriptVersion(script, 30),
  1812. short60: compactScriptVersion(script, 60)
  1813. };
  1814. }
  1815. function renderScriptSession(session, { mdPath, sessionPath, finalized = false }) {
  1816. const latest = latestScriptVersion(session);
  1817. const lines = [];
  1818. if (finalized) {
  1819. lines.push('# 定稿稿件');
  1820. } else {
  1821. lines.push(`# 选题 ${session.selectedTopicIndex} 共创稿 ${latest.version || 'v0'}`);
  1822. }
  1823. lines.push('');
  1824. lines.push('## 共创状态');
  1825. lines.push('');
  1826. const statusLabel = session.status === 'finalized'
  1827. ? '已定稿'
  1828. : session.status === 'blocked'
  1829. ? '已阻断'
  1830. : session.status === 'needs_revision'
  1831. ? '待修改'
  1832. : '共创中';
  1833. lines.push(`- 状态:${statusLabel}`);
  1834. lines.push(`- 选题:${session.topicTitle}`);
  1835. lines.push(`- 来源:${session.sourceEvidence?.author || '未知作者'} / ${session.sourceEvidence?.evidence || ''}`);
  1836. lines.push(`- 置信度:${session.sourceEvidence?.confidence?.label || '未标注'}`);
  1837. if (session.qualityGate?.level) {
  1838. lines.push(`- 质量门禁:${session.qualityGate.level}(${session.qualityGate.recommendation || '已完成审计'})`);
  1839. }
  1840. if (session.transcriptInsights) {
  1841. lines.push(`- 逐字稿:已提供(${session.transcriptInsights.provider},${session.transcriptInsights.textLength} 字)`);
  1842. } else {
  1843. lines.push('- 逐字稿:未提供,当前稿件为结构推断稿');
  1844. }
  1845. if (asArray(session.feedbackTurns).length) {
  1846. const lastTurn = asArray(session.feedbackTurns).slice(-1)[0];
  1847. lines.push('');
  1848. lines.push('## 本轮理解');
  1849. lines.push('');
  1850. lines.push(`- 用户反馈:${lastTurn.userFeedback}`);
  1851. asArray(lastTurn.parsedPreference?.summary).forEach(item => lines.push(`- ${item}`));
  1852. }
  1853. if (!asArray(session.feedbackTurns).length && asArray(session.memorySnapshot?.summary).length) {
  1854. lines.push('');
  1855. lines.push('## 历史偏好');
  1856. lines.push('');
  1857. asArray(session.memorySnapshot.summary).forEach(item => lines.push(`- ${item}`));
  1858. }
  1859. lines.push('');
  1860. lines.push(finalized ? '## 最终口播稿' : '## 当前稿件');
  1861. lines.push('');
  1862. lines.push(`### ${latest.title || session.topicTitle}`);
  1863. lines.push('');
  1864. lines.push(latest.script || '');
  1865. if (latest.cta) {
  1866. lines.push('');
  1867. lines.push(`收口:${latest.cta}`);
  1868. }
  1869. if (latest.changeNotes?.length) {
  1870. lines.push('');
  1871. lines.push('## 改动说明');
  1872. lines.push('');
  1873. latest.changeNotes.forEach(item => lines.push(`- ${item}`));
  1874. }
  1875. if (session.finalQualityGate?.level) {
  1876. lines.push('');
  1877. lines.push('## 定稿自检');
  1878. lines.push('');
  1879. lines.push(`- 结果:${session.finalQualityGate.level}`);
  1880. session.finalQualityGate.issues?.forEach(item => lines.push(`- 必改:${item}`));
  1881. session.finalQualityGate.warnings?.forEach(item => lines.push(`- 建议:${item}`));
  1882. lines.push(`- 建议动作:${session.finalQualityGate.recommendation}`);
  1883. }
  1884. if (finalized) {
  1885. const aids = buildPublishingAids(session, latest.script || '');
  1886. lines.push('');
  1887. lines.push('## 发布辅助');
  1888. lines.push('');
  1889. aids.titles.forEach((title, index) => lines.push(`- 标题备选 ${index + 1}:${title}`));
  1890. aids.shootingTips.forEach(tip => lines.push(`- 拍摄提示:${tip}`));
  1891. lines.push(`- 评论区引导:${aids.commentGuide}`);
  1892. if (aids.short30) {
  1893. lines.push('');
  1894. lines.push('## 30秒压缩版');
  1895. lines.push('');
  1896. lines.push(aids.short30);
  1897. }
  1898. if (aids.short60 && aids.short60 !== aids.short30) {
  1899. lines.push('');
  1900. lines.push('## 60秒提词器版');
  1901. lines.push('');
  1902. lines.push(aids.short60);
  1903. }
  1904. lines.push('');
  1905. lines.push('## 已沉淀');
  1906. lines.push('');
  1907. lines.push(`- 共创轮次:${asArray(session.feedbackTurns).length}`);
  1908. if (session.memoryWrites?.scriptMemoryPath) lines.push(`- 脚本偏好记忆:${session.memoryWrites.scriptMemoryPath}`);
  1909. } else {
  1910. const topic = scriptTopicText(session);
  1911. lines.push('');
  1912. lines.push('## 下一轮你可以这样回');
  1913. lines.push('');
  1914. lines.push('- “开头更狠,老板视角”');
  1915. lines.push(`- “太泛了,加一个具体${topic}场景”`);
  1916. lines.push('- “压成 30 秒,并偏产品转化”');
  1917. lines.push('- “定稿”');
  1918. }
  1919. lines.push('');
  1920. lines.push('## 本地沉淀');
  1921. lines.push('');
  1922. if (sessionPath) lines.push(`- 共创会话:${sessionPath}`);
  1923. if (mdPath) lines.push(`- 日报备份:${mdPath}`);
  1924. return lines.join('\n');
  1925. }
  1926. function topicIdeasPreview(report) {
  1927. return report.topicIdeas.slice(0, 5).map((idea, index) => ({
  1928. index: index + 1,
  1929. priority: idea.priority,
  1930. title: idea.title,
  1931. author: idea.sourceVideo.author,
  1932. aweme_id: idea.sourceVideo.id,
  1933. evidence: idea.sourceVideo.evidence,
  1934. confidence: idea.confidence.label,
  1935. angle: idea.angle,
  1936. transcript: idea.transcriptInsights ? {
  1937. provider: idea.transcriptInsights.provider,
  1938. textLength: idea.transcriptInsights.textLength,
  1939. openingExcerpt: idea.transcriptInsights.openingExcerpt,
  1940. firstReusableTactic: idea.transcriptInsights.reusableTactics[0] || ''
  1941. } : undefined,
  1942. draftScript: idea.draftScript ? {
  1943. title: idea.draftScript.title,
  1944. theme: idea.draftScript.theme,
  1945. estimatedDurationSec: idea.draftScript.estimatedDurationSec,
  1946. fullText: idea.draftScript.fullText
  1947. } : undefined,
  1948. firstCommentTrigger: idea.commentTriggers[0] || ''
  1949. }));
  1950. }
  1951. function runReportWithArgs(args) {
  1952. const naturalCommand = parseNaturalCommand(args.message || args.userMessage || args['user-message'] || args.intent || args.command);
  1953. const profile = loadProfile(args);
  1954. const date = args.date || new Date().toISOString().slice(0, 10);
  1955. const outputRoot = path.resolve(args.output || path.join(
  1956. process.cwd(),
  1957. 'openclaw-voc-output',
  1958. 'douyin-speaking-daily',
  1959. slugify(profile.projectName),
  1960. date
  1961. ));
  1962. ensureDir(outputRoot);
  1963. const explicitInput = hasConcreteArg(args.input) ? args.input : '';
  1964. const autoInput = !explicitInput && naturalCommand.isContinuation
  1965. ? findLatestFileByName('runner-raw-input.json', outputSearchRoots())
  1966. : '';
  1967. const inputData = explicitInput || autoInput ? loadInput(explicitInput || autoInput) : {};
  1968. const normalized = normalizeData(inputData);
  1969. const scriptMemoryPathForRun = defaultScriptMemoryPath(args);
  1970. const scriptMemory = readScriptMemory(scriptMemoryPathForRun);
  1971. const report = buildReport(profile, normalized, {
  1972. scriptMemorySummary: scriptMemorySummary(scriptMemory, scriptMemoryPathForRun),
  1973. scriptMemory
  1974. });
  1975. const topicPreferenceWrite = writeTopicPreferenceMemory({
  1976. report,
  1977. command: naturalCommand,
  1978. memoryPath: scriptMemoryPathForRun
  1979. });
  1980. const rawDir = path.join(outputRoot, 'raw');
  1981. ensureDir(rawDir);
  1982. const jsonPath = path.join(outputRoot, 'daily-report.json');
  1983. const mdPath = path.join(outputRoot, 'daily-report.md');
  1984. const auditPath = path.join(outputRoot, 'audit.json');
  1985. const rawInputPath = path.join(rawDir, 'input.json');
  1986. const rawVideosPath = path.join(rawDir, 'keyword-videos.json');
  1987. const rawAccountVideosPath = path.join(rawDir, 'account-videos.json');
  1988. const rawCommentsPath = path.join(rawDir, 'comments.json');
  1989. const fullReportMarkdown = renderMarkdown(report);
  1990. const explicitScriptSessionInput = args.scriptSession || args['script-session'];
  1991. const outputScriptSessionPath = path.join(outputRoot, 'script-session.json');
  1992. const feedback = cleanText(args.feedback || args.userFeedback || args['user-feedback'] || naturalCommand.feedback);
  1993. const feedbackIsFinalize = /^(定稿|确认定稿|final|finalize)$/i.test(feedback);
  1994. const shouldFinalize = boolArg(args.finalize || args.final, false) || feedbackIsFinalize || naturalCommand.finalize;
  1995. const shouldReuseOutputSession = !hasConcreteArg(explicitScriptSessionInput)
  1996. && (feedback || shouldFinalize)
  1997. && fs.existsSync(outputScriptSessionPath);
  1998. const latestScriptSessionPath = !hasConcreteArg(explicitScriptSessionInput)
  1999. && (feedback || shouldFinalize)
  2000. && !shouldReuseOutputSession
  2001. && naturalCommand.isContinuation
  2002. ? findLatestFileByName('script-session.json', outputSearchRoots())
  2003. : '';
  2004. const scriptSessionInput = shouldReuseOutputSession ? outputScriptSessionPath : (explicitScriptSessionInput || latestScriptSessionPath);
  2005. const existingScriptSession = readJsonMaybe(scriptSessionInput);
  2006. const explicitSelectedTopicIndex = toIndex(args.selectedTopicIndex || args['selected-topic-index'] || args.topicIndex || args['topic-index'] || naturalCommand.selectedTopicIndex);
  2007. const selectedTopicIndex = explicitSelectedTopicIndex || toIndex(existingScriptSession?.selectedTopicIndex);
  2008. const shouldReuseExistingSession = Boolean(existingScriptSession?.sessionId)
  2009. && (!explicitSelectedTopicIndex || toIndex(existingScriptSession.selectedTopicIndex) === explicitSelectedTopicIndex);
  2010. const effectiveExistingScriptSession = shouldReuseExistingSession ? existingScriptSession : null;
  2011. const selectedIdea = selectedTopicIndex ? report.topicIdeas[selectedTopicIndex - 1] : undefined;
  2012. const scriptMode = !naturalCommand.preferenceOnly && Boolean(selectedTopicIndex || effectiveExistingScriptSession);
  2013. const scriptSessionPath = scriptMode
  2014. ? path.resolve(hasConcreteArg(explicitScriptSessionInput) && shouldReuseExistingSession ? explicitScriptSessionInput : outputScriptSessionPath)
  2015. : '';
  2016. let scriptSession = null;
  2017. if (scriptMode) {
  2018. scriptSession = makeScriptSession({
  2019. profile,
  2020. report,
  2021. idea: selectedIdea,
  2022. selectedTopicIndex,
  2023. existingSession: effectiveExistingScriptSession,
  2024. scriptMemory,
  2025. scriptMemoryPath: scriptMemoryPathForRun
  2026. });
  2027. if (feedback && !feedbackIsFinalize) {
  2028. scriptSession = applyFeedbackToSession(scriptSession, feedback);
  2029. }
  2030. if (shouldFinalize) {
  2031. scriptSession = finalizeScriptSession(scriptSession, scriptMemoryPathForRun);
  2032. }
  2033. }
  2034. const preferenceReceipt = renderTopicPreferenceReceipt(topicPreferenceWrite);
  2035. const chatMarkdownBody = scriptMode
  2036. ? renderScriptSession(scriptSession, { mdPath, sessionPath: scriptSessionPath, finalized: scriptSession.status === 'finalized' })
  2037. : naturalCommand.preferenceOnly
  2038. ? ''
  2039. : renderChatMarkdown(report, mdPath);
  2040. const chatMarkdown = preferenceReceipt
  2041. ? [preferenceReceipt, chatMarkdownBody].filter(Boolean).join('\n')
  2042. : chatMarkdownBody;
  2043. const selectedScriptPath = selectedTopicIndex ? path.join(outputRoot, `selected-topic-${selectedTopicIndex}-script.md`) : '';
  2044. const selectedScriptJsonPath = selectedTopicIndex ? path.join(outputRoot, `selected-topic-${selectedTopicIndex}-script.json`) : '';
  2045. fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8');
  2046. fs.writeFileSync(mdPath, fullReportMarkdown, 'utf8');
  2047. fs.writeFileSync(auditPath, JSON.stringify(buildAudit(report), null, 2), 'utf8');
  2048. fs.writeFileSync(rawInputPath, JSON.stringify(inputData, null, 2), 'utf8');
  2049. fs.writeFileSync(rawVideosPath, JSON.stringify(normalized.videos, null, 2), 'utf8');
  2050. fs.writeFileSync(rawAccountVideosPath, JSON.stringify(asArray(inputData.accountVideos), null, 2), 'utf8');
  2051. fs.writeFileSync(rawCommentsPath, JSON.stringify(normalized.comments, null, 2), 'utf8');
  2052. if (selectedTopicIndex) {
  2053. fs.writeFileSync(selectedScriptPath, chatMarkdown, 'utf8');
  2054. if (scriptSessionPath) {
  2055. ensureDir(path.dirname(scriptSessionPath));
  2056. fs.writeFileSync(scriptSessionPath, JSON.stringify(scriptSession, null, 2), 'utf8');
  2057. }
  2058. fs.writeFileSync(selectedScriptJsonPath, JSON.stringify({
  2059. selectedTopicIndex,
  2060. topic: selectedIdea || null,
  2061. scriptSession,
  2062. markdown: chatMarkdown
  2063. }, null, 2), 'utf8');
  2064. }
  2065. const shouldWriteHistory = boolArg(args.writeHistory || args['write-history'], true);
  2066. const historyMemoryPath = shouldWriteHistory ? writeRunHistory({
  2067. historyPath: defaultHistoryMemoryPath(args),
  2068. profile,
  2069. report,
  2070. outputRoot,
  2071. selectedTopicIndex,
  2072. scriptSession
  2073. }) : '';
  2074. const result = {
  2075. status: report.status,
  2076. assistantMessage: chatMarkdown,
  2077. markdown: chatMarkdown,
  2078. outputDir: outputRoot,
  2079. files: [
  2080. mdPath,
  2081. jsonPath,
  2082. auditPath,
  2083. rawInputPath,
  2084. rawVideosPath,
  2085. rawAccountVideosPath,
  2086. rawCommentsPath,
  2087. ...(selectedTopicIndex ? [selectedScriptPath, selectedScriptJsonPath] : []),
  2088. ...(scriptSessionPath ? [scriptSessionPath] : []),
  2089. ...(scriptSession?.memoryWrites?.scriptMemoryPath ? [scriptSession.memoryWrites.scriptMemoryPath] : []),
  2090. ...(historyMemoryPath ? [historyMemoryPath] : [])
  2091. ],
  2092. summary: report.summary,
  2093. qualityGate: report.qualityGate,
  2094. scriptMemorySignals: report.metadata?.scriptMemory || {},
  2095. oneLineJudgement: report.oneLineJudgement,
  2096. topicIdeaCount: report.topicIdeas.length,
  2097. precisionLevel: report.precisionCheck.confidenceSummary,
  2098. calibrationPrompt: '请直接回复:保留的选题序号、要降权/禁区的方向、下一版口播视角。',
  2099. topicIdeasPreview: topicIdeasPreview(report),
  2100. selectedTopicIndex,
  2101. selectedTopic: selectedIdea ? topicIdeasPreview({ ...report, topicIdeas: [selectedIdea] })[0] : undefined,
  2102. selectedScriptPath,
  2103. scriptSessionPath,
  2104. scriptSessionStatus: scriptSession?.status || '',
  2105. scriptVersion: latestScriptVersion(scriptSession || {}).version || '',
  2106. scriptMemoryPath: scriptSession?.memoryWrites?.scriptMemoryPath || '',
  2107. historyMemoryPath,
  2108. naturalCommand,
  2109. topicPreferenceMemoryPath: topicPreferenceWrite?.path || '',
  2110. autoResolvedInputPath: autoInput,
  2111. autoResolvedScriptSessionPath: latestScriptSessionPath,
  2112. calibrationQuestions: report.calibrationQuestions,
  2113. fullReportPath: mdPath,
  2114. warningCount: report.warnings.length
  2115. };
  2116. return result;
  2117. }
  2118. function main() {
  2119. const args = parseArgs(process.argv.slice(2));
  2120. if (args.help) {
  2121. console.log(usage());
  2122. return;
  2123. }
  2124. console.log(JSON.stringify(runReportWithArgs(args), null, 2));
  2125. }
  2126. if (require.main === module) {
  2127. try {
  2128. main();
  2129. } catch (error) {
  2130. console.error(error.message);
  2131. process.exit(1);
  2132. }
  2133. }
  2134. module.exports = {
  2135. loadProfile,
  2136. loadInput,
  2137. normalizeData,
  2138. buildReport,
  2139. renderMarkdown,
  2140. renderChatMarkdown,
  2141. renderSelectedTopicDraft,
  2142. makeScriptSession,
  2143. applyFeedbackToSession,
  2144. finalizeScriptSession,
  2145. renderScriptSession,
  2146. buildAudit,
  2147. runReportWithArgs
  2148. };