hypothesis-generator.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. const fs = require('fs');
  2. const path = require('path');
  3. function parseArgs(argv) {
  4. const args = {};
  5. for (let i = 0; i < argv.length; i++) {
  6. const token = argv[i];
  7. if (!token.startsWith('--')) continue;
  8. const eq = token.indexOf('=');
  9. if (eq >= 0) {
  10. args[token.slice(2, eq)] = token.slice(eq + 1);
  11. } else {
  12. const key = token.slice(2);
  13. const next = argv[i + 1];
  14. if (next && !next.startsWith('--')) {
  15. args[key] = next;
  16. i++;
  17. } else {
  18. args[key] = true;
  19. }
  20. }
  21. }
  22. return args;
  23. }
  24. function usage() {
  25. return [
  26. 'Usage:',
  27. ' 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]',
  28. ' node scripts/tools/hypothesis-generator.js --output <out-dir> --brief <brief.md|json> [--category <name>] [--question <text>]',
  29. '',
  30. 'Outputs:',
  31. ' 1.Hypothesis.md',
  32. ' hypothesis.json'
  33. ].join('\n');
  34. }
  35. function ensureDir(dirPath) {
  36. fs.mkdirSync(dirPath, { recursive: true });
  37. }
  38. function splitList(value) {
  39. if (!value) return [];
  40. if (Array.isArray(value)) return value;
  41. return String(value).split(/[,,;;、]/).map(item => item.trim()).filter(Boolean);
  42. }
  43. function readBrief(filePath) {
  44. if (!filePath) return {};
  45. const absolute = path.resolve(filePath);
  46. const text = fs.readFileSync(absolute, 'utf8');
  47. if (absolute.endsWith('.json')) return JSON.parse(text);
  48. return parseBriefMarkdown(text);
  49. }
  50. function parseBriefMarkdown(text) {
  51. const output = { raw: text };
  52. const patterns = {
  53. brand: /(?:品牌|brand)\s*[::]\s*([^\n]+)/i,
  54. product: /(?:产品|product)\s*[::]\s*([^\n]+)/i,
  55. category: /(?:品类|category)\s*[::]\s*([^\n]+)/i,
  56. audience: /(?:目标用户|人群|audience)\s*[::]\s*([^\n]+)/i,
  57. channels: /(?:平台|渠道|channels?)\s*[::]\s*([^\n]+)/i,
  58. competitors: /(?:竞品|competitors?)\s*[::]\s*([^\n]+)/i,
  59. question: /(?:决策问题|核心问题|question)\s*[::]\s*([^\n]+)/i
  60. };
  61. Object.entries(patterns).forEach(([key, pattern]) => {
  62. const match = text.match(pattern);
  63. if (match) output[key] = match[1].replace(/^\*+|\*+$/g, '').trim();
  64. });
  65. return output;
  66. }
  67. function firstDefined(...values) {
  68. for (const value of values) {
  69. if (value !== undefined && value !== null && value !== '') return value;
  70. }
  71. return undefined;
  72. }
  73. function contextFromArgs(args) {
  74. const brief = readBrief(args.brief);
  75. const category = firstDefined(args.category, brief.category, '待确认品类');
  76. const brand = firstDefined(args.brand, brief.brand, '待确认品牌');
  77. const product = firstDefined(args.product, brief.product, category);
  78. const question = firstDefined(args.question, brief.question, `${category} 是否值得进入/加码,下一步应该怎么做?`);
  79. const audience = firstDefined(args.audience, brief.audience, '目标用户');
  80. const channels = splitList(firstDefined(args.channels, brief.channels, args.platform, '小红书'));
  81. const competitors = splitList(firstDefined(args.competitors, brief.competitors, '主要竞品'));
  82. return {
  83. category,
  84. brand,
  85. product,
  86. question,
  87. audience,
  88. channels,
  89. competitors,
  90. granularity: args.granularity || 'light',
  91. generatedAt: new Date().toISOString()
  92. };
  93. }
  94. function h(tag, direction, coreQuestion, assumption, userExpression, keywordDirections, evidenceStandard, counterSignal, reportMapping) {
  95. return {
  96. tag,
  97. direction,
  98. coreQuestion,
  99. assumption,
  100. userExpression,
  101. platforms: [],
  102. keywordDirections,
  103. evidenceStandard,
  104. counterSignal,
  105. finalJudgement: '未验证',
  106. evidenceStrength: '低',
  107. reportMapping
  108. };
  109. }
  110. function buildHypotheses(context) {
  111. const category = context.category;
  112. const product = context.product;
  113. const brand = context.brand;
  114. const audience = context.audience;
  115. const channels = context.channels.length ? context.channels : ['小红书'];
  116. const competitors = context.competitors.length ? context.competitors : ['主要竞品'];
  117. const competitorText = competitors.join('、');
  118. const hypotheses = [
  119. h('H1', '品类机会', '市场是否值得进入/加码?', `${category} 的机会来自用户对现有方案仍有未满足需求,${brand} 可以通过更清晰的产品解释和场景切入获得机会。`, `用户会主动表达为什么搜索、购买、踩雷或重新选择 ${category}。`, [`${category} 市场机会`, `${category} 值不值得买`, `${category} 趋势`, `${category} 痛点`], '至少 30 条 VOC 能解释进入动机、现有不满和机会窗口。', `用户讨论稀少,或高频表达显示 ${category} 需求已被现有方案充分满足。`, '轻量报告 Ch1 / 企业报告 Ch1-Ch2'),
  120. h('H2', '用户需求', '用户真实痛点和高频场景是什么?', `${audience} 对 ${category} 的需求不是抽象功能,而是被具体场景、症状、任务或生活时刻触发。`, '用户会说出具体使用前后情境、触发原因、焦虑和选择门槛。', [`${category} 使用场景`, `${category} 痛点`, `${category} 人群`, `${category} 怎么用`], '至少 50 条 VOC 能聚类出 3 个以上高频场景或痛点。', '评论集中在泛泛夸奖、抽奖、广告或无场景表达,无法形成稳定需求结构。', '轻量报告 Ch2 / 企业报告 Ch2-Ch3'),
  121. h('H3', '产品缺口', '当前产品/竞品哪里没有满足?', `${product} 的产品机会来自用户对配方、体验、包装、说明、服务或使用边界的不满。`, '用户会提到“不好用/看不懂/不方便/不适合/客服讲不清”等具体缺口。', [`${category} 怎么选`, `${category} 配方`, `${category} 产品缺点`, `${product} 测评`], '至少 30 条 VOC 能归纳 3 类产品缺口,并能映射到产品或详情页动作。', '负面反馈高度分散,无法证明存在稳定产品缺口。', '轻量报告 Ch2-Ch4 / 企业报告 Ch3-Ch5'),
  122. h('H4', '价格认知', '用户对价格、规格、价值感如何判断?', `用户对 ${category} 的价格接受度取决于规格、使用周期、风险感和可验证效果,而不只是绝对价格。`, '用户会比较贵不贵、够不够用、是否先试用、每天成本和复购压力。', [`${category} 价格`, `${category} 规格`, `${category} 性价比`, `${category} 试用装`], '至少 20 条 VOC 能说明价格带、规格偏好或试用/复购路径。', '用户很少讨论价格或规格,价格并非主要决策因素。', '轻量报告 Ch3-Ch4 / 企业报告 Ch4-Ch5'),
  123. h('H5', '渠道内容', '哪些内容/平台影响用户决策?', `${channels.join('、')} 上真正影响用户决策的内容不是单纯曝光,而是能降低理解成本和风险感的内容。`, '用户会收藏、追问、转述或对比某类内容形式,例如测评、避坑、观察卡、清单和达人解释。', [`${category} 种草`, `${category} 达人推荐`, `${category} 内容笔记`, `${category} 避坑`], '至少 20 条 VOC 或内容样本能识别 3 类有效内容形式。', '平台内容互动高但评论离题,或无法影响购买/咨询/收藏行为。', '轻量报告 Ch3 / 企业报告 Ch5-Ch6'),
  124. h('H6', '竞品策略', '竞品靠什么赢,哪里有漏洞?', `${competitorText} 的优势和漏洞会在用户对比、替代和吐槽中暴露,${brand} 可以找到差异化切口。`, '用户会主动拿竞品比较效果、价格、包装、信任背书、渠道和使用体验。', [`${category} 竞品`, `${category} 替代品`, `${category} 对比`, competitors[0] || `${category} 竞品`], '每个主要竞品至少 20 条 VOC,能说明赢点、输点和替代原因。', '用户没有明确竞品参照,或竞品比较主要来自营销号而非用户表达。', '轻量报告 Ch3 / 企业报告 Ch4-Ch5'),
  125. h('H7', '定位表达', '什么话术、概念、定位更能打动用户?', `${product} 更容易被接受的表达应来自用户原话,把专业参数翻译成场景、结果、边界和选择标准。`, '用户会复述某些词、问法、标题或卖点,也会反感夸大、玄学和看不懂的表达。', [`${category} 卖点`, `${category} 定位`, `${category} 话术`, `${category} 标题`], '至少 30 条 VOC 能抽取用户高频词、反感词和可测试话术。', '用户表达没有稳定词汇,或话术偏好与平台互动指标不一致。', '轻量报告 Ch3-Ch4 / 企业报告 Ch5'),
  126. h('H8', '增长行动', '下阶段最应该做哪些动作?', `下一阶段行动应优先选择能最快验证 ${category} 需求、产品缺口和内容表达的 P0 动作。`, '用户会给出复购、推荐、咨询、犹豫、放弃和改进建议等行动信号。', [`${category} 复购`, `${category} 推荐`, `${category} 转化`, `${category} 咨询`], '至少 20 条 VOC 能支撑 P0/P1/P2 行动建议和验证指标。', 'VOC 只能描述现象,无法转成明确动作或验证指标。', '轻量报告 Ch4 / 企业报告 Ch7')
  127. ];
  128. return hypotheses.map(item => ({ ...item, platforms: channels }));
  129. }
  130. function keywordRowsFromHypotheses(hypotheses, targetCount) {
  131. const rows = [];
  132. hypotheses.forEach(hypothesis => {
  133. hypothesis.keywordDirections.slice(0, 3).forEach((keyword, index) => {
  134. rows.push({
  135. keyword,
  136. keywordType: keywordTypeForHypothesis(hypothesis.tag),
  137. batch: batchForHypothesis(hypothesis.tag, index),
  138. targetCount,
  139. hypothesisTags: [hypothesis.tag],
  140. purpose: hypothesis.coreQuestion,
  141. expectedSignal: hypothesis.userExpression,
  142. risk: hypothesis.counterSignal
  143. });
  144. });
  145. });
  146. return rows;
  147. }
  148. function keywordTypeForHypothesis(tag) {
  149. return {
  150. H1: '品类词',
  151. H2: '场景词',
  152. H3: '产品词',
  153. H4: '价格词',
  154. H5: '平台内容词',
  155. H6: '竞品词',
  156. H7: '话术词',
  157. H8: '行动词'
  158. }[tag] || '长尾问题词';
  159. }
  160. function batchForHypothesis(tag, index) {
  161. if (['H1', 'H2', 'H3'].includes(tag) && index === 0) return 'P0';
  162. if (['H8'].includes(tag)) return 'P2';
  163. return index === 2 ? 'P2' : 'P1';
  164. }
  165. function renderMarkdown(context, hypotheses) {
  166. const lines = [];
  167. lines.push('# 1. VOC H1-H8 假设');
  168. lines.push('');
  169. lines.push('## 1. 项目上下文');
  170. lines.push('');
  171. lines.push('| 字段 | 内容 |');
  172. lines.push('|---|---|');
  173. lines.push(`| 品类 | ${context.category} |`);
  174. lines.push(`| 品牌 | ${context.brand} |`);
  175. lines.push(`| 产品 | ${context.product} |`);
  176. lines.push(`| 决策问题 | ${context.question} |`);
  177. lines.push(`| 目标用户 | ${context.audience} |`);
  178. lines.push(`| 目标渠道 | ${context.channels.join('、')} |`);
  179. lines.push(`| 已知竞品 | ${context.competitors.join('、')} |`);
  180. lines.push(`| 报告粒度 | ${context.granularity === 'enterprise' ? '企业 6-7 章' : '轻量 4 章'} |`);
  181. lines.push('');
  182. lines.push('## 2. 假设总览');
  183. lines.push('');
  184. lines.push('| 假设 | 方向 | 核心问题 | 验证状态 | 证据强度 |');
  185. lines.push('|---|---|---|---|---|');
  186. hypotheses.forEach(item => {
  187. lines.push(`| ${item.tag} | ${item.direction} | ${item.coreQuestion} | ${item.finalJudgement} | ${item.evidenceStrength} |`);
  188. });
  189. lines.push('');
  190. lines.push('## 3. 假设展开卡');
  191. hypotheses.forEach(item => {
  192. lines.push('');
  193. lines.push(`### ${item.tag} ${item.direction}`);
  194. lines.push('');
  195. lines.push('| 字段 | 填写 |');
  196. lines.push('|---|---|');
  197. lines.push(`| 假设表述 | ${item.assumption} |`);
  198. lines.push(`| 对应决策问题 | ${context.question} |`);
  199. lines.push(`| 需要验证的用户表达 | ${item.userExpression} |`);
  200. lines.push(`| 需要覆盖的平台 | ${item.platforms.join('、')} |`);
  201. lines.push(`| 关键词方向 | ${item.keywordDirections.join('、')} |`);
  202. lines.push(`| 支持证据标准 | ${item.evidenceStandard} |`);
  203. lines.push(`| 反证信号 | ${item.counterSignal} |`);
  204. lines.push(`| 报告映射 | ${item.reportMapping} |`);
  205. lines.push(`| 最终判断 | ${item.finalJudgement} |`);
  206. });
  207. lines.push('');
  208. lines.push('## 4. 假设到采集矩阵映射');
  209. lines.push('');
  210. lines.push('| 假设 | 推荐关键词类型 | 推荐平台 | 最低证据量 | 备注 |');
  211. lines.push('|---|---|---|---:|---|');
  212. hypotheses.forEach(item => {
  213. const evidenceNumber = Number((item.evidenceStandard.match(/\d+/) || ['20'])[0]);
  214. lines.push(`| ${item.tag} | ${keywordTypeForHypothesis(item.tag)} | ${item.platforms.join(' / ')} | ${evidenceNumber} 条 VOC | ${item.keywordDirections.slice(0, 3).join('、')} |`);
  215. });
  216. lines.push('');
  217. lines.push('## 5. Builder 输入');
  218. lines.push('');
  219. lines.push('- `hypothesis.json` 中的 `keywords` 数组可直接作为 `collection-matrix-builder` 输入。');
  220. return lines.join('\n') + '\n';
  221. }
  222. function main() {
  223. const args = parseArgs(process.argv.slice(2));
  224. if (args.help || !args.output) {
  225. console.log(usage());
  226. process.exit(args.help ? 0 : 1);
  227. }
  228. const outputDir = path.resolve(args.output);
  229. ensureDir(outputDir);
  230. const context = contextFromArgs(args);
  231. const hypotheses = buildHypotheses(context);
  232. const keywords = keywordRowsFromHypotheses(hypotheses, Number(args.target || 20));
  233. const output = {
  234. metadata: {
  235. project: args.project || context.category,
  236. generatedAt: context.generatedAt,
  237. granularity: context.granularity,
  238. hypothesisCount: hypotheses.length,
  239. keywordCount: keywords.length
  240. },
  241. context,
  242. hypotheses,
  243. keywords
  244. };
  245. fs.writeFileSync(path.join(outputDir, 'hypothesis.json'), JSON.stringify(output, null, 2) + '\n', 'utf8');
  246. fs.writeFileSync(path.join(outputDir, '1.Hypothesis.md'), renderMarkdown(context, hypotheses), 'utf8');
  247. console.log(JSON.stringify({
  248. outputDir,
  249. files: ['1.Hypothesis.md', 'hypothesis.json'],
  250. hypothesisCount: hypotheses.length,
  251. keywordCount: keywords.length,
  252. granularity: context.granularity
  253. }, null, 2));
  254. }
  255. if (require.main === module) {
  256. try {
  257. main();
  258. } catch (error) {
  259. console.error(error.message);
  260. process.exit(1);
  261. }
  262. }
  263. module.exports = {
  264. parseArgs,
  265. contextFromArgs,
  266. buildHypotheses,
  267. keywordRowsFromHypotheses,
  268. renderMarkdown
  269. };