collection-matrix-builder.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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/collection-matrix-builder.js --keywords <keywords.md|json> --output <out-dir> [--project <name>] [--platform <name>] [--target 20]',
  28. ' node scripts/tools/collection-matrix-builder.js --keyword "kw1,kw2" --output <out-dir> [--platform 小红书]',
  29. ' add --expand-hypotheses to auto-fill H1-H8 to at least 3 keywords each',
  30. '',
  31. 'Outputs:',
  32. ' 3.CollectionMatrix.md',
  33. ' collection-matrix.json'
  34. ].join('\n');
  35. }
  36. const HYPOTHESIS_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8'];
  37. const HYPOTHESIS_KEYWORD_TEMPLATES = {
  38. H1: [
  39. { keyword: '{category} 市场机会', type: '品类词', batch: 'P0' },
  40. { keyword: '{category} 值不值得买', type: '长尾问题词', batch: 'P0' },
  41. { keyword: '{category} 趋势', type: '品类词', batch: 'P1' }
  42. ],
  43. H2: [
  44. { keyword: '{category} 使用场景', type: '场景词', batch: 'P0' },
  45. { keyword: '{category} 痛点', type: '痛点场景词', batch: 'P0' },
  46. { keyword: '{category} 人群', type: '场景词', batch: 'P1' }
  47. ],
  48. H3: [
  49. { keyword: '{category} 怎么选', type: '长尾问题词', batch: 'P0' },
  50. { keyword: '{category} 配方', type: '产品词', batch: 'P1' },
  51. { keyword: '{category} 产品缺点', type: '长尾问题词', batch: 'P2' }
  52. ],
  53. H4: [
  54. { keyword: '{category} 价格', type: '价格词', batch: 'P1' },
  55. { keyword: '{category} 规格', type: '产品词', batch: 'P1' },
  56. { keyword: '{category} 性价比', type: '长尾问题词', batch: 'P1' }
  57. ],
  58. H5: [
  59. { keyword: '{category} 种草', type: '平台内容词', batch: 'P1' },
  60. { keyword: '{category} 达人推荐', type: '平台内容词', batch: 'P1' },
  61. { keyword: '{category} 内容笔记', type: '内容词', batch: 'P1' }
  62. ],
  63. H6: [
  64. { keyword: '{category} 竞品', type: '竞品词', batch: 'P1' },
  65. { keyword: '{category} 替代品', type: '竞品词', batch: 'P1' },
  66. { keyword: '{category} 对比', type: '竞品词', batch: 'P1' }
  67. ],
  68. H7: [
  69. { keyword: '{category} 卖点', type: '话术词', batch: 'P1' },
  70. { keyword: '{category} 定位', type: '话术词', batch: 'P1' },
  71. { keyword: '{category} 话术', type: '话术词', batch: 'P2' }
  72. ],
  73. H8: [
  74. { keyword: '{category} 复购', type: '行动词', batch: 'P2' },
  75. { keyword: '{category} 推荐', type: '行动词', batch: 'P2' },
  76. { keyword: '{category} 转化', type: '行动词', batch: 'P2' }
  77. ]
  78. };
  79. function ensureDir(dirPath) {
  80. fs.mkdirSync(dirPath, { recursive: true });
  81. }
  82. function readJson(filePath) {
  83. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  84. }
  85. function splitList(value) {
  86. if (!value) return [];
  87. if (Array.isArray(value)) return value;
  88. return String(value).split(/[,,;;]/).map(item => item.trim()).filter(Boolean);
  89. }
  90. function normalizeHeader(header) {
  91. return header.trim().replace(/`/g, '').toLowerCase();
  92. }
  93. function parseMarkdownTables(text) {
  94. const lines = text.split(/\r?\n/);
  95. const tables = [];
  96. let i = 0;
  97. while (i < lines.length) {
  98. const line = lines[i];
  99. if (!/^\s*\|.*\|\s*$/.test(line) || !lines[i + 1] || !/^\s*\|\s*:?-{3,}:?\s*\|/.test(lines[i + 1])) {
  100. i++;
  101. continue;
  102. }
  103. const headers = splitMarkdownRow(line).map(normalizeHeader);
  104. const rows = [];
  105. i += 2;
  106. while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
  107. const values = splitMarkdownRow(lines[i]);
  108. const row = {};
  109. headers.forEach((header, index) => {
  110. row[header] = values[index] ? values[index].trim() : '';
  111. });
  112. rows.push(row);
  113. i++;
  114. }
  115. tables.push({ headers, rows });
  116. }
  117. return tables;
  118. }
  119. function splitMarkdownRow(line) {
  120. return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
  121. }
  122. function rowValue(row, keys) {
  123. for (const key of keys) {
  124. const normalized = normalizeHeader(key);
  125. if (row[normalized] !== undefined && row[normalized] !== '') return row[normalized];
  126. }
  127. return '';
  128. }
  129. function parseKeywordsMarkdown(filePath) {
  130. const text = fs.readFileSync(filePath, 'utf8');
  131. const tables = parseMarkdownTables(text);
  132. const keywordRows = [];
  133. tables.forEach(table => {
  134. const hasKeyword = table.headers.some(header => ['keyword', '关键词'].includes(header));
  135. const hasBatch = table.headers.some(header => ['batch', '批次'].includes(header));
  136. if (!hasKeyword || !hasBatch) return;
  137. table.rows.forEach(row => {
  138. const keyword = rowValue(row, ['keyword', '关键词']);
  139. if (!keyword) return;
  140. keywordRows.push({
  141. platform: rowValue(row, ['platform', '平台']),
  142. keyword,
  143. keywordType: rowValue(row, ['keywordType', '关键词类型', '类型']),
  144. batch: rowValue(row, ['batch', '批次']),
  145. targetCount: rowValue(row, ['targetCount', '目标数量', '目标样本']),
  146. hypothesisTags: rowValue(row, ['hypothesisTags', '假设标签', 'H1-H8']),
  147. purpose: rowValue(row, ['purpose', '目的', '用途']),
  148. expectedSignal: rowValue(row, ['expectedSignal', '预期信号', '预期观察']),
  149. risk: rowValue(row, ['risk', '风险', '噪声'])
  150. });
  151. });
  152. });
  153. return keywordRows;
  154. }
  155. function parseKeywordsJson(filePath) {
  156. const data = readJson(filePath);
  157. if (Array.isArray(data)) return data;
  158. if (Array.isArray(data.matrix)) return data.matrix;
  159. if (Array.isArray(data.keywords)) return data.keywords;
  160. return [];
  161. }
  162. function inferKeywordType(keyword) {
  163. if (/怎么选|有没有用|副作用|避坑|对比|测评|review|best/i.test(keyword)) return '长尾问题词';
  164. if (/竞品|替代|vs|对标|品牌|测评/i.test(keyword)) return '竞品词';
  165. if (/开学|熬夜|送礼|旅行|饭局|便秘|腹胀|场景/i.test(keyword)) return '场景词';
  166. return '产品词';
  167. }
  168. function inferBatch(keyword, index) {
  169. if (/核心|怎么选|痛点|便秘|腹胀|差评/i.test(keyword)) return 'P0';
  170. if (/竞品|测评|替代|场景|价格|规格/i.test(keyword)) return 'P1';
  171. return index < 3 ? 'P0' : 'P2';
  172. }
  173. function inferHypothesisTags(keyword, type) {
  174. const tags = new Set();
  175. if (/痛点|焦虑|便秘|腹胀|差评|避坑/i.test(keyword)) tags.add('H2');
  176. if (/怎么选|配方|成分|参数|菌株|功能|产品/i.test(keyword)) tags.add('H3');
  177. if (/价格|规格|贵|便宜|性价比/i.test(keyword)) tags.add('H4');
  178. if (/平台|小红书|抖音|内容|种草|达人/i.test(keyword)) tags.add('H5');
  179. if (/竞品|替代|对标|vs|品牌/i.test(keyword)) tags.add('H6');
  180. if (/话术|定位|概念|卖点|表达/i.test(keyword)) tags.add('H7');
  181. if (/行动|复购|推荐|转化|增长/i.test(keyword)) tags.add('H8');
  182. if (/品类|趋势|市场|机会/i.test(keyword)) tags.add('H1');
  183. if (tags.size === 0) {
  184. if (/竞品/.test(type)) tags.add('H6');
  185. else if (/场景/.test(type)) tags.add('H2');
  186. else if (/问题/.test(type)) tags.add('H2');
  187. else tags.add('H3');
  188. }
  189. return Array.from(tags);
  190. }
  191. function inferPurpose(keyword, type, tags) {
  192. if (tags.includes('H2')) return `识别「${keyword}」相关的用户痛点、触发场景与真实表达`;
  193. if (tags.includes('H6')) return `分析「${keyword}」相关的竞品、替代方案和用户比较标准`;
  194. if (tags.includes('H4')) return `观察「${keyword}」相关的价格、规格和价值感判断`;
  195. if (tags.includes('H7')) return `提炼「${keyword}」相关的定位、卖点和话术表达`;
  196. return `验证「${keyword}」相关的品类、产品和购买决策信号`;
  197. }
  198. function inferExpectedSignal(type, tags) {
  199. const signals = [];
  200. if (tags.includes('H1')) signals.push('品类机会');
  201. if (tags.includes('H2')) signals.push('高频场景', '痛点原声');
  202. if (tags.includes('H3')) signals.push('产品参数', '购买标准');
  203. if (tags.includes('H4')) signals.push('价格带', '规格偏好');
  204. if (tags.includes('H5')) signals.push('内容形式', '平台决策链路');
  205. if (tags.includes('H6')) signals.push('竞品优劣势', '替代原因');
  206. if (tags.includes('H7')) signals.push('用户复述话术', '定位表达');
  207. if (tags.includes('H8')) signals.push('行动建议', '验证指标');
  208. return Array.from(new Set(signals)).join('、') || `${type || '关键词'}相关 VOC`;
  209. }
  210. function normalizeMatrixRows(rows, options) {
  211. return rows
  212. .filter(row => row.keyword || row.keyword === undefined)
  213. .map((row, index) => {
  214. const keyword = row.keyword || row.Keyword || row.关键词 || '';
  215. const keywordType = row.keywordType || row['关键词类型'] || row.type || inferKeywordType(keyword);
  216. const batch = row.batch || row.批次 || inferBatch(keyword, index);
  217. const hypothesisTags = splitList(row.hypothesisTags || row['假设标签'] || row.tags || row.H || '').length
  218. ? splitList(row.hypothesisTags || row['假设标签'] || row.tags || row.H || '')
  219. : inferHypothesisTags(keyword, keywordType);
  220. return {
  221. id: row.id || `M${String(index + 1).padStart(3, '0')}`,
  222. platform: row.platform || row.平台 || options.platform || '小红书',
  223. keyword,
  224. keywordType,
  225. batch,
  226. targetCount: Number(row.targetCount || row['目标数量'] || row.count || options.target || 20),
  227. hypothesisTags,
  228. purpose: row.purpose || row.目的 || inferPurpose(keyword, keywordType, hypothesisTags),
  229. expectedSignal: row.expectedSignal || row['预期信号'] || inferExpectedSignal(keywordType, hypothesisTags),
  230. risk: row.risk || row.风险 || '需检查离题、广告、重复和平台偏差',
  231. status: row.status || '未开始'
  232. };
  233. })
  234. .filter(row => row.keyword);
  235. }
  236. function nextMatrixId(rows, offset = 1) {
  237. const max = rows.reduce((current, row) => {
  238. const match = String(row.id || '').match(/^M(\d+)$/);
  239. return match ? Math.max(current, Number(match[1])) : current;
  240. }, 0);
  241. return `M${String(max + offset).padStart(3, '0')}`;
  242. }
  243. function expandHypotheses(rows, options) {
  244. const expanded = rows.map(row => ({ ...row, hypothesisTags: [...row.hypothesisTags] }));
  245. const category = options.category || options.project || '品类';
  246. const existingKeywords = new Set(expanded.map(row => row.keyword));
  247. HYPOTHESIS_TAGS.forEach(tag => {
  248. let currentCount = expanded.filter(row => row.hypothesisTags.includes(tag)).length;
  249. let templateIndex = 0;
  250. while (currentCount < 3) {
  251. const templates = HYPOTHESIS_KEYWORD_TEMPLATES[tag] || [];
  252. const template = templates[templateIndex % templates.length];
  253. const baseKeyword = template.keyword.replace('{category}', category);
  254. let keyword = baseKeyword;
  255. let suffix = 2;
  256. while (existingKeywords.has(keyword)) {
  257. keyword = `${baseKeyword} ${suffix}`;
  258. suffix++;
  259. }
  260. const row = {
  261. id: nextMatrixId(expanded),
  262. platform: options.platform || '小红书',
  263. keyword,
  264. keywordType: template.type,
  265. batch: template.batch,
  266. targetCount: Number(options.target || 20),
  267. hypothesisTags: [tag],
  268. purpose: inferPurpose(keyword, template.type, [tag]),
  269. expectedSignal: inferExpectedSignal(template.type, [tag]),
  270. risk: '自动补齐关键词,执行前需人工确认搜索噪声和业务相关性',
  271. status: '待确认'
  272. };
  273. expanded.push(row);
  274. existingKeywords.add(keyword);
  275. currentCount++;
  276. templateIndex++;
  277. }
  278. });
  279. return expanded.map((row, index) => ({ ...row, id: `M${String(index + 1).padStart(3, '0')}` }));
  280. }
  281. function buildRows(args) {
  282. if (args.keywords) {
  283. const filePath = path.resolve(args.keywords);
  284. if (filePath.endsWith('.json')) return parseKeywordsJson(filePath);
  285. return parseKeywordsMarkdown(filePath);
  286. }
  287. return splitList(args.keyword).map(keyword => ({ keyword }));
  288. }
  289. function auditMatrix(rows) {
  290. const byHypothesis = {};
  291. HYPOTHESIS_TAGS.forEach(tag => {
  292. byHypothesis[tag] = [];
  293. });
  294. rows.forEach(row => {
  295. row.hypothesisTags.forEach(tag => {
  296. byHypothesis[tag] = byHypothesis[tag] || [];
  297. byHypothesis[tag].push(row.keyword);
  298. });
  299. });
  300. const warnings = [];
  301. Object.entries(byHypothesis).forEach(([tag, keywords]) => {
  302. const uniqueKeywords = Array.from(new Set(keywords));
  303. if (uniqueKeywords.length < 3) warnings.push(`${tag} 仅有 ${uniqueKeywords.length} 个关键词支撑,建议至少 3 个`);
  304. });
  305. const missingFields = rows
  306. .filter(row => !row.platform || !row.batch || !row.targetCount || !row.hypothesisTags.length)
  307. .map(row => row.id);
  308. if (missingFields.length) warnings.push(`以下矩阵行缺少必填字段:${missingFields.join(', ')}`);
  309. return { byHypothesis, warnings };
  310. }
  311. function renderMarkdown(rows, meta, audit) {
  312. const lines = [];
  313. lines.push('# 3. VOC 采集矩阵');
  314. lines.push('');
  315. lines.push('## 1. 采集目标');
  316. lines.push('');
  317. lines.push('| 字段 | 填写 |');
  318. lines.push('|---|---|');
  319. lines.push(`| 项目名称 | ${meta.project} |`);
  320. lines.push(`| 核心决策问题 | ${meta.question} |`);
  321. lines.push(`| 覆盖平台 | ${Array.from(new Set(rows.map(row => row.platform))).join('、')} |`);
  322. lines.push(`| 计划采集周期 | ${meta.period} |`);
  323. lines.push(`| 目标原始样本数 | ${rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0)} |`);
  324. lines.push(`| 目标有效 VOC 数 | ${rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0)} |`);
  325. lines.push(`| 采集负责人 | ${meta.owner} |`);
  326. lines.push('');
  327. lines.push('## 2. 批次策略');
  328. lines.push('');
  329. lines.push('| 批次 | 用途 | 建议占比 | 启动条件 | 完成标准 |');
  330. lines.push('|---|---|---:|---|---|');
  331. lines.push('| P0 | 核心关键词和高置信方向 | 20%-30% | Intake 与 H1-H3 已确认 | 核心问题有初步证据 |');
  332. lines.push('| P1 | 竞品、场景、功效、痛点扩展 | 40%-50% | P0 噪声可控 | 主要假设都有样本覆盖 |');
  333. lines.push('| P2 | 长尾、异常、补证与反证 | 20%-30% | P0/P1 发现空白或矛盾 | 关键结论有反证检查 |');
  334. lines.push('');
  335. lines.push('## 3. 采集矩阵');
  336. lines.push('');
  337. lines.push('| id | platform | keyword | keywordType | batch | targetCount | hypothesisTags | purpose | expectedSignal | risk | status |');
  338. lines.push('|---|---|---|---|---|---:|---|---|---|---|---|');
  339. rows.forEach(row => {
  340. lines.push(`| ${row.id} | ${row.platform} | ${row.keyword} | ${row.keywordType} | ${row.batch} | ${row.targetCount} | ${row.hypothesisTags.join(',')} | ${row.purpose} | ${row.expectedSignal} | ${row.risk} | ${row.status} |`);
  341. });
  342. lines.push('');
  343. lines.push('## 4. H1-H8 覆盖审计');
  344. lines.push('');
  345. lines.push('| 假设 | 关键词数 | 关键词 | 状态 |');
  346. lines.push('|---|---:|---|---|');
  347. Object.entries(audit.byHypothesis).sort().forEach(([tag, keywords]) => {
  348. lines.push(`| ${tag} | ${keywords.length} | ${Array.from(new Set(keywords)).join('、')} | ${keywords.length >= 3 ? '通过' : '需补充'} |`);
  349. });
  350. if (!Object.keys(audit.byHypothesis).length) lines.push('| - | 0 | - | 需补充 |');
  351. lines.push('');
  352. lines.push('## 5. 采集前检查');
  353. lines.push('');
  354. lines.push('| 检查项 | 标准 | 状态 |');
  355. lines.push('|---|---|---|');
  356. lines.push('| Token / credentials | 已预飞或确认可用 | 未检查 |');
  357. lines.push('| 单关键词小样本 | 每个平台至少试采 1 个关键词 | 未检查 |');
  358. lines.push('| 噪声评估 | 离题、高赞广告、重复内容可控 | 未检查 |');
  359. lines.push('| 去重口径 | ID、URL、正文相似度口径明确 | 未检查 |');
  360. lines.push('| 文件命名 | raw 文件命名与矩阵 id 可对应 | 未检查 |');
  361. lines.push('');
  362. lines.push('## 6. Builder 审计提示');
  363. lines.push('');
  364. if (audit.warnings.length) audit.warnings.forEach(warning => lines.push(`- **Warning**: ${warning}`));
  365. else lines.push('- **通过**:矩阵基础字段完整,假设覆盖达到当前规则。');
  366. return lines.join('\n') + '\n';
  367. }
  368. function main() {
  369. const args = parseArgs(process.argv.slice(2));
  370. if (args.help || (!args.keywords && !args.keyword) || !args.output) {
  371. console.log(usage());
  372. process.exit(args.help ? 0 : 1);
  373. }
  374. const outputDir = path.resolve(args.output);
  375. ensureDir(outputDir);
  376. const rawRows = buildRows(args);
  377. let rows = normalizeMatrixRows(rawRows, {
  378. platform: args.platform,
  379. target: args.target
  380. });
  381. if (args['expand-hypotheses']) {
  382. rows = expandHypotheses(rows, {
  383. platform: args.platform,
  384. target: args.target,
  385. category: args.category,
  386. project: args.project
  387. });
  388. }
  389. const audit = auditMatrix(rows);
  390. const meta = {
  391. project: args.project || path.basename(outputDir),
  392. question: args.question || '待确认核心决策问题',
  393. period: args.period || '待确认',
  394. owner: args.owner || '待确认'
  395. };
  396. const output = {
  397. metadata: {
  398. project: meta.project,
  399. generatedAt: new Date().toISOString(),
  400. platforms: Array.from(new Set(rows.map(row => row.platform))),
  401. keywordCount: rows.length,
  402. targetCount: rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0),
  403. warningCount: audit.warnings.length
  404. },
  405. matrix: rows,
  406. audit
  407. };
  408. fs.writeFileSync(path.join(outputDir, 'collection-matrix.json'), JSON.stringify(output, null, 2) + '\n', 'utf8');
  409. fs.writeFileSync(path.join(outputDir, '3.CollectionMatrix.md'), renderMarkdown(rows, meta, audit), 'utf8');
  410. console.log(JSON.stringify({
  411. outputDir,
  412. files: ['3.CollectionMatrix.md', 'collection-matrix.json'],
  413. keywordCount: rows.length,
  414. targetCount: output.metadata.targetCount,
  415. warnings: audit.warnings.length
  416. }, null, 2));
  417. }
  418. if (require.main === module) {
  419. try {
  420. main();
  421. } catch (error) {
  422. console.error(error.message);
  423. process.exit(1);
  424. }
  425. }
  426. module.exports = {
  427. parseArgs,
  428. parseKeywordsMarkdown,
  429. normalizeMatrixRows,
  430. auditMatrix,
  431. renderMarkdown
  432. };