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/collection-matrix-builder.js --keywords --output [--project ] [--platform ] [--target 20]', ' node scripts/tools/collection-matrix-builder.js --keyword "kw1,kw2" --output [--platform 小红书]', ' add --expand-hypotheses to auto-fill H1-H8 to at least 3 keywords each', '', 'Outputs:', ' 3.CollectionMatrix.md', ' collection-matrix.json' ].join('\n'); } const HYPOTHESIS_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8']; const HYPOTHESIS_KEYWORD_TEMPLATES = { H1: [ { keyword: '{category} 市场机会', type: '品类词', batch: 'P0' }, { keyword: '{category} 值不值得买', type: '长尾问题词', batch: 'P0' }, { keyword: '{category} 趋势', type: '品类词', batch: 'P1' } ], H2: [ { keyword: '{category} 使用场景', type: '场景词', batch: 'P0' }, { keyword: '{category} 痛点', type: '痛点场景词', batch: 'P0' }, { keyword: '{category} 人群', type: '场景词', batch: 'P1' } ], H3: [ { keyword: '{category} 怎么选', type: '长尾问题词', batch: 'P0' }, { keyword: '{category} 配方', type: '产品词', batch: 'P1' }, { keyword: '{category} 产品缺点', type: '长尾问题词', batch: 'P2' } ], H4: [ { keyword: '{category} 价格', type: '价格词', batch: 'P1' }, { keyword: '{category} 规格', type: '产品词', batch: 'P1' }, { keyword: '{category} 性价比', type: '长尾问题词', batch: 'P1' } ], H5: [ { keyword: '{category} 种草', type: '平台内容词', batch: 'P1' }, { keyword: '{category} 达人推荐', type: '平台内容词', batch: 'P1' }, { keyword: '{category} 内容笔记', type: '内容词', batch: 'P1' } ], H6: [ { keyword: '{category} 竞品', type: '竞品词', batch: 'P1' }, { keyword: '{category} 替代品', type: '竞品词', batch: 'P1' }, { keyword: '{category} 对比', type: '竞品词', batch: 'P1' } ], H7: [ { keyword: '{category} 卖点', type: '话术词', batch: 'P1' }, { keyword: '{category} 定位', type: '话术词', batch: 'P1' }, { keyword: '{category} 话术', type: '话术词', batch: 'P2' } ], H8: [ { keyword: '{category} 复购', type: '行动词', batch: 'P2' }, { keyword: '{category} 推荐', type: '行动词', batch: 'P2' }, { keyword: '{category} 转化', type: '行动词', batch: 'P2' } ] }; function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value; return String(value).split(/[,,;;]/).map(item => item.trim()).filter(Boolean); } function normalizeHeader(header) { return header.trim().replace(/`/g, '').toLowerCase(); } function parseMarkdownTables(text) { const lines = text.split(/\r?\n/); const tables = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (!/^\s*\|.*\|\s*$/.test(line) || !lines[i + 1] || !/^\s*\|\s*:?-{3,}:?\s*\|/.test(lines[i + 1])) { i++; continue; } const headers = splitMarkdownRow(line).map(normalizeHeader); const rows = []; i += 2; while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { const values = splitMarkdownRow(lines[i]); const row = {}; headers.forEach((header, index) => { row[header] = values[index] ? values[index].trim() : ''; }); rows.push(row); i++; } tables.push({ headers, rows }); } return tables; } function splitMarkdownRow(line) { return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim()); } function rowValue(row, keys) { for (const key of keys) { const normalized = normalizeHeader(key); if (row[normalized] !== undefined && row[normalized] !== '') return row[normalized]; } return ''; } function parseKeywordsMarkdown(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const tables = parseMarkdownTables(text); const keywordRows = []; tables.forEach(table => { const hasKeyword = table.headers.some(header => ['keyword', '关键词'].includes(header)); const hasBatch = table.headers.some(header => ['batch', '批次'].includes(header)); if (!hasKeyword || !hasBatch) return; table.rows.forEach(row => { const keyword = rowValue(row, ['keyword', '关键词']); if (!keyword) return; keywordRows.push({ platform: rowValue(row, ['platform', '平台']), keyword, keywordType: rowValue(row, ['keywordType', '关键词类型', '类型']), batch: rowValue(row, ['batch', '批次']), targetCount: rowValue(row, ['targetCount', '目标数量', '目标样本']), hypothesisTags: rowValue(row, ['hypothesisTags', '假设标签', 'H1-H8']), purpose: rowValue(row, ['purpose', '目的', '用途']), expectedSignal: rowValue(row, ['expectedSignal', '预期信号', '预期观察']), risk: rowValue(row, ['risk', '风险', '噪声']) }); }); }); return keywordRows; } function parseKeywordsJson(filePath) { const data = readJson(filePath); if (Array.isArray(data)) return data; if (Array.isArray(data.matrix)) return data.matrix; if (Array.isArray(data.keywords)) return data.keywords; return []; } function inferKeywordType(keyword) { if (/怎么选|有没有用|副作用|避坑|对比|测评|review|best/i.test(keyword)) return '长尾问题词'; if (/竞品|替代|vs|对标|品牌|测评/i.test(keyword)) return '竞品词'; if (/开学|熬夜|送礼|旅行|饭局|便秘|腹胀|场景/i.test(keyword)) return '场景词'; return '产品词'; } function inferBatch(keyword, index) { if (/核心|怎么选|痛点|便秘|腹胀|差评/i.test(keyword)) return 'P0'; if (/竞品|测评|替代|场景|价格|规格/i.test(keyword)) return 'P1'; return index < 3 ? 'P0' : 'P2'; } function inferHypothesisTags(keyword, type) { const tags = new Set(); if (/痛点|焦虑|便秘|腹胀|差评|避坑/i.test(keyword)) tags.add('H2'); if (/怎么选|配方|成分|参数|菌株|功能|产品/i.test(keyword)) tags.add('H3'); if (/价格|规格|贵|便宜|性价比/i.test(keyword)) tags.add('H4'); if (/平台|小红书|抖音|内容|种草|达人/i.test(keyword)) tags.add('H5'); if (/竞品|替代|对标|vs|品牌/i.test(keyword)) tags.add('H6'); if (/话术|定位|概念|卖点|表达/i.test(keyword)) tags.add('H7'); if (/行动|复购|推荐|转化|增长/i.test(keyword)) tags.add('H8'); if (/品类|趋势|市场|机会/i.test(keyword)) tags.add('H1'); if (tags.size === 0) { if (/竞品/.test(type)) tags.add('H6'); else if (/场景/.test(type)) tags.add('H2'); else if (/问题/.test(type)) tags.add('H2'); else tags.add('H3'); } return Array.from(tags); } function inferPurpose(keyword, type, tags) { if (tags.includes('H2')) return `识别「${keyword}」相关的用户痛点、触发场景与真实表达`; if (tags.includes('H6')) return `分析「${keyword}」相关的竞品、替代方案和用户比较标准`; if (tags.includes('H4')) return `观察「${keyword}」相关的价格、规格和价值感判断`; if (tags.includes('H7')) return `提炼「${keyword}」相关的定位、卖点和话术表达`; return `验证「${keyword}」相关的品类、产品和购买决策信号`; } function inferExpectedSignal(type, tags) { const signals = []; if (tags.includes('H1')) signals.push('品类机会'); if (tags.includes('H2')) signals.push('高频场景', '痛点原声'); if (tags.includes('H3')) signals.push('产品参数', '购买标准'); if (tags.includes('H4')) signals.push('价格带', '规格偏好'); if (tags.includes('H5')) signals.push('内容形式', '平台决策链路'); if (tags.includes('H6')) signals.push('竞品优劣势', '替代原因'); if (tags.includes('H7')) signals.push('用户复述话术', '定位表达'); if (tags.includes('H8')) signals.push('行动建议', '验证指标'); return Array.from(new Set(signals)).join('、') || `${type || '关键词'}相关 VOC`; } function normalizeMatrixRows(rows, options) { return rows .filter(row => row.keyword || row.keyword === undefined) .map((row, index) => { const keyword = row.keyword || row.Keyword || row.关键词 || ''; const keywordType = row.keywordType || row['关键词类型'] || row.type || inferKeywordType(keyword); const batch = row.batch || row.批次 || inferBatch(keyword, index); const hypothesisTags = splitList(row.hypothesisTags || row['假设标签'] || row.tags || row.H || '').length ? splitList(row.hypothesisTags || row['假设标签'] || row.tags || row.H || '') : inferHypothesisTags(keyword, keywordType); return { id: row.id || `M${String(index + 1).padStart(3, '0')}`, platform: row.platform || row.平台 || options.platform || '小红书', keyword, keywordType, batch, targetCount: Number(row.targetCount || row['目标数量'] || row.count || options.target || 20), hypothesisTags, purpose: row.purpose || row.目的 || inferPurpose(keyword, keywordType, hypothesisTags), expectedSignal: row.expectedSignal || row['预期信号'] || inferExpectedSignal(keywordType, hypothesisTags), risk: row.risk || row.风险 || '需检查离题、广告、重复和平台偏差', status: row.status || '未开始' }; }) .filter(row => row.keyword); } function nextMatrixId(rows, offset = 1) { const max = rows.reduce((current, row) => { const match = String(row.id || '').match(/^M(\d+)$/); return match ? Math.max(current, Number(match[1])) : current; }, 0); return `M${String(max + offset).padStart(3, '0')}`; } function expandHypotheses(rows, options) { const expanded = rows.map(row => ({ ...row, hypothesisTags: [...row.hypothesisTags] })); const category = options.category || options.project || '品类'; const existingKeywords = new Set(expanded.map(row => row.keyword)); HYPOTHESIS_TAGS.forEach(tag => { let currentCount = expanded.filter(row => row.hypothesisTags.includes(tag)).length; let templateIndex = 0; while (currentCount < 3) { const templates = HYPOTHESIS_KEYWORD_TEMPLATES[tag] || []; const template = templates[templateIndex % templates.length]; const baseKeyword = template.keyword.replace('{category}', category); let keyword = baseKeyword; let suffix = 2; while (existingKeywords.has(keyword)) { keyword = `${baseKeyword} ${suffix}`; suffix++; } const row = { id: nextMatrixId(expanded), platform: options.platform || '小红书', keyword, keywordType: template.type, batch: template.batch, targetCount: Number(options.target || 20), hypothesisTags: [tag], purpose: inferPurpose(keyword, template.type, [tag]), expectedSignal: inferExpectedSignal(template.type, [tag]), risk: '自动补齐关键词,执行前需人工确认搜索噪声和业务相关性', status: '待确认' }; expanded.push(row); existingKeywords.add(keyword); currentCount++; templateIndex++; } }); return expanded.map((row, index) => ({ ...row, id: `M${String(index + 1).padStart(3, '0')}` })); } function buildRows(args) { if (args.keywords) { const filePath = path.resolve(args.keywords); if (filePath.endsWith('.json')) return parseKeywordsJson(filePath); return parseKeywordsMarkdown(filePath); } return splitList(args.keyword).map(keyword => ({ keyword })); } function auditMatrix(rows) { const byHypothesis = {}; HYPOTHESIS_TAGS.forEach(tag => { byHypothesis[tag] = []; }); rows.forEach(row => { row.hypothesisTags.forEach(tag => { byHypothesis[tag] = byHypothesis[tag] || []; byHypothesis[tag].push(row.keyword); }); }); const warnings = []; Object.entries(byHypothesis).forEach(([tag, keywords]) => { const uniqueKeywords = Array.from(new Set(keywords)); if (uniqueKeywords.length < 3) warnings.push(`${tag} 仅有 ${uniqueKeywords.length} 个关键词支撑,建议至少 3 个`); }); const missingFields = rows .filter(row => !row.platform || !row.batch || !row.targetCount || !row.hypothesisTags.length) .map(row => row.id); if (missingFields.length) warnings.push(`以下矩阵行缺少必填字段:${missingFields.join(', ')}`); return { byHypothesis, warnings }; } function renderMarkdown(rows, meta, audit) { const lines = []; lines.push('# 3. VOC 采集矩阵'); lines.push(''); lines.push('## 1. 采集目标'); lines.push(''); lines.push('| 字段 | 填写 |'); lines.push('|---|---|'); lines.push(`| 项目名称 | ${meta.project} |`); lines.push(`| 核心决策问题 | ${meta.question} |`); lines.push(`| 覆盖平台 | ${Array.from(new Set(rows.map(row => row.platform))).join('、')} |`); lines.push(`| 计划采集周期 | ${meta.period} |`); lines.push(`| 目标原始样本数 | ${rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0)} |`); lines.push(`| 目标有效 VOC 数 | ${rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0)} |`); lines.push(`| 采集负责人 | ${meta.owner} |`); lines.push(''); lines.push('## 2. 批次策略'); lines.push(''); lines.push('| 批次 | 用途 | 建议占比 | 启动条件 | 完成标准 |'); lines.push('|---|---|---:|---|---|'); lines.push('| P0 | 核心关键词和高置信方向 | 20%-30% | Intake 与 H1-H3 已确认 | 核心问题有初步证据 |'); lines.push('| P1 | 竞品、场景、功效、痛点扩展 | 40%-50% | P0 噪声可控 | 主要假设都有样本覆盖 |'); lines.push('| P2 | 长尾、异常、补证与反证 | 20%-30% | P0/P1 发现空白或矛盾 | 关键结论有反证检查 |'); lines.push(''); lines.push('## 3. 采集矩阵'); lines.push(''); lines.push('| id | platform | keyword | keywordType | batch | targetCount | hypothesisTags | purpose | expectedSignal | risk | status |'); lines.push('|---|---|---|---|---|---:|---|---|---|---|---|'); rows.forEach(row => { 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} |`); }); lines.push(''); lines.push('## 4. H1-H8 覆盖审计'); lines.push(''); lines.push('| 假设 | 关键词数 | 关键词 | 状态 |'); lines.push('|---|---:|---|---|'); Object.entries(audit.byHypothesis).sort().forEach(([tag, keywords]) => { lines.push(`| ${tag} | ${keywords.length} | ${Array.from(new Set(keywords)).join('、')} | ${keywords.length >= 3 ? '通过' : '需补充'} |`); }); if (!Object.keys(audit.byHypothesis).length) lines.push('| - | 0 | - | 需补充 |'); lines.push(''); lines.push('## 5. 采集前检查'); lines.push(''); lines.push('| 检查项 | 标准 | 状态 |'); lines.push('|---|---|---|'); lines.push('| Token / credentials | 已预飞或确认可用 | 未检查 |'); lines.push('| 单关键词小样本 | 每个平台至少试采 1 个关键词 | 未检查 |'); lines.push('| 噪声评估 | 离题、高赞广告、重复内容可控 | 未检查 |'); lines.push('| 去重口径 | ID、URL、正文相似度口径明确 | 未检查 |'); lines.push('| 文件命名 | raw 文件命名与矩阵 id 可对应 | 未检查 |'); lines.push(''); lines.push('## 6. Builder 审计提示'); lines.push(''); if (audit.warnings.length) audit.warnings.forEach(warning => lines.push(`- **Warning**: ${warning}`)); else lines.push('- **通过**:矩阵基础字段完整,假设覆盖达到当前规则。'); return lines.join('\n') + '\n'; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || (!args.keywords && !args.keyword) || !args.output) { console.log(usage()); process.exit(args.help ? 0 : 1); } const outputDir = path.resolve(args.output); ensureDir(outputDir); const rawRows = buildRows(args); let rows = normalizeMatrixRows(rawRows, { platform: args.platform, target: args.target }); if (args['expand-hypotheses']) { rows = expandHypotheses(rows, { platform: args.platform, target: args.target, category: args.category, project: args.project }); } const audit = auditMatrix(rows); const meta = { project: args.project || path.basename(outputDir), question: args.question || '待确认核心决策问题', period: args.period || '待确认', owner: args.owner || '待确认' }; const output = { metadata: { project: meta.project, generatedAt: new Date().toISOString(), platforms: Array.from(new Set(rows.map(row => row.platform))), keywordCount: rows.length, targetCount: rows.reduce((sum, row) => sum + Number(row.targetCount || 0), 0), warningCount: audit.warnings.length }, matrix: rows, audit }; fs.writeFileSync(path.join(outputDir, 'collection-matrix.json'), JSON.stringify(output, null, 2) + '\n', 'utf8'); fs.writeFileSync(path.join(outputDir, '3.CollectionMatrix.md'), renderMarkdown(rows, meta, audit), 'utf8'); console.log(JSON.stringify({ outputDir, files: ['3.CollectionMatrix.md', 'collection-matrix.json'], keywordCount: rows.length, targetCount: output.metadata.targetCount, warnings: audit.warnings.length }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { parseArgs, parseKeywordsMarkdown, normalizeMatrixRows, auditMatrix, renderMarkdown };