| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- 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 <out-dir> --category <name> --question <text> [--brand <name>] [--product <name>] [--audience <text>] [--channels 小红书,抖音] [--competitors A,B] [--granularity light|enterprise]',
- ' node scripts/tools/hypothesis-generator.js --output <out-dir> --brief <brief.md|json> [--category <name>] [--question <text>]',
- '',
- '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
- };
|