| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- #!/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
- };
|