| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- #!/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 <name> --industry <name> --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();
|