const fs = require('fs'); const path = require('path'); 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/hypothesis-generator.js --output --category --question [--brand ] [--product ] [--audience ] [--channels 小红书,抖音] [--competitors A,B] [--granularity light|enterprise]', ' node scripts/tools/hypothesis-generator.js --output --brief [--category ] [--question ]', '', 'Outputs:', ' 1.Hypothesis.md', ' hypothesis.json' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value; return String(value).split(/[,,;;、]/).map(item => item.trim()).filter(Boolean); } function readBrief(filePath) { if (!filePath) return {}; const absolute = path.resolve(filePath); const text = fs.readFileSync(absolute, 'utf8'); if (absolute.endsWith('.json')) return JSON.parse(text); return parseBriefMarkdown(text); } function parseBriefMarkdown(text) { const output = { raw: text }; const patterns = { brand: /(?:品牌|brand)\s*[::]\s*([^\n]+)/i, product: /(?:产品|product)\s*[::]\s*([^\n]+)/i, category: /(?:品类|category)\s*[::]\s*([^\n]+)/i, audience: /(?:目标用户|人群|audience)\s*[::]\s*([^\n]+)/i, channels: /(?:平台|渠道|channels?)\s*[::]\s*([^\n]+)/i, competitors: /(?:竞品|competitors?)\s*[::]\s*([^\n]+)/i, question: /(?:决策问题|核心问题|question)\s*[::]\s*([^\n]+)/i }; Object.entries(patterns).forEach(([key, pattern]) => { const match = text.match(pattern); if (match) output[key] = match[1].replace(/^\*+|\*+$/g, '').trim(); }); return output; } function firstDefined(...values) { for (const value of values) { if (value !== undefined && value !== null && value !== '') return value; } return undefined; } function contextFromArgs(args) { const brief = readBrief(args.brief); const category = firstDefined(args.category, brief.category, '待确认品类'); const brand = firstDefined(args.brand, brief.brand, '待确认品牌'); const product = firstDefined(args.product, brief.product, category); const question = firstDefined(args.question, brief.question, `${category} 是否值得进入/加码,下一步应该怎么做?`); const audience = firstDefined(args.audience, brief.audience, '目标用户'); const channels = splitList(firstDefined(args.channels, brief.channels, args.platform, '小红书')); const competitors = splitList(firstDefined(args.competitors, brief.competitors, '主要竞品')); return { category, brand, product, question, audience, channels, competitors, granularity: args.granularity || 'light', generatedAt: new Date().toISOString() }; } function h(tag, direction, coreQuestion, assumption, userExpression, keywordDirections, evidenceStandard, counterSignal, reportMapping) { return { tag, direction, coreQuestion, assumption, userExpression, platforms: [], keywordDirections, evidenceStandard, counterSignal, finalJudgement: '未验证', evidenceStrength: '低', reportMapping }; } function buildHypotheses(context) { const category = context.category; const product = context.product; const brand = context.brand; const audience = context.audience; const channels = context.channels.length ? context.channels : ['小红书']; const competitors = context.competitors.length ? context.competitors : ['主要竞品']; const competitorText = competitors.join('、'); const hypotheses = [ h('H1', '品类机会', '市场是否值得进入/加码?', `${category} 的机会来自用户对现有方案仍有未满足需求,${brand} 可以通过更清晰的产品解释和场景切入获得机会。`, `用户会主动表达为什么搜索、购买、踩雷或重新选择 ${category}。`, [`${category} 市场机会`, `${category} 值不值得买`, `${category} 趋势`, `${category} 痛点`], '至少 30 条 VOC 能解释进入动机、现有不满和机会窗口。', `用户讨论稀少,或高频表达显示 ${category} 需求已被现有方案充分满足。`, '轻量报告 Ch1 / 企业报告 Ch1-Ch2'), h('H2', '用户需求', '用户真实痛点和高频场景是什么?', `${audience} 对 ${category} 的需求不是抽象功能,而是被具体场景、症状、任务或生活时刻触发。`, '用户会说出具体使用前后情境、触发原因、焦虑和选择门槛。', [`${category} 使用场景`, `${category} 痛点`, `${category} 人群`, `${category} 怎么用`], '至少 50 条 VOC 能聚类出 3 个以上高频场景或痛点。', '评论集中在泛泛夸奖、抽奖、广告或无场景表达,无法形成稳定需求结构。', '轻量报告 Ch2 / 企业报告 Ch2-Ch3'), h('H3', '产品缺口', '当前产品/竞品哪里没有满足?', `${product} 的产品机会来自用户对配方、体验、包装、说明、服务或使用边界的不满。`, '用户会提到“不好用/看不懂/不方便/不适合/客服讲不清”等具体缺口。', [`${category} 怎么选`, `${category} 配方`, `${category} 产品缺点`, `${product} 测评`], '至少 30 条 VOC 能归纳 3 类产品缺口,并能映射到产品或详情页动作。', '负面反馈高度分散,无法证明存在稳定产品缺口。', '轻量报告 Ch2-Ch4 / 企业报告 Ch3-Ch5'), h('H4', '价格认知', '用户对价格、规格、价值感如何判断?', `用户对 ${category} 的价格接受度取决于规格、使用周期、风险感和可验证效果,而不只是绝对价格。`, '用户会比较贵不贵、够不够用、是否先试用、每天成本和复购压力。', [`${category} 价格`, `${category} 规格`, `${category} 性价比`, `${category} 试用装`], '至少 20 条 VOC 能说明价格带、规格偏好或试用/复购路径。', '用户很少讨论价格或规格,价格并非主要决策因素。', '轻量报告 Ch3-Ch4 / 企业报告 Ch4-Ch5'), h('H5', '渠道内容', '哪些内容/平台影响用户决策?', `${channels.join('、')} 上真正影响用户决策的内容不是单纯曝光,而是能降低理解成本和风险感的内容。`, '用户会收藏、追问、转述或对比某类内容形式,例如测评、避坑、观察卡、清单和达人解释。', [`${category} 种草`, `${category} 达人推荐`, `${category} 内容笔记`, `${category} 避坑`], '至少 20 条 VOC 或内容样本能识别 3 类有效内容形式。', '平台内容互动高但评论离题,或无法影响购买/咨询/收藏行为。', '轻量报告 Ch3 / 企业报告 Ch5-Ch6'), h('H6', '竞品策略', '竞品靠什么赢,哪里有漏洞?', `${competitorText} 的优势和漏洞会在用户对比、替代和吐槽中暴露,${brand} 可以找到差异化切口。`, '用户会主动拿竞品比较效果、价格、包装、信任背书、渠道和使用体验。', [`${category} 竞品`, `${category} 替代品`, `${category} 对比`, competitors[0] || `${category} 竞品`], '每个主要竞品至少 20 条 VOC,能说明赢点、输点和替代原因。', '用户没有明确竞品参照,或竞品比较主要来自营销号而非用户表达。', '轻量报告 Ch3 / 企业报告 Ch4-Ch5'), h('H7', '定位表达', '什么话术、概念、定位更能打动用户?', `${product} 更容易被接受的表达应来自用户原话,把专业参数翻译成场景、结果、边界和选择标准。`, '用户会复述某些词、问法、标题或卖点,也会反感夸大、玄学和看不懂的表达。', [`${category} 卖点`, `${category} 定位`, `${category} 话术`, `${category} 标题`], '至少 30 条 VOC 能抽取用户高频词、反感词和可测试话术。', '用户表达没有稳定词汇,或话术偏好与平台互动指标不一致。', '轻量报告 Ch3-Ch4 / 企业报告 Ch5'), h('H8', '增长行动', '下阶段最应该做哪些动作?', `下一阶段行动应优先选择能最快验证 ${category} 需求、产品缺口和内容表达的 P0 动作。`, '用户会给出复购、推荐、咨询、犹豫、放弃和改进建议等行动信号。', [`${category} 复购`, `${category} 推荐`, `${category} 转化`, `${category} 咨询`], '至少 20 条 VOC 能支撑 P0/P1/P2 行动建议和验证指标。', 'VOC 只能描述现象,无法转成明确动作或验证指标。', '轻量报告 Ch4 / 企业报告 Ch7') ]; return hypotheses.map(item => ({ ...item, platforms: channels })); } function keywordRowsFromHypotheses(hypotheses, targetCount) { const rows = []; hypotheses.forEach(hypothesis => { hypothesis.keywordDirections.slice(0, 3).forEach((keyword, index) => { rows.push({ keyword, keywordType: keywordTypeForHypothesis(hypothesis.tag), batch: batchForHypothesis(hypothesis.tag, index), targetCount, hypothesisTags: [hypothesis.tag], purpose: hypothesis.coreQuestion, expectedSignal: hypothesis.userExpression, risk: hypothesis.counterSignal }); }); }); return rows; } function keywordTypeForHypothesis(tag) { return { H1: '品类词', H2: '场景词', H3: '产品词', H4: '价格词', H5: '平台内容词', H6: '竞品词', H7: '话术词', H8: '行动词' }[tag] || '长尾问题词'; } function batchForHypothesis(tag, index) { if (['H1', 'H2', 'H3'].includes(tag) && index === 0) return 'P0'; if (['H8'].includes(tag)) return 'P2'; return index === 2 ? 'P2' : 'P1'; } function renderMarkdown(context, hypotheses) { const lines = []; lines.push('# 1. VOC H1-H8 假设'); lines.push(''); lines.push('## 1. 项目上下文'); lines.push(''); lines.push('| 字段 | 内容 |'); lines.push('|---|---|'); lines.push(`| 品类 | ${context.category} |`); lines.push(`| 品牌 | ${context.brand} |`); lines.push(`| 产品 | ${context.product} |`); lines.push(`| 决策问题 | ${context.question} |`); lines.push(`| 目标用户 | ${context.audience} |`); lines.push(`| 目标渠道 | ${context.channels.join('、')} |`); lines.push(`| 已知竞品 | ${context.competitors.join('、')} |`); lines.push(`| 报告粒度 | ${context.granularity === 'enterprise' ? '企业 6-7 章' : '轻量 4 章'} |`); lines.push(''); lines.push('## 2. 假设总览'); lines.push(''); lines.push('| 假设 | 方向 | 核心问题 | 验证状态 | 证据强度 |'); lines.push('|---|---|---|---|---|'); hypotheses.forEach(item => { lines.push(`| ${item.tag} | ${item.direction} | ${item.coreQuestion} | ${item.finalJudgement} | ${item.evidenceStrength} |`); }); lines.push(''); lines.push('## 3. 假设展开卡'); hypotheses.forEach(item => { lines.push(''); lines.push(`### ${item.tag} ${item.direction}`); lines.push(''); lines.push('| 字段 | 填写 |'); lines.push('|---|---|'); lines.push(`| 假设表述 | ${item.assumption} |`); lines.push(`| 对应决策问题 | ${context.question} |`); lines.push(`| 需要验证的用户表达 | ${item.userExpression} |`); lines.push(`| 需要覆盖的平台 | ${item.platforms.join('、')} |`); lines.push(`| 关键词方向 | ${item.keywordDirections.join('、')} |`); lines.push(`| 支持证据标准 | ${item.evidenceStandard} |`); lines.push(`| 反证信号 | ${item.counterSignal} |`); lines.push(`| 报告映射 | ${item.reportMapping} |`); lines.push(`| 最终判断 | ${item.finalJudgement} |`); }); lines.push(''); lines.push('## 4. 假设到采集矩阵映射'); lines.push(''); lines.push('| 假设 | 推荐关键词类型 | 推荐平台 | 最低证据量 | 备注 |'); lines.push('|---|---|---|---:|---|'); hypotheses.forEach(item => { const evidenceNumber = Number((item.evidenceStandard.match(/\d+/) || ['20'])[0]); lines.push(`| ${item.tag} | ${keywordTypeForHypothesis(item.tag)} | ${item.platforms.join(' / ')} | ${evidenceNumber} 条 VOC | ${item.keywordDirections.slice(0, 3).join('、')} |`); }); lines.push(''); lines.push('## 5. Builder 输入'); lines.push(''); lines.push('- `hypothesis.json` 中的 `keywords` 数组可直接作为 `collection-matrix-builder` 输入。'); return lines.join('\n') + '\n'; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.output) { console.log(usage()); process.exit(args.help ? 0 : 1); } const outputDir = path.resolve(args.output); ensureDir(outputDir); const context = contextFromArgs(args); const hypotheses = buildHypotheses(context); const keywords = keywordRowsFromHypotheses(hypotheses, Number(args.target || 20)); const output = { metadata: { project: args.project || context.category, generatedAt: context.generatedAt, granularity: context.granularity, hypothesisCount: hypotheses.length, keywordCount: keywords.length }, context, hypotheses, keywords }; fs.writeFileSync(path.join(outputDir, 'hypothesis.json'), JSON.stringify(output, null, 2) + '\n', 'utf8'); fs.writeFileSync(path.join(outputDir, '1.Hypothesis.md'), renderMarkdown(context, hypotheses), 'utf8'); console.log(JSON.stringify({ outputDir, files: ['1.Hypothesis.md', 'hypothesis.json'], hypothesisCount: hypotheses.length, keywordCount: keywords.length, granularity: context.granularity }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { parseArgs, contextFromArgs, buildHypotheses, keywordRowsFromHypotheses, renderMarkdown };