// ==============================================================================
// <品类名> · 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,
};