#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const DEFAULT_RESULT_PREFIX = 'INDUSTRY_TREND_REPORT_RESULT'; const SIGNAL_DICTIONARY = { style: [ '奶油风', '原木风', '中古风', '法式', '侘寂', '极简', '现代', '轻奢', '北欧', '复古', '黑白灰', '松弛感', '温柔', '高级感' ], element: [ '弧形', '一门到顶', '玻璃柜', '开放格', '灯带', '木纹', '隐形拉手', '肤感膜', '柜门', '岛台', '餐边柜', '玄关柜', '衣柜', '橱柜', '抽屉', '无主灯', '收纳', '转角', '嵌入式' ], decision: [ '预算', '环保', '甲醛', '翻车', '好打理', '耐看', '显大', '采光', '落灰', '售后', '增项', '尺寸', '动线', '收纳不够', '柜子太满', '怕过时', '质感' ], action: [ '避坑', '怎么选', '真实体验', '对比', '测评', '后悔', '建议', '清单', '案例', '改造', '装修日记' ] }; 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) { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function truncate(value, length = 72) { const text = cleanText(value); if (text.length <= length) return text; return `${text.slice(0, Math.max(0, length - 1))}…`; } function uniq(values) { return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))]; } function parseList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); const text = String(value).trim(); if (!text) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : []; } catch { return []; } } return text.split(/[,,、;\n\r]+/).map(item => item.trim()).filter(Boolean); } function normalizeCount(value) { const numeric = Number(value || 0); return Number.isFinite(numeric) ? numeric : 0; } function noteScore(note) { return normalizeCount(note.likeCount) + normalizeCount(note.collectCount) * 1.5 + normalizeCount(note.commentCount) * 3 + normalizeCount(note.shareCount) * 2; } function commentScore(comment) { return normalizeCount(comment.likeCount) * 2 + cleanText(comment.text || comment.content).length / 30; } function countSignals(records, terms) { const counts = new Map(); records.forEach(record => { const text = `${record.title || ''} ${record.content || ''} ${record.text || ''}`.toLowerCase(); terms.forEach(term => { if (text.includes(String(term).toLowerCase())) { counts.set(term, (counts.get(term) || 0) + 1); } }); }); return [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label))); } function isHomeDesignIndustry(profile) { const text = [ profile.industry, profile.businessType, ...asArray(profile.keywords), ...asArray(profile.mustTrackSignals) ].join(' '); return /家装|全屋定制|装修|衣柜|橱柜|门店|设计师/.test(text); } function countProfileSignals(records, profile) { const terms = uniq([ ...asArray(profile.mustTrackSignals), ...asArray(profile.trendQuestions).flatMap(question => String(question).split(/[、,,。??\s]+/)), ...asArray(profile.keywords).slice(0, 8) ]).filter(term => String(term).length >= 2); return countSignals(records, terms).slice(0, 10); } function buildOneLine(profile, styleSignals, elementSignals, decisionSignals, profileSignals) { if (isHomeDesignIndustry(profile)) { return '本轮最值得关注的不是单一风格,而是“风格审美 + 好打理 + 收纳/预算风险”一起进入女性客户决策。'; } const topTopic = profileSignals[0]?.label || elementSignals[0]?.label || styleSignals[0]?.label || '高互动内容主题'; const topDecision = decisionSignals[0]?.label || '用户真实顾虑'; return `本轮最值得关注的是“${topTopic} + ${topDecision} + 可验证行动机会”的组合,而不是单条爆款样本。`; } function keywordMatrixFromProfile(profile) { const baseKeywords = uniq([ ...asArray(profile.keywords), ...asArray(profile.productsOrServices).slice(0, 4), profile.industry ]).filter(Boolean); const questions = asArray(profile.trendQuestions); const matrix = baseKeywords.slice(0, 12).map((keyword, index) => ({ keyword, batch: 'P0', purpose: index === 0 ? '抓取行业基础讨论' : '追踪趋势与用户决策信号', expectedSignal: asArray(profile.mustTrackSignals).slice(0, 6).join('、') || '趋势、痛点、决策、内容机会', source: 'profile' })); questions.slice(0, 4).forEach(question => { const keyword = question.replace(/[??。,.,]/g, '').slice(0, 18); if (keyword && !matrix.some(row => row.keyword === keyword)) { matrix.push({ keyword, batch: 'P1', purpose: '回答趋势问题', expectedSignal: question, source: 'trendQuestion' }); } }); return matrix; } function buildSampleDataset(profile, keywordMatrix) { const keywords = keywordMatrix.length ? keywordMatrix.map(row => row.keyword) : parseList(profile.keywords); const seed = keywords.length ? keywords : ['全屋定制', '奶油风装修', '小户型收纳']; const notes = [ { id: 'xhs-home-001', platform: 'xiaohongshu', keyword: seed[0], title: '全屋定制最容易翻车的不是价格,是柜子做完不好住', content: '评论里很多女生说,装修前只看了奶油风效果图,真正入住后才发现开放格落灰、柜门不好打理、收纳不够。全屋定制要先想生活动线,再看风格。', author: '住进理想家', likeCount: 4280, collectCount: 1900, commentCount: 386, shareCount: 220, url: 'https://www.xiaohongshu.com/explore/sample-home-001', tags: ['全屋定制', '装修避坑', '收纳'] }, { id: 'xhs-home-002', platform: 'xiaohongshu', keyword: seed[1] || seed[0], title: '奶油风下半年还会流行吗?关键看材质和灯光', content: '奶油风不是越白越好看,真正高级的是低饱和颜色、木纹、弧形和无主灯配合。很多人担心过时,评论更关心耐看、好打理、显大。', author: '软装设计阿晴', likeCount: 6920, collectCount: 4100, commentCount: 612, shareCount: 488, url: 'https://www.xiaohongshu.com/explore/sample-home-002', tags: ['奶油风', '装修风格', '设计元素'] }, { id: 'xhs-home-003', platform: 'xiaohongshu', keyword: seed[2] || seed[0], title: '小户型收纳别再一屋子柜子,女生真正怕的是压抑', content: '一门到顶、玄关柜、餐边柜都很火,但评论区反复提到采光、显大、动线和预算。收纳不是柜子越多越好,而是高频物品要顺手。', author: '小户型研究所', likeCount: 5110, collectCount: 2800, commentCount: 455, shareCount: 306, url: 'https://www.xiaohongshu.com/explore/sample-home-003', tags: ['小户型', '收纳', '玄关柜'] }, { id: 'xhs-home-004', platform: 'xiaohongshu', keyword: seed[3] || seed[0], title: '衣柜设计避坑:肤感膜、玻璃柜、开放格到底怎么选', content: '高赞评论都在问环保、落灰、预算和售后。女性客户不是不喜欢设计感,而是怕好看但不好住、好看但难打理。', author: '定制柜设计师Lynn', likeCount: 3760, collectCount: 2100, commentCount: 334, shareCount: 180, url: 'https://www.xiaohongshu.com/explore/sample-home-004', tags: ['衣柜设计', '环保', '好打理'] } ]; const comments = [ { id: 'c001', noteId: 'xhs-home-001', keyword: notes[0].keyword, text: '我家就是开放格太多,现在每天擦灰,真的后悔。', likeCount: 92, theme: '翻车风险' }, { id: 'c002', noteId: 'xhs-home-001', keyword: notes[0].keyword, text: '全屋定制最怕增项,前期报价看不懂,后面预算一路涨。', likeCount: 118, theme: '预算顾虑' }, { id: 'c003', noteId: 'xhs-home-002', keyword: notes[1].keyword, text: '奶油风好看但怕过时,想要耐看一点的,不要太网红。', likeCount: 156, theme: '风格决策' }, { id: 'c004', noteId: 'xhs-home-002', keyword: notes[1].keyword, text: '低饱和颜色加木纹真的比纯白高级,灯光也很重要。', likeCount: 84, theme: '设计元素' }, { id: 'c005', noteId: 'xhs-home-003', keyword: notes[2].keyword, text: '小户型柜子做满会很压抑,还是要留一点呼吸感。', likeCount: 121, theme: '空间体验' }, { id: 'c006', noteId: 'xhs-home-003', keyword: notes[2].keyword, text: '我最关心玄关能不能放下鞋子、包、快递和雨伞。', likeCount: 77, theme: '使用场景' }, { id: 'c007', noteId: 'xhs-home-004', keyword: notes[3].keyword, text: '肤感膜到底好不好打理?有小孩家庭是不是很容易留印子?', likeCount: 103, theme: '材质顾虑' }, { id: 'c008', noteId: 'xhs-home-004', keyword: notes[3].keyword, text: '环保真的要讲清楚,不然再好看也不敢下单。', likeCount: 141, theme: '信任门槛' } ]; return { metadata: { platform: 'xiaohongshu', dataNature: 'P0 sample dataset', generatedAt: new Date().toISOString() }, notes, comments }; } function normalizeDataset(input) { if (!input) return { notes: [], comments: [] }; if (Array.isArray(input)) return { notes: input, comments: [] }; return { metadata: input.metadata || {}, notes: asArray(input.notes || input.items), comments: asArray(input.comments || input.commentsFlat) }; } function buildTrendHypotheses(profile, styleSignals, elementSignals, decisionSignals, profileSignals = []) { const industry = profile.industry || '当前行业'; const audience = asArray(profile.targetAudience)[0] || '目标客户'; const topStyle = styleSignals[0]?.label || '高互动风格'; const topElement = elementSignals[0]?.label || profileSignals[0]?.label || '高频内容元素'; const topDecision = decisionSignals[0]?.label || '关键决策顾虑'; if (!isHomeDesignIndustry(profile)) { return [ { title: `${topElement} 可以作为下周期内容和产品沟通的切入点`, confidence: elementSignals[0] || profileSignals[0] ? '中' : '低', action: `把 ${topElement} 拆成“用户为什么关心、现在怎么判断、下一步怎么验证”三个回答。` }, { title: `${audience} 的核心阻力不是没有兴趣,而是担心 ${topDecision}`, confidence: decisionSignals[0] ? '中' : '低', action: `内容和销售话术要先回应 ${topDecision},再展示 ${industry} 的方案优势。` }, { title: `高互动样本更适合沉淀为“趋势假设”,不要直接照搬结论`, confidence: '中', action: '把高互动样本拆成主题、证据、评论原声和可验证动作,再决定是否进入下轮监听。' } ]; } return [ { title: `${topStyle} 仍有热度,但用户会从“好看”追问到“耐看和好打理”`, confidence: styleSignals[0] ? '中' : '低', action: `整理 3 套 ${topStyle} 的真实案例,重点讲清颜色、材质、灯光和维护成本。` }, { title: `${topElement} 可以作为下周期内容和门店讲解的切入点`, confidence: elementSignals[0] ? '中' : '低', action: `把 ${topElement} 拆成“适合什么户型、不适合什么家庭、预算影响”三个回答。` }, { title: `${audience} 的核心阻力不是不喜欢设计,而是担心 ${topDecision}`, confidence: decisionSignals[0] ? '中' : '低', action: `销售和设计师话术要先回应 ${topDecision},再展示 ${industry} 的方案优势。` } ]; } function buildSuggestedActions(profile) { if (isHomeDesignIndustry(profile)) { return [ '门店:把高频顾虑整理成“预算、环保、好打理、收纳”四张解释卡。', '设计师:讲方案时先回应翻车风险,再展示风格效果图。', '内容:优先拍“真实案例 + 避坑 + 选择标准”,少发无评论支撑的纯美图。' ]; } return [ '内容:优先选择“真实案例 + 用户顾虑 + 判断标准”的选题,不要只搬运高互动标题。', '产品/服务:把高频评论里的疑问整理成可验证假设,下一轮用样本继续确认。', '销售/转化:先回应用户最担心的阻力,再给方案、案例或对比证据。' ]; } function buildCalibrationQuestions(profile) { if (isHomeDesignIndustry(profile)) { return [ '今天这些风格/元素里,哪 3 个最值得继续跟踪?', '哪些方向明显不适合你的客户或门店定位?', '下一版更偏设计趋势、客户决策、门店转化,还是小红书内容选题?', '是否要新增关注区域、户型、预算带或竞品品牌?' ]; } return [ '今天这些趋势信号里,哪 3 个最值得继续跟踪?', '哪些方向明显不适合你的客户、产品或品牌定位?', '下一版更偏趋势判断、用户洞察、内容选题、销售转化,还是竞品观察?', '是否要新增关注平台、关键词、人群、价格带或竞品品牌?' ]; } function markdownTable(rows, headers) { const safeRows = rows.length ? rows : [headers.map(() => '暂无')]; return [ `| ${headers.join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...safeRows.map(row => `| ${row.map(cell => String(cell ?? '').replace(/\|/g, '/')).join(' | ')} |`) ].join('\n'); } function buildReport({ profile, dataset, keywordMatrix, outputDir }) { const normalized = normalizeDataset(dataset); const notes = normalized.notes; const comments = normalized.comments; const allRecords = [...notes, ...comments]; const styleSignals = countSignals(allRecords, SIGNAL_DICTIONARY.style); const elementSignals = countSignals(allRecords, SIGNAL_DICTIONARY.element); const decisionSignals = countSignals(allRecords, SIGNAL_DICTIONARY.decision); const actionSignals = countSignals(allRecords, SIGNAL_DICTIONARY.action); const profileSignals = countProfileSignals(allRecords, profile); const highValueNotes = [...notes].sort((a, b) => noteScore(b) - noteScore(a)).slice(0, 5); const highValueComments = [...comments].sort((a, b) => commentScore(b) - commentScore(a)).slice(0, 8); const trendHypotheses = buildTrendHypotheses(profile, styleSignals, elementSignals, decisionSignals, profileSignals); const suggestedActions = buildSuggestedActions(profile); const calibrationQuestions = buildCalibrationQuestions(profile); const keywordRows = keywordMatrix.slice(0, 8).map(row => [ row.keyword, row.purpose || '趋势监听', row.expectedSignal || '趋势/痛点/决策信号' ]); const sampleLine = `${notes.length} 篇笔记 / ${comments.length} 条评论 / ${uniq(notes.map(note => note.keyword)).length || keywordMatrix.length} 个关键词`; const oneLine = buildOneLine(profile, styleSignals, elementSignals, decisionSignals, profileSignals); const report = { status: 'ok', project: profile.project || 'industry-trend-intelligence', industry: profile.industry || '未填写行业', generatedAt: new Date().toISOString(), sample: { noteCount: notes.length, commentCount: comments.length, keywordCount: uniq(notes.map(note => note.keyword)).length || keywordMatrix.length }, oneLineJudgement: oneLine, signals: { style: styleSignals.slice(0, 8), element: elementSignals.slice(0, 8), decision: decisionSignals.slice(0, 8), action: actionSignals.slice(0, 8), profile: profileSignals.slice(0, 8) }, highValueNotes, highValueComments, trendHypotheses, keywordMatrix }; const signalLines = [ profileSignals.length ? `- 重点信号:${profileSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、')}` : '', `- 风格信号:${styleSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显风格词'}`, `- 内容/元素:${elementSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显元素词'}`, `- 决策顾虑:${decisionSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显顾虑词'}` ].filter(Boolean); const assistantMessage = [ `# 行业趋势情报日报`, '', `行业:${report.industry}`, `样本:${sampleLine}`, '', `## 一句话判断`, '', oneLine, '', `## 高热趋势信号`, '', ...signalLines, '', `## 高价值样本`, '', ...highValueNotes.slice(0, 3).map((note, index) => { return `${index + 1}. ${truncate(note.title, 42)} / ${note.author || '未知作者'} / 点赞 ${normalizeCount(note.likeCount)} / 评论 ${normalizeCount(note.commentCount)} / 收藏 ${normalizeCount(note.collectCount)}`; }), '', `## 下周期可验证趋势假设`, '', ...trendHypotheses.map((item, index) => `${index + 1}. ${item.title}(置信度:${item.confidence})`), '', `## 建议动作`, '', ...suggestedActions.map(item => `- ${item}`), '', `## 校准问题`, '', ...calibrationQuestions.map((item, index) => `${index + 1}. ${item}`) ].join('\n'); const fullMarkdown = [ assistantMessage, '', `## 关键词矩阵`, '', markdownTable(keywordRows, ['关键词', '采集目的', '预期信号']), '', `## 高赞评论原声`, '', ...highValueComments.slice(0, 6).map(comment => `- ${comment.text || comment.content}(赞 ${normalizeCount(comment.likeCount)} / ${comment.theme || '未标注'})`) ].join('\n'); if (outputDir) { ensureDir(outputDir); fs.writeFileSync(path.join(outputDir, 'trend-report.md'), fullMarkdown, 'utf8'); writeJson(path.join(outputDir, 'trend-report.json'), report); } return { ...report, assistantMessage, markdown: fullMarkdown, files: outputDir ? [ path.join(outputDir, 'trend-report.md'), path.join(outputDir, 'trend-report.json') ] : [] }; } function main() { const args = parseArgs(process.argv.slice(2)); const profile = args.profile ? readJson(path.resolve(args.profile)) : {}; const input = args.input ? readJson(path.resolve(args.input)) : undefined; const keywordMatrix = args['keyword-matrix'] ? readJson(path.resolve(args['keyword-matrix'])) : keywordMatrixFromProfile(profile); const outputDir = args.output ? path.resolve(args.output) : undefined; const dataset = input || buildSampleDataset(profile, keywordMatrix); const result = buildReport({ profile, dataset, keywordMatrix, outputDir }); const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX; console.log(`${prefix}=${JSON.stringify(result)}`); } if (require.main === module) { try { main(); } catch (error) { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); } } module.exports = { buildReport, buildSampleDataset, keywordMatrixFromProfile, parseList };