douyin-speaking-profile-memory.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const TEMPLATE_PATH = path.resolve('douyin-speaking-daily', 'memory-templates', 'douyin-speaking-profile.json');
  5. const DEFAULT_PROFILE_PATH = path.resolve('memory', 'douyin-speaking-profile.json');
  6. function parseArgs(argv) {
  7. const args = {};
  8. for (let i = 0; i < argv.length; i++) {
  9. const token = argv[i];
  10. if (!token.startsWith('--')) continue;
  11. const eq = token.indexOf('=');
  12. if (eq >= 0) {
  13. args[token.slice(2, eq)] = token.slice(eq + 1);
  14. } else {
  15. const key = token.slice(2);
  16. const next = argv[i + 1];
  17. if (next && !next.startsWith('--')) {
  18. args[key] = next;
  19. i++;
  20. } else {
  21. args[key] = true;
  22. }
  23. }
  24. }
  25. return args;
  26. }
  27. function usage() {
  28. return [
  29. 'Usage:',
  30. ' node scripts/tools/douyin-speaking-profile-memory.js --mode update --output memory/douyin-speaking-profile.json --project <name> --industry <name> --keywords "kw1,kw2"',
  31. ' node scripts/tools/douyin-speaking-profile-memory.js --mode record-candidates --profile memory/douyin-speaking-profile.json --candidates outputs/.../account-candidates.json',
  32. ' node scripts/tools/douyin-speaking-profile-memory.js --mode confirm-accounts --profile memory/douyin-speaking-profile.json --candidates outputs/.../account-candidates.json --select 1,2',
  33. ' node scripts/tools/douyin-speaking-profile-memory.js --mode calibrate --profile memory/douyin-speaking-profile.json --liked-topics "1,3" --forbidden-add "topic" --keywords-add "kw"',
  34. '',
  35. 'Modes: update | record-candidates | confirm-accounts | run-complete | calibrate | summarize'
  36. ].join('\n');
  37. }
  38. function ensureDir(dirPath) {
  39. fs.mkdirSync(dirPath, { recursive: true });
  40. }
  41. function readJson(filePath) {
  42. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  43. }
  44. function readJsonMaybe(value) {
  45. if (!value) return undefined;
  46. const text = String(value).trim();
  47. if (!text) return undefined;
  48. const absolute = path.resolve(text);
  49. if (fs.existsSync(absolute)) return readJson(absolute);
  50. try {
  51. return JSON.parse(text);
  52. } catch {
  53. return undefined;
  54. }
  55. }
  56. function asArray(value) {
  57. if (!value) return [];
  58. return Array.isArray(value) ? value : [value];
  59. }
  60. function cleanText(value) {
  61. return String(value || '').replace(/\s+/g, ' ').trim();
  62. }
  63. function splitList(value) {
  64. if (!value) return [];
  65. if (Array.isArray(value)) return value.map(cleanText).filter(Boolean);
  66. const text = cleanText(value);
  67. if (!text || /^\{\{[^}]+\}\}$/.test(text)) return [];
  68. if (text.startsWith('[')) {
  69. try {
  70. const parsed = JSON.parse(text);
  71. return Array.isArray(parsed) ? parsed.map(cleanText).filter(Boolean) : [];
  72. } catch {
  73. return [];
  74. }
  75. }
  76. return text.split(/[,,、\n]/).map(cleanText).filter(Boolean);
  77. }
  78. function uniqueStrings(values) {
  79. return [...new Set(asArray(values).map(cleanText).filter(Boolean))];
  80. }
  81. function bool(value, fallback = false) {
  82. if (value === undefined || value === null || value === '') return fallback;
  83. if (typeof value === 'boolean') return value;
  84. return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
  85. }
  86. function intValue(value, fallback) {
  87. const number = Number(value);
  88. return Number.isFinite(number) ? Math.max(0, Math.round(number)) : fallback;
  89. }
  90. function loadBaseProfile(profilePath) {
  91. if (profilePath && fs.existsSync(profilePath)) return readJson(profilePath);
  92. if (fs.existsSync(DEFAULT_PROFILE_PATH)) return readJson(DEFAULT_PROFILE_PATH);
  93. if (fs.existsSync(TEMPLATE_PATH)) return readJson(TEMPLATE_PATH);
  94. return {
  95. state: 'emptyProfile',
  96. platform: 'douyin',
  97. keywords: [],
  98. referenceAccounts: [],
  99. candidateAccounts: [],
  100. confirmedAccounts: [],
  101. forbiddenTopics: [],
  102. contentPillars: [],
  103. calibrationNotes: [],
  104. dailyOutputCount: 8,
  105. automationReady: false
  106. };
  107. }
  108. function outputPath(args) {
  109. return path.resolve(args.output || args.profile || DEFAULT_PROFILE_PATH);
  110. }
  111. function accountId(account) {
  112. if (!account) return '';
  113. if (typeof account === 'string') return cleanText(account);
  114. return cleanText(account.sec_user_id || account.sec_uid || account.user_id || account.id);
  115. }
  116. function normalizeAccount(account, extra = {}) {
  117. if (!account) return undefined;
  118. if (typeof account === 'string') {
  119. const id = cleanText(account);
  120. if (!id) return undefined;
  121. return { sec_user_id: id, nickname: '', source: extra.source || '' };
  122. }
  123. const id = accountId(account);
  124. if (!id) return undefined;
  125. return {
  126. sec_user_id: id,
  127. nickname: cleanText(account.nickname || account.nick_name || account.name),
  128. follower_count: intValue(account.follower_count ?? account.fans_cnt ?? account.followerCount, 0),
  129. like_count: intValue(account.like_count ?? account.likeCount, 0),
  130. aweme_count: intValue(account.aweme_count ?? account.publish_cnt ?? account.awemeCount, 0),
  131. signature: cleanText(account.signature),
  132. source: cleanText(extra.source || account.source || account.sourceInput)
  133. };
  134. }
  135. function dedupeAccounts(accounts) {
  136. const map = new Map();
  137. asArray(accounts).map(normalizeAccount).filter(Boolean).forEach(account => {
  138. const key = account.sec_user_id;
  139. map.set(key, { ...(map.get(key) || {}), ...account });
  140. });
  141. return [...map.values()];
  142. }
  143. function parseSelectedCandidates(candidates, select) {
  144. const list = asArray(candidates).map(normalizeAccount).filter(Boolean);
  145. const tokens = splitList(select);
  146. if (!tokens.length) return [];
  147. if (tokens.some(token => token.toLowerCase() === 'all')) return list;
  148. const selected = [];
  149. tokens.forEach(token => {
  150. const index = Number(token);
  151. if (Number.isInteger(index) && index >= 1 && index <= list.length) {
  152. selected.push(list[index - 1]);
  153. return;
  154. }
  155. const matched = list.find(account => account.sec_user_id === token || account.nickname === token);
  156. if (matched) selected.push(matched);
  157. });
  158. return dedupeAccounts(selected);
  159. }
  160. function requiredProfileFields(profile) {
  161. return [
  162. { field: 'industry', ok: Boolean(cleanText(profile.industry)) },
  163. { field: 'accountPositioning', ok: Boolean(cleanText(profile.accountPositioning)) },
  164. { field: 'targetAudience', ok: Boolean(cleanText(profile.targetAudience)) },
  165. { field: 'conversionGoal', ok: Boolean(cleanText(profile.conversionGoal)) },
  166. { field: 'keywords', ok: asArray(profile.keywords).length > 0 }
  167. ];
  168. }
  169. function profileComplete(profile) {
  170. return requiredProfileFields(profile).every(item => item.ok);
  171. }
  172. function readyForDailyReport(profile) {
  173. return profileComplete(profile)
  174. && (
  175. asArray(profile.confirmedAccounts).length > 0
  176. || asArray(profile.referenceAccounts).length > 0
  177. || Boolean(cleanText(profile.accountDiscoveryDirection))
  178. );
  179. }
  180. function summarizeProfile(profile) {
  181. const missingFields = requiredProfileFields(profile).filter(item => !item.ok).map(item => item.field);
  182. return {
  183. state: profile.state || 'emptyProfile',
  184. readyForDailyReport: readyForDailyReport(profile),
  185. automationReady: Boolean(profile.automationReady),
  186. missingFields,
  187. keywordCount: asArray(profile.keywords).length,
  188. candidateAccountCount: asArray(profile.candidateAccounts).length,
  189. confirmedAccountCount: asArray(profile.confirmedAccounts).length,
  190. calibrationNoteCount: asArray(profile.calibrationNotes).length,
  191. lastReportAt: profile.lastReportAt || ''
  192. };
  193. }
  194. function applyInlineUpdates(profile, args) {
  195. const scalarFields = [
  196. ['projectName', args.project || args.projectName || args['project-name']],
  197. ['industry', args.industry],
  198. ['accountPositioning', args.accountPositioning || args['account-positioning'] || args.positioning],
  199. ['targetAudience', args.targetAudience || args['target-audience'] || args.audience],
  200. ['conversionGoal', args.conversionGoal || args['conversion-goal'] || args.goal],
  201. ['coreOffer', args.coreOffer || args['core-offer'] || args.offer],
  202. ['accountMonitorMode', args.accountMonitorMode || args['account-monitor-mode']],
  203. ['accountDiscoveryDirection', args.accountDiscoveryDirection || args['account-discovery-direction']],
  204. ['tone', args.tone]
  205. ];
  206. scalarFields.forEach(([field, value]) => {
  207. if (value !== undefined && value !== null && value !== '') profile[field] = value;
  208. });
  209. const listFields = [
  210. ['contentPillars', args.contentPillars || args['content-pillars'] || args.pillars],
  211. ['keywords', args.keywords],
  212. ['referenceAccounts', args.referenceAccounts || args['reference-accounts'] || args.accounts],
  213. ['forbiddenTopics', args.forbiddenTopics || args['forbidden-topics'] || args.forbidden]
  214. ];
  215. listFields.forEach(([field, value]) => {
  216. const items = splitList(value);
  217. if (items.length) profile[field] = uniqueStrings([...(profile[field] || []), ...items]);
  218. });
  219. const dailyOutputCount = intValue(args.dailyOutputCount || args['daily-output-count'] || args.count, undefined);
  220. if (dailyOutputCount !== undefined) profile.dailyOutputCount = dailyOutputCount;
  221. return profile;
  222. }
  223. function loadCandidates(args, profile) {
  224. const fromArgs = readJsonMaybe(args.candidates || args.candidateAccounts || args['candidate-accounts']);
  225. if (fromArgs) return asArray(fromArgs);
  226. return asArray(profile.candidateAccounts);
  227. }
  228. function applyRecordCandidates(profile, args) {
  229. const candidates = loadCandidates(args, profile).map(normalizeAccount).filter(Boolean);
  230. profile.candidateAccounts = dedupeAccounts(candidates);
  231. if (profile.candidateAccounts.length && !asArray(profile.confirmedAccounts).length) {
  232. profile.state = 'awaitingAccountConfirmation';
  233. }
  234. return {
  235. changed: ['candidateAccounts', 'state'],
  236. selectedAccounts: [],
  237. candidateAccountCount: profile.candidateAccounts.length
  238. };
  239. }
  240. function applyConfirmAccounts(profile, args) {
  241. const explicit = readJsonMaybe(args.confirmedAccounts || args['confirmed-accounts']);
  242. const explicitAccounts = explicit ? asArray(explicit).map(normalizeAccount).filter(Boolean) : [];
  243. const candidates = loadCandidates(args, profile);
  244. const selected = explicitAccounts.length
  245. ? explicitAccounts
  246. : parseSelectedCandidates(candidates, args.select || args.selected || args.indexes);
  247. if (!selected.length) {
  248. throw new Error('confirm-accounts requires --confirmed-accounts or --candidates with --select');
  249. }
  250. profile.confirmedAccounts = dedupeAccounts([...(profile.confirmedAccounts || []), ...selected]);
  251. profile.accountMonitorMode = 'specific_accounts';
  252. profile.state = profileComplete(profile) ? 'readyForManualRun' : 'collectingProfile';
  253. return {
  254. changed: ['confirmedAccounts', 'accountMonitorMode', 'state'],
  255. selectedAccounts: selected,
  256. candidateAccountCount: asArray(profile.candidateAccounts).length
  257. };
  258. }
  259. function removeKeywords(existing, removals) {
  260. const removeSet = new Set(splitList(removals).map(item => item.toLowerCase()));
  261. return asArray(existing).filter(item => !removeSet.has(cleanText(item).toLowerCase()));
  262. }
  263. function removeAccounts(existing, removals) {
  264. const removeSet = new Set(splitList(removals));
  265. if (!removeSet.size) return asArray(existing);
  266. return asArray(existing).map(normalizeAccount).filter(account => account && !removeSet.has(account.sec_user_id) && !removeSet.has(account.nickname));
  267. }
  268. function applyCalibration(profile, args) {
  269. const keywordsAdd = splitList(args.keywordsAdd || args['keywords-add']);
  270. const keywordsRemove = splitList(args.keywordsRemove || args['keywords-remove']);
  271. const forbiddenAdd = splitList(args.forbiddenAdd || args['forbidden-add'] || args.forbiddenTopicsAdd || args['forbidden-topics-add']);
  272. const accountsAdd = readJsonMaybe(args.accountsAdd || args['accounts-add'])
  273. || splitList(args.accountsAdd || args['accounts-add']).map(item => ({ sec_user_id: item }));
  274. const accountsRemove = args.accountsRemove || args['accounts-remove'];
  275. if (keywordsAdd.length) profile.keywords = uniqueStrings([...(profile.keywords || []), ...keywordsAdd]);
  276. if (keywordsRemove.length) profile.keywords = removeKeywords(profile.keywords, keywordsRemove);
  277. if (forbiddenAdd.length) profile.forbiddenTopics = uniqueStrings([...(profile.forbiddenTopics || []), ...forbiddenAdd]);
  278. if (asArray(accountsAdd).length) profile.confirmedAccounts = dedupeAccounts([...(profile.confirmedAccounts || []), ...asArray(accountsAdd)]);
  279. if (accountsRemove) profile.confirmedAccounts = removeAccounts(profile.confirmedAccounts, accountsRemove);
  280. if (args.tone) profile.tone = cleanText(args.tone);
  281. const note = {
  282. at: new Date().toISOString(),
  283. reportPath: cleanText(args.report || args['report-path']),
  284. likedTopics: splitList(args.likedTopics || args['liked-topics'] || args.like),
  285. rejectedTopics: splitList(args.rejectedTopics || args['rejected-topics'] || args.dislike),
  286. forbiddenTopicsAdded: forbiddenAdd,
  287. keywordsAdded: keywordsAdd,
  288. keywordsRemoved: keywordsRemove,
  289. accountsAdded: asArray(accountsAdd).map(normalizeAccount).filter(Boolean),
  290. accountsRemoved: splitList(accountsRemove),
  291. tone: cleanText(args.tone),
  292. rawFeedback: cleanText(args.feedback)
  293. };
  294. profile.calibrationNotes = [...asArray(profile.calibrationNotes), note];
  295. profile.state = bool(args.automationReady || args['automation-ready'], false) ? 'automationReady' : 'tunedProfile';
  296. profile.automationReady = profile.state === 'automationReady';
  297. return {
  298. changed: ['calibrationNotes', 'state'],
  299. selectedAccounts: [],
  300. candidateAccountCount: asArray(profile.candidateAccounts).length
  301. };
  302. }
  303. function applyRunComplete(profile, args) {
  304. const result = readJsonMaybe(args.result || args['runner-result']) || {};
  305. const report = readJsonMaybe(args.report || args['report-json']) || {};
  306. const status = cleanText(args.status || result.status || report.status);
  307. profile.lastReportAt = cleanText(args.reportAt || args['report-at'] || report.generatedAt || new Date().toISOString());
  308. profile.lastOutputDir = cleanText(args.outputDir || args['output-dir'] || result.outputDir);
  309. profile.lastRunStatus = status || profile.lastRunStatus || '';
  310. profile.lastRunSummary = result.summary || report.summary || profile.lastRunSummary || {};
  311. if (status === 'needs_account_confirmation') profile.state = 'awaitingAccountConfirmation';
  312. else if (status === 'ok') profile.state = 'awaitingReportCalibration';
  313. else if (readyForDailyReport(profile)) profile.state = 'readyForManualRun';
  314. else profile.state = 'collectingProfile';
  315. return {
  316. changed: ['lastReportAt', 'lastOutputDir', 'lastRunStatus', 'lastRunSummary', 'state'],
  317. selectedAccounts: [],
  318. candidateAccountCount: asArray(profile.candidateAccounts).length
  319. };
  320. }
  321. function normalizeProfile(profile) {
  322. profile.platform = profile.platform || 'douyin';
  323. profile.projectName = profile.projectName || 'douyin-speaking-daily';
  324. profile.keywords = uniqueStrings(profile.keywords || []);
  325. profile.referenceAccounts = uniqueStrings(profile.referenceAccounts || []);
  326. profile.contentPillars = uniqueStrings(profile.contentPillars || []);
  327. profile.forbiddenTopics = uniqueStrings(profile.forbiddenTopics || []);
  328. profile.candidateAccounts = dedupeAccounts(profile.candidateAccounts || []);
  329. profile.confirmedAccounts = dedupeAccounts(profile.confirmedAccounts || []);
  330. profile.calibrationNotes = asArray(profile.calibrationNotes);
  331. profile.dailyOutputCount = intValue(profile.dailyOutputCount, 8) || 8;
  332. profile.readyForDailyReport = readyForDailyReport(profile);
  333. profile.updatedAt = new Date().toISOString();
  334. if (!profile.state) profile.state = profile.readyForDailyReport ? 'readyForManualRun' : 'collectingProfile';
  335. return profile;
  336. }
  337. function writeProfile(filePath, profile) {
  338. ensureDir(path.dirname(filePath));
  339. fs.writeFileSync(filePath, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
  340. }
  341. function main() {
  342. const args = parseArgs(process.argv.slice(2));
  343. if (args.help) {
  344. console.log(usage());
  345. return;
  346. }
  347. const mode = args.mode || 'summarize';
  348. const outPath = outputPath(args);
  349. let profile = loadBaseProfile(args.profile ? path.resolve(args.profile) : outPath);
  350. profile = applyInlineUpdates(profile, args);
  351. let detail = { changed: [], selectedAccounts: [], candidateAccountCount: asArray(profile.candidateAccounts).length };
  352. if (mode === 'update') {
  353. profile.state = profileComplete(profile) ? 'awaitingProfileConfirm' : 'collectingProfile';
  354. detail.changed = ['profile'];
  355. } else if (mode === 'record-candidates') {
  356. detail = applyRecordCandidates(profile, args);
  357. } else if (mode === 'confirm-accounts') {
  358. detail = applyConfirmAccounts(profile, args);
  359. } else if (mode === 'run-complete') {
  360. detail = applyRunComplete(profile, args);
  361. } else if (mode === 'calibrate') {
  362. detail = applyCalibration(profile, args);
  363. } else if (mode !== 'summarize') {
  364. throw new Error(`Unknown mode: ${mode}`);
  365. }
  366. profile = normalizeProfile(profile);
  367. if (mode !== 'summarize' || bool(args.write, false)) {
  368. writeProfile(outPath, profile);
  369. }
  370. const result = {
  371. status: 'ok',
  372. mode,
  373. profilePath: outPath,
  374. profileState: profile.state,
  375. readyForDailyReport: profile.readyForDailyReport,
  376. automationReady: profile.automationReady,
  377. changed: detail.changed,
  378. selectedAccounts: asArray(detail.selectedAccounts).map(normalizeAccount).filter(Boolean),
  379. summary: summarizeProfile(profile),
  380. profile
  381. };
  382. console.log(JSON.stringify(result, null, 2));
  383. if (args.resultPrefix || args['result-prefix']) {
  384. const prefix = args.resultPrefix || args['result-prefix'];
  385. console.log(`${prefix}=${JSON.stringify(result)}`);
  386. }
  387. }
  388. main();