const fs = require('fs'); const path = require('path'); const HYPOTHESIS_LABELS = { H1: '品类机会', H2: '用户需求', H3: '产品缺口', H4: '价格认知', H5: '渠道内容', H6: '竞品策略', H7: '定位表达', H8: '增长行动' }; const PLATFORM_NAMES = { xiaohongshu: '小红书', xhs: '小红书', douyin: '抖音', amazon: 'Amazon', tiktok: 'TikTok', instagram: 'Instagram' }; const PLATFORM_PURPOSES = { xiaohongshu: ['获客', '建信任', '转化', '复购'], xhs: ['获客', '建信任', '转化', '复购'], douyin: ['曝光', '互动', '转化', '复购'], amazon: ['转化', '留评', 'Listing 优化', '复购'], tiktok: ['曝光', '种草', '互动', '转化'], instagram: ['品牌感知', '种草', '互动', '转化'] }; const THEME_EXPLANATIONS = { 痛点: '用户正在规避闲置、无效、焦虑或使用失败风险,应优先转成产品改进和 FAQ。', 场景: '需求由具体生活时刻触发,适合拆成场景化内容、组合装或使用说明。', 决策: '用户需要明确选择标准,应把专业参数翻译成能下单的判断表。', 反馈: '使用反馈能暴露观察周期、体感边界和售后解释需求。', 信任: '信任门槛来自适用边界、合规表达和不夸大的风险提示。', 价格: '价格接受度与规格、试用成本、复购压力和效果确定性绑定。', 竞品: '竞品/替代方案能暴露现有方案的赢点、输点和差异化切口。', 话术: '用户原话可转成标题、详情页模块、客服脚本和内容 AB 测试。' }; 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/platform-mini-report-generator.js --input --output [--project ] [--category ] [--platform xiaohongshu|douyin|amazon|tiktok|instagram] [--data-nature ] [--owner ] [--date YYYY-MM-DD]', '', 'Outputs:', ' platform-mini-report.md', ' platform-mini-report.json' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function readJsonl(filePath) { return fs.readFileSync(filePath, 'utf8') .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map(line => JSON.parse(line)); } function asArray(value) { if (!value) return []; if (Array.isArray(value)) return value; return [value]; } function uniq(values) { return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))]; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function truncate(value, length = 72) { const text = cleanText(value); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } function resolveInput(inputPath) { const absolute = path.resolve(inputPath); const stat = fs.statSync(absolute); if (stat.isDirectory()) { const merged = path.join(absolute, '_merged.json'); const flat = path.join(absolute, 'comments-flat.jsonl'); if (fs.existsSync(merged)) return { type: 'merged', filePath: merged, data: readJson(merged) }; if (fs.existsSync(flat)) return { type: 'jsonl', filePath: flat, data: readJsonl(flat) }; throw new Error(`no _merged.json or comments-flat.jsonl found in ${absolute}`); } if (absolute.endsWith('.jsonl')) return { type: 'jsonl', filePath: absolute, data: readJsonl(absolute) }; return { type: 'merged', filePath: absolute, data: readJson(absolute) }; } function normalizeInput(resolved) { if (resolved.type === 'jsonl') { return { metadata: {}, items: [], comments: resolved.data, sources: [{ file: path.basename(resolved.filePath), platform: 'jsonl', commentCount: resolved.data.length }] }; } const data = resolved.data; if (Array.isArray(data)) return { metadata: {}, items: [], comments: data, sources: [] }; return { metadata: data.metadata || {}, items: asArray(data.items), comments: asArray(data.comments), sources: asArray(data.sources) }; } function countBy(records, getter) { const counts = new Map(); records.forEach(record => { const key = getter(record) || '未标注'; counts.set(key, (counts.get(key) || 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 scoreEvidence(record) { return Number(record.likeCount || 0) + Number(record.replyCount || 0) * 2 + cleanText(record.text || record.content).length / 80; } function platformKey(value) { const raw = String(value || '').toLowerCase(); if (raw.includes('xiaohongshu') || raw === 'xhs' || raw.includes('小红书')) return 'xiaohongshu'; if (raw.includes('douyin') || raw.includes('抖音')) return 'douyin'; if (raw.includes('amazon')) return 'amazon'; if (raw.includes('tiktok')) return 'tiktok'; if (raw.includes('instagram')) return 'instagram'; return raw || 'unknown'; } function displayPlatform(value) { const key = platformKey(value); return PLATFORM_NAMES[key] || value || '未标注平台'; } function evidenceCard(record) { return { id: record.id || record.commentId, source: `${displayPlatform(record.platform)} / ${record.keyword || '未标注关键词'} / ${record.batch || 'P0'} / ${record.commentId || record.id || 'no-id'}`, platform: record.platform, keyword: record.keyword, batch: record.batch || 'P0', text: cleanText(record.text || record.content), hypothesisTags: asArray(record.hypothesisTags), theme: record.theme || '未标注', likeCount: Number(record.likeCount || 0), replyCount: Number(record.replyCount || 0), url: record.url, parentId: record.parentId, commentId: record.commentId || record.id }; } function selectEvidence(records, limit = 6) { const seen = new Set(); return [...records] .filter(record => cleanText(record.text || record.content)) .sort((a, b) => scoreEvidence(b) - scoreEvidence(a)) .filter(record => { const key = cleanText(record.text || record.content).slice(0, 80); if (seen.has(key)) return false; seen.add(key); return true; }) .slice(0, limit) .map(evidenceCard); } function highInteractionCount(items) { return items.filter(item => Number(item.likeCount || 0) > 500 || Number(item.commentCount || 0) > 100 || Number(item.raw?.metrics?.collectCount || 0) > 300).length; } function buildStats(normalized, comments) { const items = normalized.items; const keywords = uniq([...items.map(item => item.keyword), ...comments.map(comment => comment.keyword)]); const platforms = uniq([...items.map(item => item.platform), ...comments.map(comment => comment.platform)]); return { platformCount: platforms.length, keywordCount: keywords.length, itemCount: items.length, commentCount: comments.length, highInteractionItemCount: highInteractionCount(items), keywords, platforms, hypothesisTags: uniq(comments.flatMap(comment => asArray(comment.hypothesisTags))), batches: uniq(comments.map(comment => comment.batch)) }; } function buildHypothesisCoverage(comments) { return Object.keys(HYPOTHESIS_LABELS).map(tag => { const records = comments.filter(comment => asArray(comment.hypothesisTags).includes(tag)); return { tag, label: HYPOTHESIS_LABELS[tag], count: records.length, status: records.length >= 3 ? '已覆盖' : records.length > 0 ? '弱覆盖' : '未覆盖', usage: inferHypothesisUsage(tag) }; }); } function inferHypothesisUsage(tag) { const usage = { H1: '用于判断品类机会、进入动机和核心阻力', H2: '用于提炼用户痛点、场景和真实需求', H3: '用于定位产品缺口、体验问题和详情页改进', H4: '用于判断价格、规格、周期和价值感', H5: '用于识别平台内容形式、种草路径和渠道触点', H6: '用于分析竞品替代、对比和差异化空间', H7: '用于抽取定位表达、标题和话术偏好', H8: '用于输出下一轮行动、验证指标和优先级' }; return usage[tag] || '用于支撑报告判断'; } function buildThemeClusters(comments) { const total = Math.max(comments.length, 1); return countBy(comments, comment => comment.theme).slice(0, 8).map(item => { const records = comments.filter(comment => (comment.theme || '未标注') === item.label); const evidence = selectEvidence(records, 1)[0]; return { theme: item.label, count: item.count, ratio: `${Math.round(item.count / total * 100)}%`, quote: evidence ? evidence.text : '', source: evidence ? evidence.source : '', explanation: THEME_EXPLANATIONS[item.label] || '该主题需要人工结合原声继续解释。' }; }); } function evidenceStrength(comments, coverage) { const covered = coverage.filter(item => item.status === '已覆盖').length; if (comments.length >= 100 && covered >= 8) return '高'; if (comments.length >= 30 && covered >= 4) return '中'; return '低'; } function buildConclusion(context, themes, comments, coverage) { if (!comments.length) return '当前没有可引用 VOC,不能生成核心结论。'; const topThemes = themes.slice(0, 3).map(item => `${item.theme}(${item.count})`).join('、'); const covered = coverage.filter(item => item.status !== '未覆盖').map(item => item.tag).join(', '); return `${context.platformName} ${comments.length} 条可追溯 VOC 显示,${context.category || '该品类'} 当前最稳定的用户信号集中在 ${topThemes || '未标注主题'};这些证据覆盖 ${covered || '暂无 H1-H8'},适合先形成单平台阶段性判断,再进入下一轮采集或报告定稿。`; } function buildOpportunities(themes, evidenceCards) { const selected = themes.slice(0, 5); return selected.map((theme, index) => { const evidence = evidenceCards.find(card => card.theme === theme.theme) || {}; return { opportunity: `${theme.theme}机会`, evidence: evidence.text ? truncate(evidence.text, 42) : truncate(theme.quote, 42), action: opportunityAction(theme.theme), priority: index < 3 ? 'P0' : 'P1' }; }); } function opportunityAction(theme) { const actions = { 痛点: '把高频痛点转成详情页 FAQ、客服脚本和反向避坑内容。', 场景: '围绕高触发场景设计内容专题、组合装或使用指南。', 决策: '输出选择标准表,把专业参数翻译成用户能判断的语言。', 反馈: '补充观察周期、适应期和售后解释,降低误解与退货。', 信任: '强化适用/不适用人群、风险边界和合规表达。', 价格: '设计试用装、月装和每天成本解释,降低首次购买压力。', 竞品: '提炼竞品漏洞,形成差异化卖点或替代理由。', 话术: '把用户原话改写成标题、开头和内容 AB 测试。' }; return actions[theme] || '把该主题对应原声转成一个可验证动作。'; } function buildContentRecommendations(context, themes, evidenceCards) { const purposes = PLATFORM_PURPOSES[context.platformKey] || ['获客', '建信任', '转化', '复购']; return themes.slice(0, 5).map((theme, index) => { const evidence = evidenceCards.find(card => card.theme === theme.theme) || {}; return { angle: theme.theme, title: titleForTheme(context.category, theme.theme, context.platformName), source: evidence.source || theme.source || '待关联证据', purpose: purposes[index % purposes.length] }; }); } function titleForTheme(category, theme, platformName) { const subject = category || '这个产品'; const titles = { 痛点: `《${subject}到底解决什么问题?先看这些真实顾虑》`, 场景: `《什么时候才需要${subject}?把场景讲清楚》`, 决策: `《选${subject}别只看参数:先看这几条标准》`, 反馈: `《用${subject}多久观察一次?真实反馈怎么判断》`, 信任: `《${subject}适合谁、不适合谁,一次说清楚》`, 价格: `《先买试用装还是月装?${subject}新手这样选》`, 竞品: `《${subject}和常见替代方案怎么选?真实对比清单》`, 话术: `《把${subject}参数翻译成生活语言:${platformName}用户更爱看这个》` }; return titles[theme] || `《${subject}用户最关心的 ${theme} 问题》`; } function buildRisks(context, comments) { const synthetic = String(context.dataNature || '').includes('样例') || String(context.dataNature || '').includes('合成'); return { dataBoundary: synthetic ? `本报告使用${context.dataNature},不能代表真实市场规模、真实互动分布或真实品牌口碑。` : `本报告仅代表当前输入数据和采集窗口,不能直接外推为全平台或全市场结论。`, noiseSource: `${context.platformName} 内容可能包含商业合作、测评号、重复评论、算法推荐和主观体验偏差。`, cannotConclude: '不能把用户原声直接写成未经验证的功效、医学、安全或全市场结论。', nextValidation: comments.length < 100 ? '建议补足更多关键词和评论样本,再进行跨平台交叉验证。' : '建议用竞品词、差评词和成交评价做交叉验证。' }; } function buildActions(context, opportunities) { return opportunities.slice(0, 4).map((item, index) => ({ action: item.action, owner: ['产品经理', '内容运营', '电商/客服', '项目负责人'][index] || '项目负责人', deadline: ['1 周', '1 周', '2 周', '2 周'][index] || '2 周', metric: actionMetric(item.opportunity) })); } function actionMetric(opportunity) { if (opportunity.includes('价格')) return '试用装转月装率、咨询转化率'; if (opportunity.includes('信任')) return 'FAQ 点击率、客服重复问题下降'; if (opportunity.includes('话术')) return '收藏率、评论追问率、标题点击率'; if (opportunity.includes('场景')) return '场景内容互动率、搜索词覆盖数'; return '有效评论数、转化线索数、负反馈下降'; } function buildReport(normalized, args) { const comments = normalized.comments.filter(comment => cleanText(comment.text || comment.content)); const stats = buildStats(normalized, comments); const inferredPlatform = args.platform || stats.platforms[0] || normalized.metadata.platform || 'unknown'; const context = { project: args.project || normalized.metadata.project || 'platform-mini-report', category: args.category || normalized.metadata.category || '', platformKey: platformKey(inferredPlatform), platformName: displayPlatform(inferredPlatform), dataNature: args['data-nature'] || args.dataNature || '未标注', owner: args.owner || 'VOC Skills 课程 demo', date: args.date || new Date().toISOString().slice(0, 10), generatedAt: new Date().toISOString() }; const coverage = buildHypothesisCoverage(comments); const themes = buildThemeClusters(comments); const evidenceCards = selectEvidence(comments, Number(args['evidence-limit'] || 6)); const strength = evidenceStrength(comments, coverage); const conclusion = buildConclusion(context, themes, comments, coverage); const opportunities = buildOpportunities(themes, evidenceCards); const contentRecommendations = buildContentRecommendations(context, themes, evidenceCards); const risks = buildRisks(context, comments); const actions = buildActions(context, opportunities); const audit = { generatedAt: context.generatedAt, commentCount: comments.length, evidenceCardCount: evidenceCards.length, warningCount: 0, uncoveredHypothesisCount: coverage.filter(item => item.status === '未覆盖').length, emptyConclusion: !comments.length, hasTraceableEvidence: evidenceCards.every(card => card.source && card.text) }; if (!comments.length) audit.warningCount++; if (!audit.hasTraceableEvidence) audit.warningCount++; return { metadata: context, stats, conclusion, evidenceStrength: strength, hypothesisCoverage: coverage, themes, evidenceCards, opportunities, contentRecommendations, risks, actions, audit }; } function renderMarkdown(report) { const meta = report.metadata; const lines = []; lines.push(`# Platform VOC Mini Report:${meta.platformName}${meta.category ? meta.category : ''}`); lines.push(''); lines.push(`> 本报告由 \`platform-mini-report-generator\` 基于 normalized VOC 数据自动生成,所有关键结论必须回溯到 VOC 证据卡。`); lines.push(''); lines.push('## 0. 报告信息'); lines.push(''); lines.push('| 字段 | 填写 |'); lines.push('|---|---|'); lines.push(`| 项目/品类 | ${meta.project}${meta.category ? ` / ${meta.category}` : ''} |`); lines.push(`| 分析平台 | ${meta.platformName} |`); lines.push(`| 分析关键词 | ${report.stats.keywords.join('、') || '未标注'} |`); lines.push(`| 样本范围 | ${report.stats.itemCount} 条内容 / ${report.stats.commentCount} 条 VOC |`); lines.push(`| 数据性质 | ${meta.dataNature} |`); lines.push(`| 完成人 | ${meta.owner} |`); lines.push(`| 日期 | ${meta.date} |`); lines.push(''); lines.push('## 1. 一句话结论'); lines.push(''); lines.push(`- **核心结论**:${report.conclusion}`); lines.push(`- **证据强度**:${report.evidenceStrength}`); lines.push(`- **最关键证据**:${report.evidenceCards[0] ? `“${report.evidenceCards[0].text}”(${report.evidenceCards[0].source})` : '暂无可引用证据'}`); lines.push(''); lines.push('## 2. 数据概况'); lines.push(''); lines.push('| 指标 | 数值 | 说明 |'); lines.push('|---|---:|---|'); lines.push(`| 关键词数 | ${report.stats.keywordCount} | ${report.stats.keywords.join('、') || '未标注'} |`); lines.push(`| 内容数 | ${report.stats.itemCount} | normalized items |`); lines.push(`| VOC 数 | ${report.stats.commentCount} | normalized comments |`); lines.push(`| 高互动内容数 | ${report.stats.highInteractionItemCount} | 点赞 > 500 / 收藏 > 300 / 评论 > 100 |`); lines.push(`| 覆盖假设数 | ${report.hypothesisCoverage.filter(item => item.status !== '未覆盖').length} | H1-H8 覆盖 |`); lines.push(`| 主要场景/主题 | ${report.themes.slice(0, 3).map(item => item.theme).join('、') || '未标注'} | 按 theme 聚合 |`); lines.push(''); lines.push('### 2.1 H1-H8 假设覆盖速查'); lines.push(''); lines.push('| 假设 | 覆盖判断 | VOC 数 | 本报告中的使用方式 |'); lines.push('|---|---|---:|---|'); report.hypothesisCoverage.forEach(item => lines.push(`| ${item.tag} ${item.label} | ${item.status} | ${item.count} | ${item.usage} |`)); lines.push(''); lines.push('## 3. 高频主题聚类'); lines.push(''); lines.push('| 主题 | 占比/频次 | 典型原声 | 业务解释 |'); lines.push('|---|---:|---|---|'); report.themes.forEach(item => lines.push(`| ${item.theme} | ${item.ratio} / ${item.count} | “${truncate(item.quote, 52)}” | ${item.explanation} |`)); lines.push(''); lines.push('## 4. VOC 原声证据卡'); lines.push(''); report.evidenceCards.forEach((item, index) => { lines.push(`### 证据 ${index + 1}:${truncate(item.text, 32)}`); lines.push(''); lines.push(`- **来源**:${item.source}`); lines.push(`- **原声**:“${item.text}”`); lines.push(`- **标签**:${item.hypothesisTags.join(', ') || '未标注'}`); lines.push(`- **解释**:这条原声可支撑「${item.theme}」相关判断。`); lines.push(''); }); lines.push('## 5. 产品/内容机会'); lines.push(''); lines.push('| 机会点 | 对应 VOC | 建议动作 | 优先级 |'); lines.push('|---|---|---|---|'); report.opportunities.forEach(item => lines.push(`| ${item.opportunity} | “${item.evidence}” | ${item.action} | ${item.priority} |`)); lines.push(''); lines.push(`## 6. ${meta.platformName}内容建议`); lines.push(''); lines.push('| 内容角度 | 推荐标题 | 证据来源 | 目的 |'); lines.push('|---|---|---|---|'); report.contentRecommendations.forEach(item => lines.push(`| ${item.angle} | ${item.title} | ${item.source} | ${item.purpose} |`)); lines.push(''); lines.push('## 7. 风险与边界'); lines.push(''); lines.push(`- **数据边界**:${report.risks.dataBoundary}`); lines.push(`- **噪声来源**:${report.risks.noiseSource}`); lines.push(`- **不能推出的结论**:${report.risks.cannotConclude}`); lines.push(`- **下一步需要补采/验证**:${report.risks.nextValidation}`); lines.push(''); lines.push('## 8. 结课行动清单'); lines.push(''); lines.push('| 行动 | 负责人 | 截止时间 | 验证指标 |'); lines.push('|---|---|---|---|'); report.actions.forEach(item => lines.push(`| ${item.action} | ${item.owner} | ${item.deadline} | ${item.metric} |`)); lines.push(''); lines.push('## 9. 生成审计'); lines.push(''); lines.push(`- **证据卡数**:${report.audit.evidenceCardCount}`); lines.push(`- **警告数**:${report.audit.warningCount}`); lines.push(`- **未覆盖假设数**:${report.audit.uncoveredHypothesisCount}`); lines.push(`- **证据可追溯**:${report.audit.hasTraceableEvidence ? '是' : '否'}`); return `${lines.join('\n')}\n`; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.input || !args.output) { console.log(usage()); process.exit(args.help ? 0 : 1); } const resolved = resolveInput(args.input); const normalized = normalizeInput(resolved); const report = buildReport(normalized, args); const outputDir = path.resolve(args.output); ensureDir(outputDir); fs.writeFileSync(path.join(outputDir, 'platform-mini-report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8'); fs.writeFileSync(path.join(outputDir, 'platform-mini-report.md'), renderMarkdown(report), 'utf8'); console.log(JSON.stringify({ outputDir, files: ['platform-mini-report.md', 'platform-mini-report.json'], platform: report.metadata.platformName, commentCount: report.stats.commentCount, evidenceCards: report.audit.evidenceCardCount, warnings: report.audit.warningCount, uncoveredHypothesisCount: report.audit.uncoveredHypothesisCount }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { parseArgs, buildReport, renderMarkdown, selectEvidence, buildThemeClusters, buildHypothesisCoverage };