douyin-speaking-daily-runner.js 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const os = require('os');
  5. const { spawnSync } = require('child_process');
  6. const API_ROOT = 'https://server.fmode.cn/api/voc-social';
  7. function parseArgs(argv) {
  8. const args = {};
  9. for (let i = 0; i < argv.length; i++) {
  10. const token = argv[i];
  11. if (!token.startsWith('--')) continue;
  12. const eq = token.indexOf('=');
  13. if (eq >= 0) {
  14. args[token.slice(2, eq)] = token.slice(eq + 1);
  15. } else {
  16. const key = token.slice(2);
  17. const next = argv[i + 1];
  18. if (next && !next.startsWith('--')) {
  19. args[key] = next;
  20. i++;
  21. } else {
  22. args[key] = true;
  23. }
  24. }
  25. }
  26. return args;
  27. }
  28. function usage() {
  29. return [
  30. 'Usage:',
  31. ' node scripts/tools/douyin-speaking-daily-runner.js --profile <profile.json> --output <out-dir>',
  32. ' node scripts/tools/douyin-speaking-daily-runner.js --project <name> --industry <name> --keywords "kw1,kw2"',
  33. '',
  34. 'Requires VOC_TOKEN env var or ~/.openclaw/voc-credentials.json with { "vocToken": "..." } for live fetch.'
  35. ].join('\n');
  36. }
  37. function emitResult(args, result) {
  38. console.log(JSON.stringify(result, null, 2));
  39. if (args.resultPrefix || args['result-prefix']) {
  40. const prefix = args.resultPrefix || args['result-prefix'];
  41. console.log(`${prefix}=${JSON.stringify(result)}`);
  42. }
  43. }
  44. function bool(value, fallback = false) {
  45. if (value === undefined || value === null || value === '') return fallback;
  46. if (typeof value === 'boolean') return value;
  47. return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
  48. }
  49. function intValue(value, fallback) {
  50. const number = Number(value);
  51. return Number.isFinite(number) ? Math.max(0, Math.round(number)) : fallback;
  52. }
  53. function asArray(value) {
  54. if (!value) return [];
  55. return Array.isArray(value) ? value : [value];
  56. }
  57. function splitList(value) {
  58. if (!value) return [];
  59. if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
  60. const text = String(value).trim();
  61. if (!text || /^\{\{[^}]+\}\}$/.test(text)) return [];
  62. if (text.startsWith('[')) {
  63. try {
  64. const parsed = JSON.parse(text);
  65. return Array.isArray(parsed) ? parsed.map(item => typeof item === 'string' ? item : JSON.stringify(item)) : [];
  66. } catch {
  67. return [];
  68. }
  69. }
  70. return text.split(/[,,;;|、\n]/).map(item => item.trim()).filter(Boolean);
  71. }
  72. function uniqueStrings(values) {
  73. return [...new Set(values.map(value => cleanText(value)).filter(Boolean))];
  74. }
  75. function cleanText(value) {
  76. return String(value || '').replace(/\s+/g, ' ').trim();
  77. }
  78. function slugify(value) {
  79. return String(value || 'douyin-speaking-daily')
  80. .trim()
  81. .replace(/[\\/:*?"<>|\s]+/g, '-')
  82. .replace(/-+/g, '-')
  83. .replace(/^-|-$/g, '') || 'douyin-speaking-daily';
  84. }
  85. function ensureDir(dirPath) {
  86. fs.mkdirSync(dirPath, { recursive: true });
  87. }
  88. function readJson(filePath) {
  89. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  90. }
  91. function readJsonMaybe(value) {
  92. if (!value) return undefined;
  93. const text = String(value).trim();
  94. if (!text || /^\{\{[^}]+\}$/.test(text)) return undefined;
  95. const absolute = path.resolve(text);
  96. if (fs.existsSync(absolute)) return readJson(absolute);
  97. try {
  98. return JSON.parse(text);
  99. } catch {
  100. return undefined;
  101. }
  102. }
  103. function hasConcreteArg(value) {
  104. if (value === undefined || value === null) return false;
  105. const text = String(value).trim();
  106. return Boolean(text) && !/^\{\{[^}]+\}$/.test(text);
  107. }
  108. function firstConcreteArg(...values) {
  109. return values.find(value => hasConcreteArg(value));
  110. }
  111. function outputSearchRoots() {
  112. return [
  113. path.join(process.cwd(), 'outputs'),
  114. path.join(process.cwd(), 'openclaw-voc-output'),
  115. path.join(process.cwd(), 'memory')
  116. ];
  117. }
  118. function findLatestFileByName(fileName, roots = outputSearchRoots()) {
  119. const queue = roots.map(root => path.resolve(root)).filter(root => fs.existsSync(root));
  120. let latest = null;
  121. while (queue.length) {
  122. const dir = queue.shift();
  123. let entries = [];
  124. try {
  125. entries = fs.readdirSync(dir, { withFileTypes: true });
  126. } catch {
  127. continue;
  128. }
  129. for (const entry of entries) {
  130. const full = path.join(dir, entry.name);
  131. if (entry.isDirectory()) {
  132. if (!['node_modules', '.git', 'dist'].includes(entry.name)) queue.push(full);
  133. } else if (entry.isFile() && entry.name === fileName) {
  134. const stat = fs.statSync(full);
  135. if (!latest || stat.mtimeMs > latest.mtimeMs) latest = { path: full, mtimeMs: stat.mtimeMs };
  136. }
  137. }
  138. }
  139. return latest?.path || '';
  140. }
  141. function naturalCommandText(args) {
  142. return cleanText(args.message || args.userMessage || args['user-message'] || args.intent || args.command);
  143. }
  144. function isContinuationMessage(text) {
  145. return /选题\s*\d+|第\s*\d+\s*条|定稿|改稿|调整|开头|老板|专家|案例|场景|具体|太泛|压成|保留\s*\d|不要|降权|禁区|转写|逐字稿/i.test(text || '');
  146. }
  147. function loadProfile(args) {
  148. const fileProfile = hasConcreteArg(args.profile) ? readJson(path.resolve(args.profile)) : {};
  149. const inline = {
  150. projectName: args.project || args.projectName || args['project-name'],
  151. industry: args.industry,
  152. accountPositioning: args.accountPositioning || args['account-positioning'] || args.positioning,
  153. targetAudience: args.targetAudience || args['target-audience'] || args.audience,
  154. conversionGoal: args.conversionGoal || args['conversion-goal'] || args.goal,
  155. coreOffer: args.coreOffer || args['core-offer'] || args.offer,
  156. contentPillars: splitList(args.contentPillars || args['content-pillars'] || args.pillars),
  157. keywords: splitList(args.keywords),
  158. referenceAccounts: splitList(args.referenceAccounts || args['reference-accounts'] || args.accounts),
  159. accountDiscoveryDirection: args.accountDiscoveryDirection || args['account-discovery-direction'],
  160. forbiddenTopics: splitList(args.forbiddenTopics || args['forbidden-topics'] || args.forbidden),
  161. tone: args.tone,
  162. dailyOutputCount: intValue(args.dailyOutputCount || args['daily-output-count'] || args.count, undefined)
  163. };
  164. const confirmed = readJsonMaybe(args.confirmedAccounts || args['confirmed-accounts']);
  165. if (confirmed) inline.confirmedAccounts = Array.isArray(confirmed) ? confirmed : [confirmed];
  166. const profile = { ...fileProfile };
  167. Object.entries(inline).forEach(([key, value]) => {
  168. if (Array.isArray(value)) {
  169. if (value.length) profile[key] = value;
  170. } else if (value !== undefined && value !== null && value !== '') {
  171. profile[key] = value;
  172. }
  173. });
  174. profile.projectName = profile.projectName || 'douyin-speaking-daily';
  175. profile.keywords = splitList(profile.keywords);
  176. profile.referenceAccounts = splitList(profile.referenceAccounts);
  177. profile.contentPillars = splitList(profile.contentPillars);
  178. profile.forbiddenTopics = splitList(profile.forbiddenTopics);
  179. profile.confirmedAccounts = asArray(profile.confirmedAccounts);
  180. profile.dailyOutputCount = intValue(profile.dailyOutputCount, 8) || 8;
  181. return profile;
  182. }
  183. function loadVocToken() {
  184. const envToken = process.env.VOC_TOKEN || process.env.OPENCLAW_VOC_TOKEN || process.env.VOC_SOCIAL_TOKEN;
  185. if (envToken) return envToken;
  186. const credentialsPath = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  187. if (fs.existsSync(credentialsPath)) {
  188. const data = readJson(credentialsPath);
  189. if (data.vocToken) return data.vocToken;
  190. if (data.token) return data.token;
  191. }
  192. return '';
  193. }
  194. async function requestJson({ method = 'GET', pathUrl, body, token, query }) {
  195. const url = new URL(`${API_ROOT}${pathUrl}`);
  196. Object.entries(query || {}).forEach(([key, value]) => {
  197. if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
  198. });
  199. const response = await fetch(url.toString(), {
  200. method,
  201. headers: {
  202. Authorization: `Bearer ${token}`,
  203. Accept: 'application/json',
  204. ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
  205. },
  206. body: method === 'POST' ? JSON.stringify(body || {}) : undefined
  207. });
  208. const text = await response.text();
  209. let data;
  210. try {
  211. data = JSON.parse(text);
  212. } catch {
  213. data = { rawText: text };
  214. }
  215. if (!response.ok) {
  216. const error = new Error(data.message || data.message_zh || data.mess || data.descInfo || `HTTP ${response.status}`);
  217. error.data = data;
  218. error.httpStatus = response.status;
  219. error.responseCode = data.code;
  220. throw error;
  221. }
  222. return data;
  223. }
  224. function apiErrorRecord(base, error) {
  225. return {
  226. ...base,
  227. message: error.message,
  228. ...(error.httpStatus ? { httpStatus: error.httpStatus } : {}),
  229. ...(error.responseCode !== undefined ? { code: error.responseCode } : {})
  230. };
  231. }
  232. function isGatewayAuthError(error) {
  233. const message = String(error.message || '');
  234. return error.httpStatus === 403
  235. || /没有开通社交平台API权限|社交平台API权限|余额不足|not authorized|permission/i.test(message);
  236. }
  237. function toNumber(value) {
  238. const number = Number(value || 0);
  239. return Number.isFinite(number) ? number : 0;
  240. }
  241. function videoScore(video) {
  242. return toNumber(video.statistics?.digg_count || video.digg_count || video.likeCount)
  243. + toNumber(video.statistics?.comment_count || video.comment_count || video.commentCount) * 4
  244. + toNumber(video.statistics?.share_count || video.share_count || video.shareCount) * 6
  245. + toNumber(video.statistics?.play_count || video.play_count || video.playCount) * 0.003;
  246. }
  247. function normalizeAweme(aweme, extra = {}) {
  248. const statistics = aweme.statistics || {};
  249. const author = aweme.author || {};
  250. return {
  251. aweme_id: cleanText(aweme.aweme_id || aweme.awemeId || aweme.id),
  252. keyword: extra.keyword || aweme.keyword || '',
  253. sourceType: extra.sourceType || aweme.sourceType || 'video',
  254. accountSource: extra.accountSource || '',
  255. desc: cleanText(aweme.desc || aweme.title || aweme.content),
  256. title: cleanText(aweme.desc || aweme.title || aweme.content),
  257. author: {
  258. uid: author.uid || author.user_id || '',
  259. sec_uid: author.sec_uid || author.sec_user_id || extra.sec_user_id || '',
  260. nickname: author.nickname || author.nick_name || extra.nickname || ''
  261. },
  262. statistics: {
  263. digg_count: toNumber(statistics.digg_count ?? aweme.digg_count ?? aweme.likeCount),
  264. comment_count: toNumber(statistics.comment_count ?? aweme.comment_count ?? aweme.commentCount),
  265. share_count: toNumber(statistics.share_count ?? aweme.share_count ?? aweme.shareCount),
  266. play_count: toNumber(statistics.play_count ?? aweme.play_count ?? aweme.playCount)
  267. },
  268. create_time: aweme.create_time || aweme.publishTime || '',
  269. share_url: aweme.share_url || aweme.url || '',
  270. cha_list: asArray(aweme.cha_list),
  271. raw: aweme
  272. };
  273. }
  274. function extractSearchVideos(response, keyword) {
  275. const cards = asArray(response?.data?.business_data)
  276. .concat(asArray(response?.data?.data?.business_data))
  277. .concat(asArray(response?.business_data));
  278. const videos = [];
  279. cards.forEach(card => {
  280. const aweme = card?.data?.aweme_info || card?.aweme_info || card?.data?.aweme_detail;
  281. if (aweme?.aweme_id) videos.push(normalizeAweme(aweme, { keyword, sourceType: 'keyword' }));
  282. });
  283. asArray(response?.data?.aweme_list).forEach(aweme => {
  284. if (aweme?.aweme_id) videos.push(normalizeAweme(aweme, { keyword, sourceType: 'keyword' }));
  285. });
  286. return dedupeVideos(videos);
  287. }
  288. function dedupeVideos(videos) {
  289. const map = new Map();
  290. videos.filter(video => video.aweme_id).forEach(video => {
  291. const existing = map.get(video.aweme_id);
  292. if (!existing || videoScore(video) > videoScore(existing)) map.set(video.aweme_id, video);
  293. });
  294. return [...map.values()];
  295. }
  296. function extractAccountCandidates(response, source) {
  297. const buckets = asArray(response?.data?.data)
  298. .concat(asArray(response?.data?.user_list))
  299. .concat(asArray(response?.user_list));
  300. const list = [];
  301. buckets.forEach(bucket => {
  302. asArray(bucket?.user_list).forEach(item => list.push(item));
  303. if (bucket?.user_info || bucket?.user_id || bucket?.sec_uid) list.push(bucket);
  304. });
  305. return list.map((item, index) => {
  306. const info = item.user_info || item;
  307. const sec = info.sec_uid || info.sec_user_id || info.user_id || item.user_id || item.sec_uid || '';
  308. return {
  309. id: sec || `candidate_${source}_${index}`,
  310. sec_user_id: sec,
  311. nickname: info.nickname || info.nick_name || item.nick_name || '',
  312. unique_id: info.unique_id || '',
  313. follower_count: toNumber(info.follower_count ?? info.fans_cnt ?? item.fans_cnt),
  314. like_count: toNumber(info.total_favorited ?? info.like_cnt ?? item.like_cnt),
  315. aweme_count: toNumber(info.aweme_count ?? info.publish_cnt ?? item.publish_cnt),
  316. signature: info.signature || item.signature || '',
  317. source
  318. };
  319. }).filter(item => item.sec_user_id || item.nickname);
  320. }
  321. function accountCandidateScore(candidate) {
  322. const evidence = candidate.evidence || {};
  323. const evidenceBoost = evidence.aweme_id || candidate.match_reason === 'general_search_video_author' ? 1000000 : 0;
  324. return toNumber(candidate.score)
  325. + evidenceBoost
  326. + toNumber(evidence.interaction_score)
  327. + toNumber(candidate.follower_count) * 0.02
  328. + toNumber(candidate.like_count) * 0.002
  329. + toNumber(candidate.aweme_count) * 2;
  330. }
  331. function dedupeAccountCandidates(candidates) {
  332. const map = new Map();
  333. asArray(candidates).filter(Boolean).forEach((candidate, index) => {
  334. const sec = cleanText(candidate.sec_user_id || candidate.sec_uid || candidate.user_id || candidate.id);
  335. const nickname = cleanText(candidate.nickname || candidate.nick_name || candidate.name);
  336. const key = sec || nickname || `candidate_${index}`;
  337. if (!key) return;
  338. const normalized = {
  339. ...candidate,
  340. sec_user_id: sec,
  341. nickname,
  342. source: candidate.source || candidate.sourceInput || ''
  343. };
  344. const existing = map.get(key);
  345. if (!existing || accountCandidateScore(normalized) > accountCandidateScore(existing)) {
  346. map.set(key, normalized);
  347. }
  348. });
  349. return [...map.values()].sort((a, b) => accountCandidateScore(b) - accountCandidateScore(a));
  350. }
  351. function accountCandidateFromVideo(video, source) {
  352. const rawAuthor = video?.raw?.author || {};
  353. const author = {
  354. ...rawAuthor,
  355. ...(video?.author || {})
  356. };
  357. const sec = cleanText(author.sec_uid || author.sec_user_id || author.user_id || author.uid);
  358. const nickname = cleanText(author.nickname || author.nick_name);
  359. if (!sec && !nickname) return undefined;
  360. const evidenceScore = videoScore(video);
  361. return {
  362. id: sec || `${source}_${nickname}`,
  363. sec_user_id: sec,
  364. nickname,
  365. unique_id: cleanText(author.unique_id || author.short_id),
  366. follower_count: toNumber(author.follower_count ?? author.fans_cnt),
  367. like_count: toNumber(author.total_favorited ?? author.like_count ?? author.like_cnt),
  368. aweme_count: toNumber(author.aweme_count ?? author.publish_cnt),
  369. signature: cleanText(author.signature),
  370. source,
  371. match_reason: 'general_search_video_author',
  372. evidence: {
  373. keyword: cleanText(video.keyword || source),
  374. aweme_id: cleanText(video.aweme_id),
  375. desc: cleanText(video.desc || video.title).slice(0, 180),
  376. interaction_score: evidenceScore,
  377. statistics: video.statistics || {}
  378. },
  379. score: evidenceScore
  380. };
  381. }
  382. function extractAuthorAccountCandidates(videos, source) {
  383. return dedupeAccountCandidates(
  384. asArray(videos).map(video => accountCandidateFromVideo(video, source)).filter(Boolean)
  385. );
  386. }
  387. function compactDiscoveryTerm(value) {
  388. const text = cleanText(value);
  389. if (!text) return '';
  390. const head = cleanText(text.split(/[:\uFF1A]/)[0]);
  391. return (head || text).replace(/类账号|账号|方向/g, '').trim();
  392. }
  393. function discoveryTerms(profile) {
  394. return uniqueStrings([
  395. ...splitList(profile.accountDiscoveryDirection),
  396. ...splitList(profile.industry),
  397. ...splitList(profile.keywords)
  398. ].map(compactDiscoveryTerm).filter(Boolean));
  399. }
  400. async function discoverAccountsFromGeneralSearch({ token, profile, seedVideos, options, warnings, errors }) {
  401. const candidates = extractAuthorAccountCandidates(seedVideos, 'keyword_general_search');
  402. const searched = new Set(asArray(seedVideos).map(video => cleanText(video.keyword)).filter(Boolean));
  403. const terms = discoveryTerms(profile).filter(term => !searched.has(term)).slice(0, options.maxKeywords);
  404. for (const term of terms) {
  405. try {
  406. const response = await requestJson({
  407. method: 'POST',
  408. pathUrl: '/douyin/search/fetch_general_search_v2',
  409. token,
  410. body: {
  411. keyword: term,
  412. cursor: 0,
  413. sort_type: '1',
  414. publish_time: '180',
  415. filter_duration: '0',
  416. content_type: '1',
  417. search_id: '',
  418. backtrace: ''
  419. }
  420. });
  421. const videos = extractSearchVideos(response, term)
  422. .sort((a, b) => videoScore(b) - videoScore(a))
  423. .slice(0, Math.max(options.videosPerKeyword, 10));
  424. candidates.push(...extractAuthorAccountCandidates(videos, `direction_general_search:${term}`));
  425. } catch (error) {
  426. errors.push(apiErrorRecord({ stage: 'account_discovery_general_search', keyword: term }, error));
  427. }
  428. }
  429. const deduped = dedupeAccountCandidates(candidates).slice(0, Math.max(options.accountsLimit * 3, 10));
  430. if (deduped.length) {
  431. warnings.push(`账号方向发现:已从综合搜索视频作者中提取 ${deduped.length} 个候选账号,需用户确认后再进入监听池。`);
  432. }
  433. return deduped;
  434. }
  435. function dedupeAccounts(accounts) {
  436. const map = new Map();
  437. asArray(accounts).forEach((account, index) => {
  438. const sec = parseSecUserId(account);
  439. const raw = typeof account === 'object' && account ? account : {};
  440. const nickname = cleanText(raw.nickname || raw.nick_name || raw.name || (typeof account === 'string' ? account : ''));
  441. const key = sec || raw.user_id || raw.sec_uid || raw.id || nickname || `account_${index}`;
  442. if (!key || map.has(key)) return;
  443. map.set(key, typeof account === 'object' ? account : { sec_user_id: sec, nickname });
  444. });
  445. return [...map.values()];
  446. }
  447. function summarizeAccountCandidate(candidate) {
  448. const evidence = candidate.evidence || {};
  449. return {
  450. sec_user_id: candidate.sec_user_id || candidate.sec_uid || candidate.user_id || candidate.id || '',
  451. nickname: candidate.nickname || candidate.nick_name || '',
  452. follower_count: toNumber(candidate.follower_count ?? candidate.fans_cnt),
  453. like_count: toNumber(candidate.like_count ?? candidate.like_cnt),
  454. aweme_count: toNumber(candidate.aweme_count ?? candidate.publish_cnt),
  455. signature: cleanText(candidate.signature),
  456. source: candidate.source || candidate.sourceInput || '',
  457. match_reason: candidate.match_reason || '',
  458. evidence_keyword: evidence.keyword || '',
  459. evidence_aweme_id: evidence.aweme_id || '',
  460. evidence_desc: evidence.desc || ''
  461. };
  462. }
  463. function requiredProfileFields(profile) {
  464. return [
  465. { field: 'industry', ok: Boolean(cleanText(profile.industry)) },
  466. { field: 'accountPositioning', ok: Boolean(cleanText(profile.accountPositioning)) },
  467. { field: 'targetAudience', ok: Boolean(cleanText(profile.targetAudience)) },
  468. { field: 'conversionGoal', ok: Boolean(cleanText(profile.conversionGoal)) },
  469. { field: 'keywords', ok: asArray(profile.keywords).length > 0 }
  470. ];
  471. }
  472. function profileComplete(profile) {
  473. return requiredProfileFields(profile).every(item => item.ok);
  474. }
  475. function readyForDailyReport(profile) {
  476. return profileComplete(profile)
  477. && (
  478. asArray(profile.confirmedAccounts).length > 0
  479. || asArray(profile.referenceAccounts).length > 0
  480. || Boolean(cleanText(profile.accountDiscoveryDirection))
  481. );
  482. }
  483. function stateAfterRun(status, profile) {
  484. if (status === 'needs_account_confirmation') return 'awaitingAccountConfirmation';
  485. if (status === 'ok') return 'awaitingReportCalibration';
  486. if (readyForDailyReport(profile)) return 'readyForManualRun';
  487. return 'collectingProfile';
  488. }
  489. function buildProfileAfterRun({ profile, rawInput, result, reportResult, outputDir }) {
  490. const currentCandidates = asArray(rawInput.accountCandidates).map(summarizeAccountCandidate).filter(item => item.sec_user_id || item.nickname);
  491. const updated = {
  492. ...profile,
  493. platform: profile.platform || 'douyin',
  494. candidateAccounts: currentCandidates.length ? currentCandidates : asArray(profile.candidateAccounts),
  495. confirmedAccounts: dedupeAccounts(profile.confirmedAccounts),
  496. lastReportAt: reportResult.generatedAt || new Date().toISOString(),
  497. lastOutputDir: outputDir,
  498. lastRunStatus: result.status,
  499. lastRunSummary: result.summary,
  500. updatedAt: new Date().toISOString()
  501. };
  502. updated.state = stateAfterRun(result.status, updated);
  503. updated.readyForDailyReport = readyForDailyReport(updated);
  504. updated.automationReady = Boolean(updated.automationReady);
  505. return updated;
  506. }
  507. function maybeWriteProfileAfterRun({ args, profile, rawInput, result, reportResult, outputDir }) {
  508. if (!bool(args.writeProfile || args['write-profile'], false)) return undefined;
  509. const target = path.resolve(
  510. hasConcreteArg(args.profileOutput || args['profile-output'])
  511. ? (args.profileOutput || args['profile-output'])
  512. : hasConcreteArg(args.profile)
  513. ? args.profile
  514. : path.join('memory', 'douyin-speaking-profile.json')
  515. );
  516. const updated = buildProfileAfterRun({ profile, rawInput, result, reportResult, outputDir });
  517. ensureDir(path.dirname(target));
  518. fs.writeFileSync(target, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
  519. return { path: target, profile: updated };
  520. }
  521. function renderAccountCandidatesMarkdown(candidates) {
  522. const summarized = asArray(candidates).map(summarizeAccountCandidate).filter(item => item.sec_user_id || item.nickname);
  523. if (!summarized.length) return '';
  524. const lines = [
  525. '# 抖音口播账号候选确认',
  526. '',
  527. '已根据账号昵称或发现方向找到候选账号。P0 规则要求先由用户确认监听池,暂不把候选账号直接当作近期作品监听结果。',
  528. '',
  529. '## 候选账号'
  530. ];
  531. summarized.slice(0, 10).forEach((item, index) => {
  532. lines.push('');
  533. lines.push(`### ${index + 1}. ${item.nickname || item.sec_user_id || '未命名账号'}`);
  534. lines.push(`- sec_user_id:${item.sec_user_id || '待确认'}`);
  535. lines.push(`- 粉丝:${item.follower_count || 0}`);
  536. lines.push(`- 作品:${item.aweme_count || 0}`);
  537. if (item.signature) lines.push(`- 简介:${item.signature}`);
  538. if (item.source) lines.push(`- 来源:${item.source}`);
  539. if (item.evidence_desc) lines.push(`- 匹配样本:${item.evidence_desc}`);
  540. });
  541. lines.push('');
  542. lines.push('## 需要你确认');
  543. lines.push('');
  544. lines.push('请回复要纳入监听的账号序号,或直接提供确认后的 `sec_user_id`。确认后再运行 `douyin-speaking-daily-runner`,并把确认账号写入 `confirmedAccounts`。');
  545. return lines.join('\n');
  546. }
  547. function parseSecUserId(value) {
  548. if (!value) return '';
  549. const text = typeof value === 'string' ? value.trim() : String(value.sec_user_id || value.sec_uid || value.user_id || value.id || '').trim();
  550. if (!text) return '';
  551. const urlMatch = text.match(/\/user\/([^/?#\s]+)/);
  552. if (urlMatch) return urlMatch[1];
  553. if (/MS4w|MS4x|MS4z/.test(text) || text.length > 40) return text;
  554. return '';
  555. }
  556. async function fetchKeywordVideos({ token, keywords, options, warnings, errors }) {
  557. const videos = [];
  558. for (const keyword of keywords.slice(0, options.maxKeywords)) {
  559. try {
  560. const response = await requestJson({
  561. method: 'POST',
  562. pathUrl: '/douyin/search/fetch_general_search_v2',
  563. token,
  564. body: {
  565. keyword,
  566. cursor: 0,
  567. sort_type: String(options.sortType),
  568. publish_time: String(options.publishTime),
  569. filter_duration: String(options.filterDuration),
  570. content_type: String(options.contentType),
  571. search_id: '',
  572. backtrace: ''
  573. }
  574. });
  575. const extracted = extractSearchVideos(response, keyword)
  576. .sort((a, b) => videoScore(b) - videoScore(a))
  577. .slice(0, options.videosPerKeyword);
  578. if (!extracted.length) warnings.push(`关键词「${keyword}」未解析到视频样本。`);
  579. videos.push(...extracted);
  580. } catch (error) {
  581. errors.push(apiErrorRecord({ stage: 'keyword_search', keyword }, error));
  582. }
  583. }
  584. return dedupeVideos(videos);
  585. }
  586. async function searchAccounts({ token, query, warnings, errors }) {
  587. try {
  588. const response = await requestJson({
  589. method: 'POST',
  590. pathUrl: '/douyin/search/fetch_user_search_v2',
  591. token,
  592. body: { keyword: query, cursor: 0 }
  593. });
  594. return extractAccountCandidates(response, query);
  595. } catch (error) {
  596. errors.push(apiErrorRecord({ stage: 'account_search', query }, error));
  597. warnings.push(`账号方向「${query}」搜索失败:${error.message}`);
  598. return [];
  599. }
  600. }
  601. async function fetchAccountPosts({ token, accounts, options, warnings, errors }) {
  602. const accountVideos = [];
  603. const accountProfiles = [];
  604. for (const account of accounts.slice(0, options.accountsLimit)) {
  605. const sec = parseSecUserId(account);
  606. if (!sec) continue;
  607. const nickname = typeof account === 'object' ? account.nickname || account.nick_name || '' : '';
  608. try {
  609. const profile = await requestJson({
  610. method: 'GET',
  611. pathUrl: '/douyin/app/v3/handler_user_profile',
  612. token,
  613. query: { sec_user_id: sec }
  614. });
  615. accountProfiles.push({ sec_user_id: sec, nickname, raw: profile });
  616. } catch (error) {
  617. warnings.push(`账号 ${nickname || sec.slice(0, 12)} 主页信息获取失败:${error.message}`);
  618. }
  619. try {
  620. const response = await requestJson({
  621. method: 'GET',
  622. pathUrl: '/douyin/app/v3/fetch_user_post_videos',
  623. token,
  624. query: { sec_user_id: sec, max_cursor: 0, count: options.postsPerAccount }
  625. });
  626. const posts = asArray(response?.data?.aweme_list)
  627. .map(aweme => normalizeAweme(aweme, { sourceType: 'account', accountSource: nickname || sec, sec_user_id: sec, nickname }))
  628. .filter(item => item.aweme_id);
  629. if (!posts.length) warnings.push(`账号 ${nickname || sec.slice(0, 12)} 未返回近期作品。`);
  630. accountVideos.push(...posts);
  631. } catch (error) {
  632. errors.push(apiErrorRecord({ stage: 'account_posts', sec_user_id: sec }, error));
  633. warnings.push(`账号 ${nickname || sec.slice(0, 12)} 近期作品监听失败:${error.message}`);
  634. }
  635. }
  636. return { accountVideos: dedupeVideos(accountVideos), accountProfiles };
  637. }
  638. async function fetchCommentsAndReplies({ token, videos, options, warnings, errors }) {
  639. const comments = [];
  640. const replies = [];
  641. for (const video of videos.slice(0, options.maxVideosWithComments)) {
  642. let cursor = 0;
  643. const videoComments = [];
  644. for (let page = 0; page < options.maxCommentPages; page++) {
  645. try {
  646. const response = await requestJson({
  647. method: 'GET',
  648. pathUrl: '/douyin/app/v3/fetch_video_comments',
  649. token,
  650. query: { aweme_id: video.aweme_id, cursor, count: options.commentsPerPage }
  651. });
  652. const pageComments = asArray(response?.data?.comments).map(comment => ({
  653. ...comment,
  654. aweme_id: video.aweme_id,
  655. item_id: video.aweme_id,
  656. keyword: video.keyword,
  657. sourceType: video.sourceType
  658. }));
  659. comments.push(...pageComments);
  660. videoComments.push(...pageComments);
  661. const hasMore = Number(response?.data?.has_more || 0) > 0;
  662. cursor = response?.data?.cursor || 0;
  663. if (!hasMore) break;
  664. } catch (error) {
  665. errors.push(apiErrorRecord({ stage: 'comments', aweme_id: video.aweme_id }, error));
  666. warnings.push(`视频 ${video.aweme_id} 评论抓取失败:${error.message}`);
  667. break;
  668. }
  669. }
  670. if (!options.includeReplies) continue;
  671. const rootComments = [...videoComments]
  672. .filter(comment => Number(comment.reply_comment_total || 0) > 0 && comment.cid)
  673. .sort((a, b) => toNumber(b.digg_count) - toNumber(a.digg_count))
  674. .slice(0, options.repliesPerVideo);
  675. for (const comment of rootComments) {
  676. try {
  677. const response = await requestJson({
  678. method: 'GET',
  679. pathUrl: '/douyin/app/v3/fetch_video_comment_replies',
  680. token,
  681. query: { item_id: video.aweme_id, comment_id: comment.cid, cursor: 0, count: options.replyCount }
  682. });
  683. asArray(response?.data?.comments).forEach(reply => replies.push({
  684. ...reply,
  685. aweme_id: video.aweme_id,
  686. item_id: video.aweme_id,
  687. root_comment_id: comment.cid,
  688. keyword: video.keyword,
  689. sourceType: video.sourceType
  690. }));
  691. } catch (error) {
  692. errors.push(apiErrorRecord({ stage: 'comment_replies', aweme_id: video.aweme_id, comment_id: comment.cid }, error));
  693. }
  694. }
  695. }
  696. return { comments, replies };
  697. }
  698. function resolveDailyReportScript() {
  699. const candidates = [
  700. path.join(__dirname, 'douyin-speaking-daily-report.js'),
  701. path.join(process.cwd(), 'scripts', 'tools', 'douyin-speaking-daily-report.js'),
  702. path.join(process.cwd(), 'douyin-speaking-daily', 'scripts', 'douyin-speaking-daily-report.js')
  703. ];
  704. return candidates.find(filePath => fs.existsSync(filePath));
  705. }
  706. function extractLastJson(stdout) {
  707. const text = String(stdout || '').trim();
  708. for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) {
  709. try {
  710. return JSON.parse(text.slice(i));
  711. } catch {
  712. continue;
  713. }
  714. }
  715. return undefined;
  716. }
  717. function reportPassThroughArgs(args) {
  718. const out = {};
  719. const selectedTopicIndex = firstConcreteArg(args.selectedTopicIndex, args['selected-topic-index'], args.topicIndex, args['topic-index']);
  720. const scriptSession = firstConcreteArg(args.scriptSession, args['script-session']);
  721. const feedback = firstConcreteArg(args.feedback, args.userFeedback, args['user-feedback']);
  722. const finalize = firstConcreteArg(args.finalize, args.final);
  723. const scriptMemory = firstConcreteArg(args.scriptMemory, args['script-memory']);
  724. const historyMemory = firstConcreteArg(args.historyMemory, args['history-memory']);
  725. const writeHistory = firstConcreteArg(args.writeHistory, args['write-history']);
  726. const message = firstConcreteArg(args.message, args.userMessage, args['user-message'], args.intent, args.command);
  727. if (selectedTopicIndex !== undefined) out.selectedTopicIndex = selectedTopicIndex;
  728. if (scriptSession !== undefined) out.scriptSession = scriptSession;
  729. if (feedback !== undefined) out.feedback = feedback;
  730. if (finalize !== undefined) out.finalize = finalize;
  731. if (scriptMemory !== undefined) out.scriptMemory = scriptMemory;
  732. if (historyMemory !== undefined) out.historyMemory = historyMemory;
  733. if (writeHistory !== undefined) out.writeHistory = writeHistory;
  734. if (message !== undefined) out.message = message;
  735. return out;
  736. }
  737. function appendOptionalCliArgs(target, mapping) {
  738. Object.entries(mapping).forEach(([key, value]) => {
  739. if (value === undefined || value === null || value === '') return;
  740. target.push(`--${key}`, String(value));
  741. });
  742. }
  743. function runDailyReport({ profile, rawPath, outputDir, args = {} }) {
  744. const reportScript = resolveDailyReportScript();
  745. if (!reportScript) throw new Error('Cannot find douyin-speaking-daily-report.js');
  746. const profilePath = path.join(outputDir, 'profile.resolved.json');
  747. fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), 'utf8');
  748. const reportArgs = reportPassThroughArgs(args);
  749. const reportModule = require(reportScript);
  750. if (typeof reportModule.runReportWithArgs === 'function') {
  751. return reportModule.runReportWithArgs({
  752. profile: profilePath,
  753. input: rawPath,
  754. output: outputDir,
  755. ...reportArgs
  756. });
  757. }
  758. const cliArgs = [
  759. reportScript,
  760. '--profile', profilePath,
  761. '--input', rawPath,
  762. '--output', outputDir
  763. ];
  764. appendOptionalCliArgs(cliArgs, {
  765. 'selected-topic-index': reportArgs.selectedTopicIndex,
  766. 'script-session': reportArgs.scriptSession,
  767. feedback: reportArgs.feedback,
  768. finalize: reportArgs.finalize,
  769. 'script-memory': reportArgs.scriptMemory,
  770. 'history-memory': reportArgs.historyMemory,
  771. 'write-history': reportArgs.writeHistory,
  772. message: reportArgs.message
  773. });
  774. const child = spawnSync(process.execPath, cliArgs, {
  775. cwd: process.cwd(),
  776. encoding: 'utf8',
  777. maxBuffer: 1024 * 1024 * 100
  778. });
  779. if (child.error) throw child.error;
  780. if (child.stderr) process.stderr.write(child.stderr);
  781. if (child.status !== 0) {
  782. throw new Error(`daily report failed: ${child.stderr || child.stdout}`);
  783. }
  784. const parsed = extractLastJson(child.stdout);
  785. if (!parsed) throw new Error('daily report did not emit JSON');
  786. return parsed;
  787. }
  788. function resolveVideoTranscriberScript() {
  789. const candidates = [
  790. path.join(__dirname, 'douyin-video-transcriber.js'),
  791. path.join(process.cwd(), 'scripts', 'tools', 'douyin-video-transcriber.js'),
  792. path.join(process.cwd(), 'douyin-speaking-daily', 'scripts', 'douyin-video-transcriber.js')
  793. ];
  794. return candidates.find(filePath => fs.existsSync(filePath));
  795. }
  796. function videoId(video) {
  797. return cleanText(video?.aweme_id || video?.awemeId || video?.id || video?.raw?.aweme_id || video?.raw?.awemeId);
  798. }
  799. function videoShareUrl(video) {
  800. return cleanText(video?.share_url || video?.url || video?.raw?.share_url || video?.raw?.url);
  801. }
  802. function videoDurationMs(video) {
  803. return intValue(video?.duration || video?.durationMs || video?.raw?.duration || video?.raw?.duration_ms || video?.raw?.durationMs, 0) || 0;
  804. }
  805. function transcriptVideoId(transcript) {
  806. return cleanText(transcript?.awemeId || transcript?.aweme_id || transcript?.videoId || transcript?.itemId);
  807. }
  808. function transcriptHasText(transcript) {
  809. return Boolean(cleanText(transcript?.text));
  810. }
  811. function dedupeTranscripts(transcripts) {
  812. const map = new Map();
  813. asArray(transcripts).filter(Boolean).forEach((transcript, index) => {
  814. const id = transcriptVideoId(transcript);
  815. const key = id || `transcript_${index}`;
  816. const existing = map.get(key);
  817. if (!existing || (!transcriptHasText(existing) && transcriptHasText(transcript))) {
  818. map.set(key, transcript);
  819. }
  820. });
  821. return [...map.values()];
  822. }
  823. function simpleHash(value) {
  824. const text = cleanText(value);
  825. let hash = 0;
  826. for (let i = 0; i < text.length; i++) {
  827. hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
  828. }
  829. return Math.abs(hash).toString(36);
  830. }
  831. function truncateText(text, limit) {
  832. const normalized = cleanText(text);
  833. return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
  834. }
  835. function videoTitle(video) {
  836. return truncateText(video?.title || video?.desc || video?.content || video?.raw?.title || video?.raw?.desc, 80);
  837. }
  838. function videoAuthorName(video) {
  839. const author = video?.author || video?.raw?.author || video?.raw?.author_user_id;
  840. if (typeof author === 'string') return cleanText(author);
  841. return cleanText(author?.nickname || author?.name || video?.authorName || video?.raw?.authorName);
  842. }
  843. function resolveFilePathMaybe(filePath) {
  844. if (!hasConcreteArg(filePath)) return '';
  845. return path.resolve(filePath);
  846. }
  847. function emptyTranscriptCache() {
  848. return { version: 1, updatedAt: '', items: {} };
  849. }
  850. function normalizeTranscriptCache(raw) {
  851. const cache = emptyTranscriptCache();
  852. if (!raw) return cache;
  853. const items = Array.isArray(raw)
  854. ? raw
  855. : Array.isArray(raw.items)
  856. ? raw.items
  857. : Object.entries(raw.items || {}).map(([awemeId, value]) => ({ awemeId, ...(value || {}) }));
  858. items.forEach(item => {
  859. const transcript = item?.transcript || item;
  860. const id = cleanText(item?.awemeId || item?.aweme_id || transcriptVideoId(transcript));
  861. if (!id) return;
  862. cache.items[id] = {
  863. awemeId: id,
  864. provider: item?.provider || transcript?.provider || '',
  865. orderId: item?.orderId || transcript?.orderId || '',
  866. sourceUrl: item?.sourceUrl || transcript?.sourceUrl || '',
  867. mediaUrlHash: item?.mediaUrlHash || simpleHash(item?.sourceUrl || transcript?.sourceUrl || id),
  868. transcriptPath: item?.transcriptPath || '',
  869. textLength: intValue(item?.textLength || cleanText(transcript?.text).length, 0) || 0,
  870. generatedAt: item?.generatedAt || item?.createdAt || '',
  871. lastUsedAt: item?.lastUsedAt || '',
  872. transcript: transcriptHasText(transcript) ? transcript : undefined
  873. };
  874. });
  875. cache.updatedAt = raw.updatedAt || '';
  876. return cache;
  877. }
  878. function readTranscriptCache(cachePath, warnings) {
  879. const resolved = resolveFilePathMaybe(cachePath);
  880. if (!resolved || !fs.existsSync(resolved)) return emptyTranscriptCache();
  881. try {
  882. return normalizeTranscriptCache(readJson(resolved));
  883. } catch (error) {
  884. warnings.push(`transcript cache 读取失败,已忽略:${error.message}`);
  885. return emptyTranscriptCache();
  886. }
  887. }
  888. function transcriptFromCacheEntry(entry) {
  889. if (!entry) return null;
  890. if (transcriptHasText(entry.transcript)) {
  891. return { ...entry.transcript, awemeId: transcriptVideoId(entry.transcript) || entry.awemeId };
  892. }
  893. if (hasConcreteArg(entry.transcriptPath) && fs.existsSync(entry.transcriptPath)) {
  894. try {
  895. const transcript = readJson(entry.transcriptPath);
  896. if (transcriptHasText(transcript)) return { ...transcript, awemeId: transcriptVideoId(transcript) || entry.awemeId };
  897. } catch {
  898. return null;
  899. }
  900. }
  901. return null;
  902. }
  903. function makeTranscriptQueueEntry(video, rank, status, reason, options, extra = {}) {
  904. const id = videoId(video);
  905. return {
  906. rank,
  907. awemeId: id,
  908. title: videoTitle(video),
  909. author: videoAuthorName(video),
  910. sourceUrl: videoShareUrl(video),
  911. mediaUrlHash: simpleHash(videoShareUrl(video) || id),
  912. durationMs: videoDurationMs(video),
  913. score: Math.round(videoScore(video)),
  914. provider: options.autoTranscriptProvider,
  915. status,
  916. reason,
  917. transcriptPath: '',
  918. orderId: '',
  919. textLength: 0,
  920. updatedAt: new Date().toISOString(),
  921. ...extra
  922. };
  923. }
  924. function updateQueueEntry(queue, awemeId, patch) {
  925. const entry = queue.find(item => item.awemeId === awemeId);
  926. if (!entry) return;
  927. Object.assign(entry, patch, { updatedAt: new Date().toISOString() });
  928. }
  929. function selectAutoTranscriptTargets(rawInput, options, cache) {
  930. const existingIds = new Set(asArray(rawInput.transcripts).filter(transcriptHasText).map(transcriptVideoId).filter(Boolean));
  931. const maxDurationMs = options.autoTranscriptMaxDurationMs;
  932. const topN = options.autoTranscriptTopN;
  933. const dailyBudget = Number.isFinite(options.transcriptDailyBudget)
  934. ? options.transcriptDailyBudget
  935. : topN;
  936. let selectedCount = 0;
  937. let newRemaining = dailyBudget;
  938. const queue = [];
  939. const cached = [];
  940. const targets = [];
  941. dedupeVideos([...asArray(rawInput.videos), ...asArray(rawInput.accountVideos)])
  942. .sort((a, b) => videoScore(b) - videoScore(a))
  943. .forEach((video, index) => {
  944. const id = videoId(video);
  945. const url = videoShareUrl(video);
  946. if (!id || !url) {
  947. queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'missing_video_id_or_url', options));
  948. return;
  949. }
  950. if (selectedCount >= topN) {
  951. queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'outside_top_n', options));
  952. return;
  953. }
  954. if (existingIds.has(id)) {
  955. selectedCount += 1;
  956. queue.push(makeTranscriptQueueEntry(video, index + 1, 'existing', 'transcript_already_in_input', options));
  957. return;
  958. }
  959. const cacheEntry = options.transcriptReuseCache ? cache.items[id] : null;
  960. const cachedTranscript = transcriptFromCacheEntry(cacheEntry);
  961. if (cachedTranscript) {
  962. selectedCount += 1;
  963. cached.push({ video, transcript: cachedTranscript, entry: cacheEntry });
  964. queue.push(makeTranscriptQueueEntry(video, index + 1, 'cached', 'transcript_cache_hit', options, {
  965. transcriptPath: cacheEntry.transcriptPath || '',
  966. orderId: cacheEntry.orderId || cachedTranscript.orderId || '',
  967. provider: cacheEntry.provider || cachedTranscript.provider || options.autoTranscriptProvider,
  968. textLength: cacheEntry.textLength || cleanText(cachedTranscript.text).length
  969. }));
  970. return;
  971. }
  972. if (maxDurationMs && videoDurationMs(video) && videoDurationMs(video) > maxDurationMs) {
  973. queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'duration_over_limit', options));
  974. return;
  975. }
  976. if (newRemaining <= 0) {
  977. queue.push(makeTranscriptQueueEntry(video, index + 1, 'skipped', 'daily_budget_exhausted', options));
  978. return;
  979. }
  980. selectedCount += 1;
  981. newRemaining -= 1;
  982. targets.push(video);
  983. queue.push(makeTranscriptQueueEntry(video, index + 1, 'pending', 'selected_for_transcription', options));
  984. });
  985. return { targets, cached, queue };
  986. }
  987. function updateTranscriptCache(cache, result, video) {
  988. if (result.status !== 'ok' || !transcriptHasText(result.transcript)) return false;
  989. const id = result.awemeId || videoId(video);
  990. cache.items[id] = {
  991. awemeId: id,
  992. provider: result.provider,
  993. orderId: result.orderId,
  994. sourceUrl: videoShareUrl(video),
  995. mediaUrlHash: simpleHash(videoShareUrl(video) || id),
  996. transcriptPath: result.transcriptPath,
  997. textLength: result.textLength || cleanText(result.transcript.text).length,
  998. generatedAt: new Date().toISOString(),
  999. lastUsedAt: new Date().toISOString(),
  1000. transcript: result.transcript
  1001. };
  1002. cache.updatedAt = new Date().toISOString();
  1003. return true;
  1004. }
  1005. function writeTranscriptCache(cachePath, cache, warnings) {
  1006. const resolved = resolveFilePathMaybe(cachePath);
  1007. if (!resolved) return '';
  1008. try {
  1009. ensureDir(path.dirname(resolved));
  1010. fs.writeFileSync(resolved, JSON.stringify(cache, null, 2), 'utf8');
  1011. return resolved;
  1012. } catch (error) {
  1013. warnings.push(`transcript cache 写入失败:${error.message}`);
  1014. return '';
  1015. }
  1016. }
  1017. function runVideoTranscriber({ script, video, outputDir, options }) {
  1018. const id = videoId(video);
  1019. const transcriptDir = path.join(outputDir, 'transcripts', id);
  1020. ensureDir(transcriptDir);
  1021. const args = [
  1022. script,
  1023. '--provider', options.autoTranscriptProvider,
  1024. '--douyin-url', videoShareUrl(video),
  1025. '--aweme-id', id,
  1026. '--output', transcriptDir,
  1027. '--poll-interval-ms', String(options.transcriptPollIntervalMs),
  1028. '--max-polls', String(options.transcriptMaxPolls),
  1029. '--max-download-mb', String(options.transcriptMaxDownloadMb),
  1030. '--download-timeout-ms', String(options.transcriptDownloadTimeoutMs),
  1031. '--download-idle-timeout-ms', String(options.transcriptDownloadIdleTimeoutMs)
  1032. ];
  1033. const durationMs = videoDurationMs(video);
  1034. if (durationMs) args.push('--duration-ms', String(durationMs));
  1035. const child = spawnSync(process.execPath, args, {
  1036. cwd: process.cwd(),
  1037. encoding: 'utf8',
  1038. maxBuffer: 1024 * 1024 * 100
  1039. });
  1040. const parsed = extractLastJson(child.stdout);
  1041. const transcriptPath = path.join(transcriptDir, 'transcript.json');
  1042. let transcript;
  1043. if (fs.existsSync(transcriptPath)) transcript = readJson(transcriptPath);
  1044. return {
  1045. status: child.status === 0 ? parsed?.status || transcript?.status || 'ok' : 'error',
  1046. awemeId: id,
  1047. outputDir: transcriptDir,
  1048. transcriptPath: fs.existsSync(transcriptPath) ? transcriptPath : '',
  1049. orderId: parsed?.orderId || transcript?.orderId || '',
  1050. provider: parsed?.provider || transcript?.provider || options.autoTranscriptProvider,
  1051. textLength: parsed?.textLength || cleanText(transcript?.text).length || 0,
  1052. segmentCount: parsed?.segmentCount || asArray(transcript?.segments).length,
  1053. message: child.status === 0 ? '' : cleanText(child.stderr || child.stdout),
  1054. transcript
  1055. };
  1056. }
  1057. function autoGenerateTranscripts({ rawInput, outputDir, options, warnings, errors }) {
  1058. const queuePath = path.join(outputDir, 'transcript-queue.json');
  1059. const cache = readTranscriptCache(options.transcriptCachePath, warnings);
  1060. const empty = {
  1061. transcripts: [],
  1062. results: [],
  1063. queue: [],
  1064. queuePath: '',
  1065. cachePath: resolveFilePathMaybe(options.transcriptCachePath),
  1066. cachedCount: 0,
  1067. generatedCount: 0
  1068. };
  1069. if (!options.autoTranscriptTopN) return empty;
  1070. const selection = selectAutoTranscriptTargets(rawInput, options, cache);
  1071. const results = [];
  1072. const transcripts = [];
  1073. let cacheChanged = false;
  1074. selection.cached.forEach(({ video, transcript, entry }) => {
  1075. const id = videoId(video);
  1076. const usedAt = new Date().toISOString();
  1077. cache.items[id] = { ...(entry || {}), awemeId: id, lastUsedAt: usedAt, transcript };
  1078. cache.updatedAt = usedAt;
  1079. cacheChanged = true;
  1080. transcripts.push(transcript);
  1081. results.push({
  1082. status: 'cached',
  1083. awemeId: id,
  1084. outputDir: '',
  1085. transcriptPath: entry?.transcriptPath || '',
  1086. orderId: entry?.orderId || transcript.orderId || '',
  1087. provider: entry?.provider || transcript.provider || options.autoTranscriptProvider,
  1088. textLength: entry?.textLength || cleanText(transcript.text).length,
  1089. segmentCount: asArray(transcript.segments).length,
  1090. message: 'transcript cache hit'
  1091. });
  1092. });
  1093. let script = '';
  1094. if (selection.targets.length) {
  1095. script = resolveVideoTranscriberScript();
  1096. if (!script) {
  1097. warnings.push('autoTranscript 已开启,但未找到 douyin-video-transcriber.js,跳过逐字稿补跑。');
  1098. selection.targets.forEach(video => {
  1099. const id = videoId(video);
  1100. updateQueueEntry(selection.queue, id, { status: 'skipped', reason: 'missing_transcriber_script' });
  1101. errors.push({ stage: 'auto_transcript', aweme_id: id, status: 'missing_transcriber_script' });
  1102. });
  1103. }
  1104. }
  1105. if (script) {
  1106. selection.targets.forEach(video => {
  1107. const result = runVideoTranscriber({ script, video, outputDir, options });
  1108. const resultSummary = {
  1109. status: result.status,
  1110. awemeId: result.awemeId,
  1111. outputDir: result.outputDir,
  1112. transcriptPath: result.transcriptPath,
  1113. orderId: result.orderId,
  1114. provider: result.provider,
  1115. textLength: result.textLength,
  1116. segmentCount: result.segmentCount,
  1117. message: result.message
  1118. };
  1119. results.push(resultSummary);
  1120. updateQueueEntry(selection.queue, result.awemeId, {
  1121. status: result.status === 'ok' && transcriptHasText(result.transcript) ? 'generated' : 'failed',
  1122. reason: result.status === 'ok' && transcriptHasText(result.transcript) ? 'transcription_finished' : 'transcription_failed',
  1123. transcriptPath: result.transcriptPath,
  1124. orderId: result.orderId,
  1125. provider: result.provider,
  1126. textLength: result.textLength,
  1127. error: result.message || ''
  1128. });
  1129. if (result.status === 'ok' && transcriptHasText(result.transcript)) {
  1130. transcripts.push(result.transcript);
  1131. cacheChanged = updateTranscriptCache(cache, result, video) || cacheChanged;
  1132. } else {
  1133. errors.push({
  1134. stage: 'auto_transcript',
  1135. aweme_id: result.awemeId,
  1136. status: result.status,
  1137. message: result.message || 'transcript did not return text'
  1138. });
  1139. warnings.push(`视频 ${result.awemeId} 自动转写未产出可用文本,日报将继续按结构推断。`);
  1140. }
  1141. });
  1142. }
  1143. if (!selection.cached.length && !selection.targets.length) {
  1144. warnings.push('autoTranscript 已开启,但没有找到可转写的视频链接,或候选已存在逐字稿。');
  1145. }
  1146. const queueDoc = {
  1147. version: 1,
  1148. generatedAt: new Date().toISOString(),
  1149. policy: {
  1150. provider: options.autoTranscriptProvider,
  1151. topN: options.autoTranscriptTopN,
  1152. maxDurationMs: options.autoTranscriptMaxDurationMs,
  1153. dailyBudget: options.transcriptDailyBudget,
  1154. reuseCache: options.transcriptReuseCache
  1155. },
  1156. cachePath: resolveFilePathMaybe(options.transcriptCachePath),
  1157. items: selection.queue
  1158. };
  1159. fs.writeFileSync(queuePath, JSON.stringify(queueDoc, null, 2), 'utf8');
  1160. const cachePath = cacheChanged
  1161. ? writeTranscriptCache(options.transcriptCachePath, cache, warnings)
  1162. : resolveFilePathMaybe(options.transcriptCachePath);
  1163. return {
  1164. transcripts,
  1165. results,
  1166. queue: selection.queue,
  1167. queuePath,
  1168. cachePath,
  1169. cachedCount: selection.cached.length,
  1170. generatedCount: results.filter(item => item.status === 'ok').length
  1171. };
  1172. }
  1173. async function main() {
  1174. const args = parseArgs(process.argv.slice(2));
  1175. if (args.help) {
  1176. console.log(usage());
  1177. return;
  1178. }
  1179. const profile = loadProfile(args);
  1180. const date = args.date || new Date().toISOString().slice(0, 10);
  1181. const outputDir = path.resolve(args.output || path.join(
  1182. process.cwd(),
  1183. 'openclaw-voc-output',
  1184. 'douyin-speaking-daily',
  1185. slugify(profile.projectName),
  1186. date
  1187. ));
  1188. ensureDir(outputDir);
  1189. const transcriptPolicy = profile.transcriptPolicy || {};
  1190. const options = {
  1191. maxKeywords: intValue(args.maxKeywords || args['max-keywords'], 5) || 5,
  1192. videosPerKeyword: intValue(args.videosPerKeyword || args['videos-per-keyword'], 5) || 5,
  1193. publishTime: args.publishTime || args['publish-time'] || '7',
  1194. sortType: args.sortType || args['sort-type'] || '1',
  1195. filterDuration: args.filterDuration || args['filter-duration'] || '0',
  1196. contentType: args.contentType || args['content-type'] || '1',
  1197. accountsLimit: intValue(args.accountsLimit || args['accounts-limit'], 5) || 5,
  1198. postsPerAccount: intValue(args.postsPerAccount || args['posts-per-account'], 8) || 8,
  1199. autoConfirmAccounts: bool(args.autoConfirmAccounts || args['auto-confirm-accounts'], false),
  1200. maxVideosWithComments: intValue(args.maxVideosWithComments || args['max-videos-with-comments'], 8) || 8,
  1201. maxCommentPages: intValue(args.maxCommentPages || args['max-comment-pages'], 1) || 1,
  1202. commentsPerPage: intValue(args.commentsPerPage || args['comments-per-page'], 20) || 20,
  1203. includeReplies: bool(args.includeReplies || args['include-replies'], true),
  1204. repliesPerVideo: intValue(args.repliesPerVideo || args['replies-per-video'], 2) || 2,
  1205. replyCount: intValue(args.replyCount || args['reply-count'], 20) || 20,
  1206. autoTranscriptTopN: intValue(firstConcreteArg(args.autoTranscriptTopN, args['auto-transcript-top-n'], transcriptPolicy.topN), 0) || 0,
  1207. autoTranscriptProvider: firstConcreteArg(args.autoTranscriptProvider, args['auto-transcript-provider'], transcriptPolicy.provider) || 'iflytek-gateway',
  1208. autoTranscriptMaxDurationMs: intValue(firstConcreteArg(args.autoTranscriptMaxDurationMs, args['auto-transcript-max-duration-ms'], transcriptPolicy.maxDurationMs), 600000) || 600000,
  1209. transcriptPollIntervalMs: intValue(args.transcriptPollIntervalMs || args['transcript-poll-interval-ms'], 4000) || 4000,
  1210. transcriptMaxPolls: intValue(args.transcriptMaxPolls || args['transcript-max-polls'], 30) || 30,
  1211. transcriptMaxDownloadMb: intValue(args.transcriptMaxDownloadMb || args['transcript-max-download-mb'], 80) || 80,
  1212. transcriptDownloadTimeoutMs: intValue(args.transcriptDownloadTimeoutMs || args['transcript-download-timeout-ms'], 120000) || 120000,
  1213. transcriptDownloadIdleTimeoutMs: intValue(args.transcriptDownloadIdleTimeoutMs || args['transcript-download-idle-timeout-ms'], 15000) || 15000,
  1214. transcriptCachePath: firstConcreteArg(args.transcriptCache, args['transcript-cache'], transcriptPolicy.cachePath) || path.join('memory', 'douyin-speaking-transcript-cache.json'),
  1215. transcriptReuseCache: bool(firstConcreteArg(args.transcriptReuseCache, args['transcript-reuse-cache'], transcriptPolicy.reuseCache), true),
  1216. transcriptDailyBudget: intValue(firstConcreteArg(args.transcriptDailyBudget, args['transcript-daily-budget'], transcriptPolicy.dailyBudget), undefined)
  1217. };
  1218. if (!Number.isFinite(options.transcriptDailyBudget)) options.transcriptDailyBudget = options.autoTranscriptTopN;
  1219. const warnings = [];
  1220. const errors = [];
  1221. const messageText = naturalCommandText(args);
  1222. const autoInputPath = !hasConcreteArg(args.input) && isContinuationMessage(messageText)
  1223. ? findLatestFileByName('runner-raw-input.json')
  1224. : '';
  1225. let rawInput = readJsonMaybe(firstConcreteArg(args.input, autoInputPath));
  1226. if (hasConcreteArg(args.input) && !rawInput) {
  1227. throw new Error(`无法读取 --input:请传入存在的 JSON 文件路径,或传入合法 JSON 字符串。收到: ${args.input}`);
  1228. }
  1229. if (rawInput) {
  1230. warnings.push(autoInputPath && !hasConcreteArg(args.input)
  1231. ? `已根据自然话术自动续接最近一次 raw 数据:${autoInputPath}`
  1232. : '使用 input 中的已有 raw 数据,跳过在线采集。');
  1233. } else if (bool(args.dryRun || args['dry-run'], false)) {
  1234. rawInput = { videos: [], accountVideos: [], comments: [], replies: [], dryRun: true };
  1235. warnings.push('dry-run 模式未调用抖音网关。');
  1236. } else {
  1237. const token = loadVocToken();
  1238. if (!token) {
  1239. throw new Error('缺少 VOC Token:请设置 VOC_TOKEN,或运行 node ~/.openclaw/tools/set-voc-token.js <token>');
  1240. }
  1241. const keywordVideos = await fetchKeywordVideos({
  1242. token,
  1243. keywords: profile.keywords,
  1244. options,
  1245. warnings,
  1246. errors
  1247. });
  1248. const accountCandidates = [];
  1249. const confirmedAccounts = [...profile.confirmedAccounts];
  1250. const explicitAccountInputs = uniqueStrings([
  1251. ...splitList(args.accounts),
  1252. ...profile.referenceAccounts
  1253. ]);
  1254. for (const account of explicitAccountInputs) {
  1255. const sec = parseSecUserId(account);
  1256. if (sec) {
  1257. confirmedAccounts.push({ sec_user_id: sec, nickname: account });
  1258. } else if (account) {
  1259. const candidates = await searchAccounts({ token, query: account, warnings, errors });
  1260. if (candidates.length) {
  1261. accountCandidates.push(...candidates.map(candidate => ({ ...candidate, sourceInput: account })));
  1262. warnings.push(`账号「${account}」已搜索到候选账号,等待用户确认后再进入近期作品监听。`);
  1263. } else {
  1264. warnings.push(`账号「${account}」未解析为 sec_user_id,也未搜索到候选账号。`);
  1265. }
  1266. }
  1267. }
  1268. const dedupedConfirmedAccounts = dedupeAccounts(confirmedAccounts);
  1269. if (profile.accountDiscoveryDirection && !dedupedConfirmedAccounts.length) {
  1270. const generalSearchCandidates = await discoverAccountsFromGeneralSearch({
  1271. token,
  1272. profile,
  1273. seedVideos: keywordVideos,
  1274. options,
  1275. warnings,
  1276. errors
  1277. });
  1278. accountCandidates.push(...generalSearchCandidates);
  1279. for (const query of discoveryTerms(profile).slice(0, Math.min(options.maxKeywords, 3))) {
  1280. const candidates = await searchAccounts({ token, query, warnings, errors });
  1281. accountCandidates.push(...candidates);
  1282. }
  1283. const candidatePool = dedupeAccountCandidates(accountCandidates).slice(0, Math.max(options.accountsLimit * 5, 20));
  1284. accountCandidates.length = 0;
  1285. accountCandidates.push(...candidatePool);
  1286. if (options.autoConfirmAccounts) {
  1287. dedupedConfirmedAccounts.push(...candidatePool.filter(item => item.sec_user_id).slice(0, options.accountsLimit));
  1288. warnings.push('已按 autoConfirmAccounts 使用候选账号 Top 结果进入作品监听;正式使用建议先让用户确认。');
  1289. } else if (candidatePool.length) {
  1290. warnings.push('账号方向发现已产出候选账号,但未进入作品监听;需要用户确认 confirmedAccounts。');
  1291. }
  1292. }
  1293. const uniqueAccountCandidates = dedupeAccountCandidates(accountCandidates).slice(0, Math.max(options.accountsLimit * 5, 20));
  1294. accountCandidates.length = 0;
  1295. accountCandidates.push(...uniqueAccountCandidates);
  1296. const { accountVideos, accountProfiles } = await fetchAccountPosts({
  1297. token,
  1298. accounts: dedupeAccounts(dedupedConfirmedAccounts),
  1299. options,
  1300. warnings,
  1301. errors
  1302. });
  1303. const commentTargets = dedupeVideos([...keywordVideos, ...accountVideos])
  1304. .sort((a, b) => videoScore(b) - videoScore(a));
  1305. const { comments, replies } = await fetchCommentsAndReplies({
  1306. token,
  1307. videos: commentTargets,
  1308. options,
  1309. warnings,
  1310. errors
  1311. });
  1312. rawInput = {
  1313. metadata: {
  1314. projectName: profile.projectName,
  1315. generatedAt: new Date().toISOString(),
  1316. options,
  1317. accountCandidateCount: accountCandidates.length,
  1318. accountProfileCount: accountProfiles.length,
  1319. errors
  1320. },
  1321. videos: keywordVideos,
  1322. accountVideos,
  1323. comments,
  1324. replies,
  1325. accountCandidates,
  1326. accountProfiles,
  1327. transcripts: asArray(readJsonMaybe(args.transcripts) || [])
  1328. };
  1329. }
  1330. const extraTranscripts = asArray(readJsonMaybe(args.transcripts) || []);
  1331. if (extraTranscripts.length) {
  1332. rawInput.transcripts = dedupeTranscripts([...asArray(rawInput.transcripts), ...extraTranscripts]);
  1333. }
  1334. const autoTranscript = autoGenerateTranscripts({ rawInput, outputDir, options, warnings, errors });
  1335. rawInput.transcripts = dedupeTranscripts([...asArray(rawInput.transcripts), ...autoTranscript.transcripts]);
  1336. rawInput.metadata = {
  1337. ...(rawInput.metadata || {}),
  1338. autoTranscript: {
  1339. enabled: Boolean(options.autoTranscriptTopN),
  1340. provider: options.autoTranscriptProvider,
  1341. topN: options.autoTranscriptTopN,
  1342. maxDurationMs: options.autoTranscriptMaxDurationMs,
  1343. dailyBudget: options.transcriptDailyBudget,
  1344. reuseCache: options.transcriptReuseCache,
  1345. cachePath: autoTranscript.cachePath,
  1346. queuePath: autoTranscript.queuePath,
  1347. cachedCount: autoTranscript.cachedCount,
  1348. generatedCount: autoTranscript.generatedCount,
  1349. queuedCount: autoTranscript.queue.length,
  1350. results: autoTranscript.results
  1351. }
  1352. };
  1353. const rawPath = path.join(outputDir, 'runner-raw-input.json');
  1354. fs.writeFileSync(rawPath, JSON.stringify(rawInput, null, 2), 'utf8');
  1355. const reportResult = runDailyReport({ profile, rawPath, outputDir, args });
  1356. const accountCandidatesPath = path.join(outputDir, 'account-candidates.json');
  1357. fs.writeFileSync(accountCandidatesPath, JSON.stringify(asArray(rawInput.accountCandidates), null, 2), 'utf8');
  1358. const candidateMarkdown = renderAccountCandidatesMarkdown(rawInput.accountCandidates);
  1359. const accountCandidatesMdPath = path.join(outputDir, 'account-candidates.md');
  1360. if (candidateMarkdown) fs.writeFileSync(accountCandidatesMdPath, candidateMarkdown, 'utf8');
  1361. const hasAccountCandidates = asArray(rawInput.accountCandidates).length > 0;
  1362. const hasAccountVideos = asArray(rawInput.accountVideos).length > 0;
  1363. const accountPostErrors = errors.filter(error => error.stage === 'account_posts');
  1364. const gatewayAuthErrors = errors.filter(isGatewayAuthError);
  1365. const hasAnyCollectedData = asArray(rawInput.videos).length
  1366. || asArray(rawInput.accountVideos).length
  1367. || asArray(rawInput.comments).length
  1368. || asArray(rawInput.replies).length;
  1369. if (gatewayAuthErrors.length && !hasAnyCollectedData) {
  1370. warnings.unshift('VOC 社媒网关鉴权被拒绝:Token 有效但 APIGAuth 可能未绑定到当前 Company,或后端仍按 company-only 查询。请先修复授权绑定后重跑同一命令。');
  1371. }
  1372. const status = gatewayAuthErrors.length && !hasAnyCollectedData
  1373. ? 'gateway_auth_blocked'
  1374. : accountPostErrors.length && !hasAccountVideos
  1375. ? 'account_monitor_failed'
  1376. : hasAccountCandidates && !hasAccountVideos && !options.autoConfirmAccounts
  1377. ? 'needs_account_confirmation'
  1378. : reportResult.status;
  1379. const reportMarkdown = reportResult.assistantMessage
  1380. || reportResult.contentMarkdown
  1381. || reportResult.chatMarkdown
  1382. || reportResult.markdown
  1383. || '';
  1384. const fullMarkdown = candidateMarkdown && reportMarkdown
  1385. ? `${candidateMarkdown}\n\n---\n\n${reportMarkdown}`
  1386. : candidateMarkdown || reportMarkdown;
  1387. const markdown = candidateMarkdown || reportMarkdown || fullMarkdown;
  1388. const result = {
  1389. status,
  1390. assistantMessage: markdown,
  1391. markdown,
  1392. outputDir,
  1393. files: [
  1394. ...asArray(reportResult.files),
  1395. rawPath,
  1396. accountCandidatesPath,
  1397. ...(candidateMarkdown ? [accountCandidatesMdPath] : []),
  1398. ...(autoTranscript.queuePath ? [autoTranscript.queuePath] : []),
  1399. ...(autoTranscript.cachePath && fs.existsSync(autoTranscript.cachePath) ? [autoTranscript.cachePath] : []),
  1400. ...autoTranscript.results.flatMap(result => {
  1401. const files = [];
  1402. if (result.transcriptPath) files.push(result.transcriptPath);
  1403. if (result.outputDir) files.push(path.join(result.outputDir, 'transcript.txt'));
  1404. return files;
  1405. })
  1406. ],
  1407. summary: {
  1408. ...reportResult.summary,
  1409. accountCandidateCount: asArray(rawInput.accountCandidates).length,
  1410. accountVideoCount: asArray(rawInput.accountVideos).length,
  1411. autoTranscriptCachedCount: autoTranscript.cachedCount,
  1412. autoTranscriptGeneratedCount: autoTranscript.generatedCount,
  1413. autoTranscriptQueueCount: autoTranscript.queue.length,
  1414. errorCount: errors.length,
  1415. accountPostErrorCount: accountPostErrors.length
  1416. },
  1417. qualityGate: reportResult.qualityGate || {},
  1418. scriptMemorySignals: reportResult.scriptMemorySignals || {},
  1419. transcriptResults: autoTranscript.results,
  1420. transcriptQueuePath: autoTranscript.queuePath,
  1421. transcriptCachePath: autoTranscript.cachePath,
  1422. accountCandidates: asArray(rawInput.accountCandidates).map(summarizeAccountCandidate).slice(0, 20),
  1423. warnings,
  1424. errors,
  1425. nextAction: status === 'gateway_auth_blocked'
  1426. ? 'repair_apigauth_company_binding_then_rerun'
  1427. : undefined,
  1428. oneLineJudgement: reportResult.oneLineJudgement,
  1429. topicIdeaCount: reportResult.topicIdeaCount,
  1430. topicIdeasPreview: asArray(reportResult.topicIdeasPreview),
  1431. selectedTopicIndex: reportResult.selectedTopicIndex,
  1432. selectedTopic: reportResult.selectedTopic,
  1433. selectedScriptPath: reportResult.selectedScriptPath || '',
  1434. scriptSessionPath: reportResult.scriptSessionPath || '',
  1435. scriptSessionStatus: reportResult.scriptSessionStatus || '',
  1436. scriptVersion: reportResult.scriptVersion || '',
  1437. scriptMemoryPath: reportResult.scriptMemoryPath || '',
  1438. historyMemoryPath: reportResult.historyMemoryPath || '',
  1439. naturalCommand: reportResult.naturalCommand || {},
  1440. topicPreferenceMemoryPath: reportResult.topicPreferenceMemoryPath || '',
  1441. autoResolvedInputPath: reportResult.autoResolvedInputPath || autoInputPath || '',
  1442. autoResolvedScriptSessionPath: reportResult.autoResolvedScriptSessionPath || '',
  1443. calibrationPrompt: reportResult.calibrationPrompt || '',
  1444. calibrationQuestions: asArray(reportResult.calibrationQuestions),
  1445. fullReportPath: reportResult.fullReportPath || asArray(reportResult.files).find(file => /daily-report\.md$/i.test(String(file))) || ''
  1446. };
  1447. const profileWrite = maybeWriteProfileAfterRun({ args, profile, rawInput, result, reportResult, outputDir });
  1448. if (profileWrite) {
  1449. result.files.push(profileWrite.path);
  1450. result.profileState = profileWrite.profile.state;
  1451. result.readyForDailyReport = profileWrite.profile.readyForDailyReport;
  1452. }
  1453. emitResult(args, result);
  1454. }
  1455. main().catch(error => {
  1456. console.error(error.message);
  1457. process.exit(1);
  1458. });