#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { buildReport, keywordMatrixFromProfile, parseList } = require('./industry-trend-report'); const { collectXiaohongshuDataset } = require('./xiaohongshu-trend-collector'); const DEFAULT_RESULT_PREFIX = 'INDUSTRY_TREND_RUNNER_RESULT'; 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 ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJson(filePath, data) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); } function toBool(value, fallback = false) { if (typeof value === 'boolean') return value; if (value === undefined || value === null || value === '') return fallback; return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase()); } function cleanPreferenceItems(value) { return parseList(value) .map(item => item .replace(/^(这些|这类|这种|方向是|方向:|方向:)/, '') .replace(/(这些方向|这些内容|这类方向|这类内容|这种方向|这种内容|方向|内容)$/g, '') .trim()) .filter(Boolean); } function defaultProfilePath() { return path.resolve('industry-trend-intelligence/memory-templates/industry-trend-profile.json'); } function hasInlineProfileArgs(args) { return Boolean( args.project || args.industry || args['business-type'] || args.businessType || args.keywords || args.platforms || args['target-audience'] || args.targetAudience || args['trend-questions'] || args.trendQuestions || args['blocked-directions'] || args.blockedDirections ); } function loadProfile(args) { const explicit = args.profile ? path.resolve(args.profile) : undefined; const profilePath = explicit && fs.existsSync(explicit) ? explicit : defaultProfilePath(); const profile = explicit && fs.existsSync(explicit) ? readJson(profilePath) : (hasInlineProfileArgs(args) ? {} : (fs.existsSync(profilePath) ? readJson(profilePath) : {})); if (args.project) profile.project = args.project; if (args.industry) profile.industry = args.industry; if (args['business-type'] || args.businessType) profile.businessType = args['business-type'] || args.businessType; if (args.keywords) profile.keywords = parseList(args.keywords); if (args.platforms) profile.platforms = parseList(args.platforms); if (args['target-audience'] || args.targetAudience) profile.targetAudience = parseList(args['target-audience'] || args.targetAudience); if (args['trend-questions'] || args.trendQuestions) profile.trendQuestions = parseList(args['trend-questions'] || args.trendQuestions); if (args['blocked-directions'] || args.blockedDirections) profile.blockedDirections = parseList(args['blocked-directions'] || args.blockedDirections); return { profile, profilePath }; } function inferProfileFromMessage(message, profile) { const text = String(message || ''); if (!text) return profile; const next = { ...profile }; if (text.includes('家装') || text.includes('全屋定制') || text.includes('装修')) { next.industry = next.industry || '家装全屋定制'; next.businessType = next.businessType || '门店/设计服务/全屋定制服务商'; next.platforms = next.platforms && next.platforms.length ? next.platforms : ['xiaohongshu']; next.keywords = next.keywords && next.keywords.length ? next.keywords : [ '全屋定制', '奶油风装修', '小户型收纳', '衣柜设计避坑', '厨房橱柜设计', '装修翻车' ]; next.mustTrackSignals = next.mustTrackSignals && next.mustTrackSignals.length ? next.mustTrackSignals : [ '装修风格', '设计元素', '收纳需求', '环保顾虑', '预算焦虑', '翻车风险' ]; } return next; } function updateMemoryFromMessage(message, memoryPath) { const text = String(message || '').trim(); if (!text) return undefined; const memory = fs.existsSync(memoryPath) ? readJson(memoryPath) : { preferredSignals: [], blockedDirections: [], preferredOutput: [], updatedAt: '' }; const keepMatch = text.match(/保留(.+?)(?:。|;|;|$)/); const dropMatch = text.match(/(?:不要|少给|降权)(.+?)(?:。|;|;|$)/); const outputMatch = text.match(/(?:更偏|偏)(.+?)(?:。|;|;|$)/); if (keepMatch) memory.preferredSignals = [...new Set([...(memory.preferredSignals || []), ...cleanPreferenceItems(keepMatch[1])])]; if (dropMatch) memory.blockedDirections = [...new Set([...(memory.blockedDirections || []), ...cleanPreferenceItems(dropMatch[1])])]; if (outputMatch) memory.preferredOutput = [...new Set([...(memory.preferredOutput || []), outputMatch[1].trim()])]; memory.updatedAt = new Date().toISOString(); writeJson(memoryPath, memory); return memory; } function isPreferenceMessage(message) { const text = String(message || ''); return /保留|不要|少给|降权|更偏/.test(text) && !/日报|生成|启动/.test(text); } async function main() { const args = parseArgs(process.argv.slice(2)); const outputDir = path.resolve(args.output || path.join('outputs', 'industry-trend-intelligence', new Date().toISOString().slice(0, 10))); ensureDir(outputDir); const message = args.message || ''; const memoryPath = path.resolve(args.memory || 'memory/industry-trend-memory.json'); if (isPreferenceMessage(message)) { const memory = updateMemoryFromMessage(message, memoryPath); const result = { status: 'ok', action: 'memory_updated', memoryPath, memory, assistantMessage: [ '✅ 趋势偏好已保存。', '', '后续行业趋势情报日报会优先参考你保留的方向,并降低你不想看的方向。', '', '你可以继续说“重新生成日报”或“下次更偏门店转化/设计师话术”。' ].join('\n') }; const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX; console.log(`${prefix}=${JSON.stringify(result)}`); return; } const { profile: loadedProfile, profilePath } = loadProfile(args); const profile = inferProfileFromMessage(message, loadedProfile); const keywordMatrix = args['keyword-matrix'] ? readJson(path.resolve(args['keyword-matrix'])) : keywordMatrixFromProfile(profile); const inputPath = args.input ? path.resolve(args.input) : undefined; const collectionMode = args['collection-mode'] || args.collectionMode || (toBool(args.live) ? 'live' : 'sample'); const collectorOutputPath = path.join(outputDir, 'raw-input.json'); let collectionResult; const dataset = inputPath && fs.existsSync(inputPath) ? readJson(inputPath) : (collectionResult = await collectXiaohongshuDataset({ profile, keywordMatrix, mode: collectionMode, output: collectorOutputPath, fallbackSample: !toBool(args['no-fallback-sample']), keywordLimit: args['keyword-limit'] || args.keywordLimit, searchPages: args['search-pages'] || args.searchPages, notesPerKeyword: args['notes-per-keyword'] || args.notesPerKeyword, maxCommentPages: args['max-comment-pages'] || args.maxCommentPages, delayMs: args['delay-ms'] || args.delayMs, sort: args.sort, noteType: args['note-type'] || args.noteType })).dataset; writeJson(path.join(outputDir, 'profile-used.json'), profile); writeJson(path.join(outputDir, 'keyword-matrix.json'), keywordMatrix); if (inputPath) writeJson(path.join(outputDir, 'raw-input.json'), dataset); const report = buildReport({ profile, dataset, keywordMatrix, outputDir }); const result = { status: 'ok', profilePath, outputDir, files: [ path.join(outputDir, 'profile-used.json'), path.join(outputDir, 'keyword-matrix.json'), path.join(outputDir, 'raw-input.json'), ...report.files ], summary: report.sample, oneLineJudgement: report.oneLineJudgement, assistantMessage: report.assistantMessage, markdown: report.markdown, keywordMatrix, collection: collectionResult ? { status: collectionResult.status, mode: collectionResult.mode, noteCount: collectionResult.noteCount, commentCount: collectionResult.commentCount, warningCount: (collectionResult.warnings || []).length, errorCount: (collectionResult.errors || []).length } : { status: 'provided_input', mode: 'input' }, warnings: inputPath ? [] : (collectionResult?.warnings || []), errors: collectionResult?.errors || [] }; const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX; console.log(`${prefix}=${JSON.stringify(result)}`); } if (require.main === module) { main().catch(error => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); }