| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- 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 <keywords.md|json> --output <out-dir> [--project <name>] [--platform <name>] [--target 20]',
- ' node scripts/tools/collection-matrix-builder.js --keyword "kw1,kw2" --output <out-dir> [--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
- };
|