// ============================================================================== // <品类名> · VOC 数据分析模块(模板) // ============================================================================== // 职责: // - 加载 _merged.json // - 提供 getEvidence() 主查询 API // - 提供 filterByPlatform/Hypothesis/Product 等辅助筛选 // - 判断 "substantive" 内容(过滤纯表情/水字) // ============================================================================== const fs = require('fs'); const path = require('path'); const CATEGORY = '<品类>'; // TODO: 改为实际目录名 // ========================================================== // 1. 常量 // ========================================================== const HYPOTHESES = { H1: '

', // TODO: 按 2.VOC深度思路 填写 H2: '

', H3: '

', H4: '

', H5: '

', H6: '
', H7: '', H8: '', }; const PLATFORMS = ['xhs', 'douyin', 'weibo', 'jd', 'tmall', 'amazon']; // ========================================================== // 2. 数据加载 // ========================================================== function loadMerged() { const fp = path.resolve(__dirname, '..', '..', 'docs', CATEGORY, 'raw', '_merged.json'); if (!fs.existsSync(fp)) { throw new Error(`_merged.json 不存在。请先跑 collect --merge。路径:${fp}`); } const data = JSON.parse(fs.readFileSync(fp, 'utf8')); if (!data.items || !Array.isArray(data.items)) { throw new Error(`_merged.json 格式错误:缺少 items 数组`); } return data; } // ========================================================== // 3. 内容实质性判断 // ========================================================== function isSubstantive(content, minChars = 10) { const s = String(content || '').trim(); if (s.length < minChars) return false; // 剥离 [表情] 标记和 unicode 表情 const stripped = s .replace(/\[[^\]]+\]/g, '') .replace(/[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, ''); return stripped.length >= Math.max(4, Math.floor(minChars / 2)); } // ========================================================== // 4. 基础筛选 // ========================================================== const filterByPlatform = (items, p) => items.filter((it) => it.platform === p); const filterByHypothesis = (items, h) => items.filter((it) => (it.hypotheses || []).includes(h)); const filterByProduct = (items, prod) => items.filter((it) => it.product === prod); const filterByKeyword = (items, kw) => items.filter((it) => it.keyword === kw); const filterBySentiment = (items, s) => items.filter((it) => it.sentiment === s); // ========================================================== // 5. 内容匹配 // ========================================================== function filterByContentMatch(items, regex) { if (!regex) return items; const re = regex instanceof RegExp ? regex : new RegExp(regex); return items.filter((it) => re.test(it.content || '')); } // ========================================================== // 6. 核心 API · getEvidence // ========================================================== /** * 从 items 池里取出证据卡 * @param {Array} items * @param {Object} opts * @param {string} [opts.keyword] * @param {string} [opts.product] * @param {string} [opts.platform] * @param {RegExp|string} [opts.contentMatch] * @param {Array} [opts.hypotheses] - 必含的 H 标签(AND 关系) * @param {string} [opts.sentiment] - positive / negative / neutral * @param {number} [opts.minChars=10] * @param {number} [opts.minLikes=0] * @param {number} [opts.top=5] * @param {number} [opts.seed=0] - 乱序种子(避免章节重复) * @returns {Array} */ function getEvidence(items, opts = {}) { let pool = items; if (opts.keyword) pool = filterByKeyword(pool, opts.keyword); if (opts.product) pool = filterByProduct(pool, opts.product); if (opts.platform) pool = filterByPlatform(pool, opts.platform); if (opts.sentiment) pool = filterBySentiment(pool, opts.sentiment); if (opts.contentMatch) pool = filterByContentMatch(pool, opts.contentMatch); if (opts.hypotheses?.length) { pool = pool.filter((it) => opts.hypotheses.every((h) => (it.hypotheses || []).includes(h))); } const minChars = opts.minChars ?? 10; pool = pool.filter((it) => isSubstantive(it.content, minChars)); if (opts.minLikes) pool = pool.filter((it) => (it.likes || 0) >= opts.minLikes); // 去重:前 40 字相同视为同内容 const seen = new Set(); pool = pool.filter((it) => { const key = String(it.content || '').slice(0, 40); if (seen.has(key)) return false; seen.add(key); return true; }); // 按 likes 降序 pool.sort((a, b) => (b.likes || 0) - (a.likes || 0)); const top = opts.top || 5; // 取前 top*2 再按 seed 乱序选 top(避免每章都抽到同几条) const top2 = pool.slice(0, top * 2); if (opts.seed) return shuffleWithSeed(top2, opts.seed).slice(0, top); return top2.slice(0, top); } // ========================================================== // 7. 工具 // ========================================================== function shuffleWithSeed(arr, seed) { const result = arr.slice(); let rnd = seed || 1; for (let i = result.length - 1; i > 0; i--) { rnd = (rnd * 9301 + 49297) % 233280; const j = Math.floor((rnd / 233280) * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } function fmtLikes(n) { if (!n) return '0'; if (n >= 10000) return (n / 10000).toFixed(1) + 'w'; if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; return String(n); } // ========================================================== // 8. 聚合统计 // ========================================================== function statsByPlatform(items) { const map = {}; for (const it of items) map[it.platform] = (map[it.platform] || 0) + 1; return map; } function statsByHypothesis(items) { const map = {}; for (const it of items) { for (const h of it.hypotheses || []) map[h] = (map[h] || 0) + 1; } return map; } function statsByProduct(items) { const map = {}; for (const it of items) map[it.product] = (map[it.product] || 0) + 1; return map; } function statsBySentiment(items) { const map = { positive: 0, negative: 0, neutral: 0 }; for (const it of items) map[it.sentiment || 'neutral']++; return map; } // ========================================================== // 9. 导出 // ========================================================== module.exports = { loadMerged, getEvidence, isSubstantive, filterByPlatform, filterByHypothesis, filterByProduct, filterByKeyword, filterBySentiment, filterByContentMatch, shuffleWithSeed, fmtLikes, statsByPlatform, statsByHypothesis, statsByProduct, statsBySentiment, HYPOTHESES, PLATFORMS, };