#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const TEMPLATE_PATH = path.resolve('douyin-speaking-daily', 'memory-templates', 'douyin-speaking-profile.json'); const DEFAULT_PROFILE_PATH = path.resolve('memory', 'douyin-speaking-profile.json'); function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/douyin-speaking-profile-memory.js --mode update --output memory/douyin-speaking-profile.json --project --industry --keywords "kw1,kw2"', ' node scripts/tools/douyin-speaking-profile-memory.js --mode record-candidates --profile memory/douyin-speaking-profile.json --candidates outputs/.../account-candidates.json', ' 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', ' 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"', '', 'Modes: update | record-candidates | confirm-accounts | run-complete | calibrate | summarize' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function readJsonMaybe(value) { if (!value) return undefined; const text = String(value).trim(); if (!text) return undefined; const absolute = path.resolve(text); if (fs.existsSync(absolute)) return readJson(absolute); try { return JSON.parse(text); } catch { return undefined; } } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(cleanText).filter(Boolean); const text = cleanText(value); if (!text || /^\{\{[^}]+\}\}$/.test(text)) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(cleanText).filter(Boolean) : []; } catch { return []; } } return text.split(/[,,、\n]/).map(cleanText).filter(Boolean); } function uniqueStrings(values) { return [...new Set(asArray(values).map(cleanText).filter(Boolean))]; } function bool(value, fallback = false) { if (value === undefined || value === null || value === '') return fallback; if (typeof value === 'boolean') return value; return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase()); } function intValue(value, fallback) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, Math.round(number)) : fallback; } function loadBaseProfile(profilePath) { if (profilePath && fs.existsSync(profilePath)) return readJson(profilePath); if (fs.existsSync(DEFAULT_PROFILE_PATH)) return readJson(DEFAULT_PROFILE_PATH); if (fs.existsSync(TEMPLATE_PATH)) return readJson(TEMPLATE_PATH); return { state: 'emptyProfile', platform: 'douyin', keywords: [], referenceAccounts: [], candidateAccounts: [], confirmedAccounts: [], forbiddenTopics: [], contentPillars: [], calibrationNotes: [], dailyOutputCount: 8, automationReady: false }; } function outputPath(args) { return path.resolve(args.output || args.profile || DEFAULT_PROFILE_PATH); } function accountId(account) { if (!account) return ''; if (typeof account === 'string') return cleanText(account); return cleanText(account.sec_user_id || account.sec_uid || account.user_id || account.id); } function normalizeAccount(account, extra = {}) { if (!account) return undefined; if (typeof account === 'string') { const id = cleanText(account); if (!id) return undefined; return { sec_user_id: id, nickname: '', source: extra.source || '' }; } const id = accountId(account); if (!id) return undefined; return { sec_user_id: id, nickname: cleanText(account.nickname || account.nick_name || account.name), follower_count: intValue(account.follower_count ?? account.fans_cnt ?? account.followerCount, 0), like_count: intValue(account.like_count ?? account.likeCount, 0), aweme_count: intValue(account.aweme_count ?? account.publish_cnt ?? account.awemeCount, 0), signature: cleanText(account.signature), source: cleanText(extra.source || account.source || account.sourceInput) }; } function dedupeAccounts(accounts) { const map = new Map(); asArray(accounts).map(normalizeAccount).filter(Boolean).forEach(account => { const key = account.sec_user_id; map.set(key, { ...(map.get(key) || {}), ...account }); }); return [...map.values()]; } function parseSelectedCandidates(candidates, select) { const list = asArray(candidates).map(normalizeAccount).filter(Boolean); const tokens = splitList(select); if (!tokens.length) return []; if (tokens.some(token => token.toLowerCase() === 'all')) return list; const selected = []; tokens.forEach(token => { const index = Number(token); if (Number.isInteger(index) && index >= 1 && index <= list.length) { selected.push(list[index - 1]); return; } const matched = list.find(account => account.sec_user_id === token || account.nickname === token); if (matched) selected.push(matched); }); return dedupeAccounts(selected); } function requiredProfileFields(profile) { return [ { field: 'industry', ok: Boolean(cleanText(profile.industry)) }, { field: 'accountPositioning', ok: Boolean(cleanText(profile.accountPositioning)) }, { field: 'targetAudience', ok: Boolean(cleanText(profile.targetAudience)) }, { field: 'conversionGoal', ok: Boolean(cleanText(profile.conversionGoal)) }, { field: 'keywords', ok: asArray(profile.keywords).length > 0 } ]; } function profileComplete(profile) { return requiredProfileFields(profile).every(item => item.ok); } function readyForDailyReport(profile) { return profileComplete(profile) && ( asArray(profile.confirmedAccounts).length > 0 || asArray(profile.referenceAccounts).length > 0 || Boolean(cleanText(profile.accountDiscoveryDirection)) ); } function summarizeProfile(profile) { const missingFields = requiredProfileFields(profile).filter(item => !item.ok).map(item => item.field); return { state: profile.state || 'emptyProfile', readyForDailyReport: readyForDailyReport(profile), automationReady: Boolean(profile.automationReady), missingFields, keywordCount: asArray(profile.keywords).length, candidateAccountCount: asArray(profile.candidateAccounts).length, confirmedAccountCount: asArray(profile.confirmedAccounts).length, calibrationNoteCount: asArray(profile.calibrationNotes).length, lastReportAt: profile.lastReportAt || '' }; } function applyInlineUpdates(profile, args) { const scalarFields = [ ['projectName', args.project || args.projectName || args['project-name']], ['industry', args.industry], ['accountPositioning', args.accountPositioning || args['account-positioning'] || args.positioning], ['targetAudience', args.targetAudience || args['target-audience'] || args.audience], ['conversionGoal', args.conversionGoal || args['conversion-goal'] || args.goal], ['coreOffer', args.coreOffer || args['core-offer'] || args.offer], ['accountMonitorMode', args.accountMonitorMode || args['account-monitor-mode']], ['accountDiscoveryDirection', args.accountDiscoveryDirection || args['account-discovery-direction']], ['tone', args.tone] ]; scalarFields.forEach(([field, value]) => { if (value !== undefined && value !== null && value !== '') profile[field] = value; }); const listFields = [ ['contentPillars', args.contentPillars || args['content-pillars'] || args.pillars], ['keywords', args.keywords], ['referenceAccounts', args.referenceAccounts || args['reference-accounts'] || args.accounts], ['forbiddenTopics', args.forbiddenTopics || args['forbidden-topics'] || args.forbidden] ]; listFields.forEach(([field, value]) => { const items = splitList(value); if (items.length) profile[field] = uniqueStrings([...(profile[field] || []), ...items]); }); const dailyOutputCount = intValue(args.dailyOutputCount || args['daily-output-count'] || args.count, undefined); if (dailyOutputCount !== undefined) profile.dailyOutputCount = dailyOutputCount; return profile; } function loadCandidates(args, profile) { const fromArgs = readJsonMaybe(args.candidates || args.candidateAccounts || args['candidate-accounts']); if (fromArgs) return asArray(fromArgs); return asArray(profile.candidateAccounts); } function applyRecordCandidates(profile, args) { const candidates = loadCandidates(args, profile).map(normalizeAccount).filter(Boolean); profile.candidateAccounts = dedupeAccounts(candidates); if (profile.candidateAccounts.length && !asArray(profile.confirmedAccounts).length) { profile.state = 'awaitingAccountConfirmation'; } return { changed: ['candidateAccounts', 'state'], selectedAccounts: [], candidateAccountCount: profile.candidateAccounts.length }; } function applyConfirmAccounts(profile, args) { const explicit = readJsonMaybe(args.confirmedAccounts || args['confirmed-accounts']); const explicitAccounts = explicit ? asArray(explicit).map(normalizeAccount).filter(Boolean) : []; const candidates = loadCandidates(args, profile); const selected = explicitAccounts.length ? explicitAccounts : parseSelectedCandidates(candidates, args.select || args.selected || args.indexes); if (!selected.length) { throw new Error('confirm-accounts requires --confirmed-accounts or --candidates with --select'); } profile.confirmedAccounts = dedupeAccounts([...(profile.confirmedAccounts || []), ...selected]); profile.accountMonitorMode = 'specific_accounts'; profile.state = profileComplete(profile) ? 'readyForManualRun' : 'collectingProfile'; return { changed: ['confirmedAccounts', 'accountMonitorMode', 'state'], selectedAccounts: selected, candidateAccountCount: asArray(profile.candidateAccounts).length }; } function removeKeywords(existing, removals) { const removeSet = new Set(splitList(removals).map(item => item.toLowerCase())); return asArray(existing).filter(item => !removeSet.has(cleanText(item).toLowerCase())); } function removeAccounts(existing, removals) { const removeSet = new Set(splitList(removals)); if (!removeSet.size) return asArray(existing); return asArray(existing).map(normalizeAccount).filter(account => account && !removeSet.has(account.sec_user_id) && !removeSet.has(account.nickname)); } function applyCalibration(profile, args) { const keywordsAdd = splitList(args.keywordsAdd || args['keywords-add']); const keywordsRemove = splitList(args.keywordsRemove || args['keywords-remove']); const forbiddenAdd = splitList(args.forbiddenAdd || args['forbidden-add'] || args.forbiddenTopicsAdd || args['forbidden-topics-add']); const accountsAdd = readJsonMaybe(args.accountsAdd || args['accounts-add']) || splitList(args.accountsAdd || args['accounts-add']).map(item => ({ sec_user_id: item })); const accountsRemove = args.accountsRemove || args['accounts-remove']; if (keywordsAdd.length) profile.keywords = uniqueStrings([...(profile.keywords || []), ...keywordsAdd]); if (keywordsRemove.length) profile.keywords = removeKeywords(profile.keywords, keywordsRemove); if (forbiddenAdd.length) profile.forbiddenTopics = uniqueStrings([...(profile.forbiddenTopics || []), ...forbiddenAdd]); if (asArray(accountsAdd).length) profile.confirmedAccounts = dedupeAccounts([...(profile.confirmedAccounts || []), ...asArray(accountsAdd)]); if (accountsRemove) profile.confirmedAccounts = removeAccounts(profile.confirmedAccounts, accountsRemove); if (args.tone) profile.tone = cleanText(args.tone); const note = { at: new Date().toISOString(), reportPath: cleanText(args.report || args['report-path']), likedTopics: splitList(args.likedTopics || args['liked-topics'] || args.like), rejectedTopics: splitList(args.rejectedTopics || args['rejected-topics'] || args.dislike), forbiddenTopicsAdded: forbiddenAdd, keywordsAdded: keywordsAdd, keywordsRemoved: keywordsRemove, accountsAdded: asArray(accountsAdd).map(normalizeAccount).filter(Boolean), accountsRemoved: splitList(accountsRemove), tone: cleanText(args.tone), rawFeedback: cleanText(args.feedback) }; profile.calibrationNotes = [...asArray(profile.calibrationNotes), note]; profile.state = bool(args.automationReady || args['automation-ready'], false) ? 'automationReady' : 'tunedProfile'; profile.automationReady = profile.state === 'automationReady'; return { changed: ['calibrationNotes', 'state'], selectedAccounts: [], candidateAccountCount: asArray(profile.candidateAccounts).length }; } function applyRunComplete(profile, args) { const result = readJsonMaybe(args.result || args['runner-result']) || {}; const report = readJsonMaybe(args.report || args['report-json']) || {}; const status = cleanText(args.status || result.status || report.status); profile.lastReportAt = cleanText(args.reportAt || args['report-at'] || report.generatedAt || new Date().toISOString()); profile.lastOutputDir = cleanText(args.outputDir || args['output-dir'] || result.outputDir); profile.lastRunStatus = status || profile.lastRunStatus || ''; profile.lastRunSummary = result.summary || report.summary || profile.lastRunSummary || {}; if (status === 'needs_account_confirmation') profile.state = 'awaitingAccountConfirmation'; else if (status === 'ok') profile.state = 'awaitingReportCalibration'; else if (readyForDailyReport(profile)) profile.state = 'readyForManualRun'; else profile.state = 'collectingProfile'; return { changed: ['lastReportAt', 'lastOutputDir', 'lastRunStatus', 'lastRunSummary', 'state'], selectedAccounts: [], candidateAccountCount: asArray(profile.candidateAccounts).length }; } function normalizeProfile(profile) { profile.platform = profile.platform || 'douyin'; profile.projectName = profile.projectName || 'douyin-speaking-daily'; profile.keywords = uniqueStrings(profile.keywords || []); profile.referenceAccounts = uniqueStrings(profile.referenceAccounts || []); profile.contentPillars = uniqueStrings(profile.contentPillars || []); profile.forbiddenTopics = uniqueStrings(profile.forbiddenTopics || []); profile.candidateAccounts = dedupeAccounts(profile.candidateAccounts || []); profile.confirmedAccounts = dedupeAccounts(profile.confirmedAccounts || []); profile.calibrationNotes = asArray(profile.calibrationNotes); profile.dailyOutputCount = intValue(profile.dailyOutputCount, 8) || 8; profile.readyForDailyReport = readyForDailyReport(profile); profile.updatedAt = new Date().toISOString(); if (!profile.state) profile.state = profile.readyForDailyReport ? 'readyForManualRun' : 'collectingProfile'; return profile; } function writeProfile(filePath, profile) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, `${JSON.stringify(profile, null, 2)}\n`, 'utf8'); } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const mode = args.mode || 'summarize'; const outPath = outputPath(args); let profile = loadBaseProfile(args.profile ? path.resolve(args.profile) : outPath); profile = applyInlineUpdates(profile, args); let detail = { changed: [], selectedAccounts: [], candidateAccountCount: asArray(profile.candidateAccounts).length }; if (mode === 'update') { profile.state = profileComplete(profile) ? 'awaitingProfileConfirm' : 'collectingProfile'; detail.changed = ['profile']; } else if (mode === 'record-candidates') { detail = applyRecordCandidates(profile, args); } else if (mode === 'confirm-accounts') { detail = applyConfirmAccounts(profile, args); } else if (mode === 'run-complete') { detail = applyRunComplete(profile, args); } else if (mode === 'calibrate') { detail = applyCalibration(profile, args); } else if (mode !== 'summarize') { throw new Error(`Unknown mode: ${mode}`); } profile = normalizeProfile(profile); if (mode !== 'summarize' || bool(args.write, false)) { writeProfile(outPath, profile); } const result = { status: 'ok', mode, profilePath: outPath, profileState: profile.state, readyForDailyReport: profile.readyForDailyReport, automationReady: profile.automationReady, changed: detail.changed, selectedAccounts: asArray(detail.selectedAccounts).map(normalizeAccount).filter(Boolean), summary: summarizeProfile(profile), profile }; console.log(JSON.stringify(result, null, 2)); if (args.resultPrefix || args['result-prefix']) { const prefix = args.resultPrefix || args['result-prefix']; console.log(`${prefix}=${JSON.stringify(result)}`); } } main();