ks-filter.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. /**
  2. * KS-VOC 数据筛选脚本
  3. * 功能:读取 raw/KS/*.json,筛选高质量评论,按假设/主题分类,输出到 markdown
  4. *
  5. * 使用方式:
  6. * node ks-filter.js # 默认输出到 docs/KS-Chanel_analysis/5.数据筛选报告.md
  7. * node ks-filter.js --output ./test.md # 指定输出路径
  8. * node ks-filter.js --min-likes 5 # 最小点赞数(默认3)
  9. * node ks-filter.js --min-chars 10 # 最小评论字数(默认8)
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. // ============ 配置 ============
  14. const RAW_DIR = path.join(__dirname, '..', '..', 'raw', 'KS');
  15. const OUTPUT_PATH = process.argv.includes('--output')
  16. ? process.argv[process.argv.indexOf('--output') + 1]
  17. : path.join(__dirname, '..', '5.数据筛选报告.md');
  18. const MIN_LIKES = parseInt(process.argv[process.argv.indexOf('--min-likes') + 1]) || 3;
  19. const MIN_CHARS = parseInt(process.argv[process.argv.indexOf('--min-chars') + 1]) || 8;
  20. // 分类映射
  21. const CATEGORY_MAP = {
  22. '快聘': { hypothesis: 'H1', theme: '快聘核心场景', color: 'amber' },
  23. '直播带岗': { hypothesis: 'H1', theme: '快聘核心场景', color: 'amber' },
  24. '蓝领找工作': { hypothesis: 'H1', theme: '快聘核心场景', color: 'amber' },
  25. '工资靠谱': { hypothesis: 'H3', theme: '薪资透明度', color: 'green' },
  26. '真实薪资': { hypothesis: 'H3', theme: '薪资透明度', color: 'green' },
  27. '工资日结': { hypothesis: 'H8', theme: '薪资结算方式', color: 'purple' },
  28. '包吃包住': { hypothesis: 'H4', theme: '食宿条件', color: 'blue' },
  29. '当天入职': { hypothesis: 'H5', theme: '入职便捷性', color: 'blue' },
  30. '求职被骗': { hypothesis: 'H6', theme: '求职被骗焦虑', color: 'rose' },
  31. '入职被坑': { hypothesis: 'H6', theme: '求职被骗焦虑', color: 'rose' },
  32. '黑中介': { hypothesis: 'H7', theme: '黑中介痛点', color: 'rose' },
  33. '押金不退': { hypothesis: 'H7', theme: '黑中介痛点', color: 'rose' },
  34. };
  35. // 噪音词过滤
  36. const SPAM_PATTERNS = [
  37. /^[\[\]\🌹👍]+$/, // 纯emoji/符号
  38. /^(催|来|在|哪|要|报|这|那|啥|吗|么|呢|吧|啊|哈|噢)$/, // 单字重复
  39. /^嗯{2,}/, // 重复语气词
  40. /^(支持|感谢|点赞|收藏|分享)+$/, // 灌水词
  41. ];
  42. // ============ 工具函数 ============
  43. function isValidComment(comment) {
  44. const content = comment.content?.trim() || '';
  45. // 长度过滤
  46. if (content.length < MIN_CHARS) return false;
  47. // 过滤纯数字/字母
  48. if (/^[\d\w\s\.,]+$/.test(content)) return false;
  49. // 噪音词过滤
  50. for (const pattern of SPAM_PATTERNS) {
  51. if (pattern.test(content)) return false;
  52. }
  53. // 过滤纯@提及
  54. if (/^@{[\w]+}$/.test(content.trim())) return false;
  55. // 过滤太短的疑问(可能是灌水)
  56. if (content.length < 10 && content.includes('?') && content.length < 8) return false;
  57. return true;
  58. }
  59. function extractKeyword(filename) {
  60. return path.basename(filename, '.json');
  61. }
  62. function formatDate(timestamp) {
  63. return new Date(timestamp).toLocaleDateString('zh-CN', {
  64. year: 'numeric',
  65. month: '2-digit',
  66. day: '2-digit',
  67. });
  68. }
  69. function escapeMarkdown(text) {
  70. return text
  71. .replace(/\\/g, '\\\\')
  72. .replace(/`/g, '\\`')
  73. .replace(/\*/g, '\\*')
  74. .replace(/_/g, '\\_')
  75. .replace(/\[/g, '\\[')
  76. .replace(/\]/g, '\\]');
  77. }
  78. // ============ 读取数据 ============
  79. function loadAllData() {
  80. const files = fs.readdirSync(RAW_DIR).filter(f => f.endsWith('.json') && f !== '-.json');
  81. const allData = [];
  82. for (const file of files) {
  83. const filepath = path.join(RAW_DIR, file);
  84. try {
  85. const data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
  86. data._file = file;
  87. allData.push(data);
  88. } catch (e) {
  89. console.warn(`⚠️ 跳过损坏文件: ${file}`);
  90. }
  91. }
  92. return allData;
  93. }
  94. // ============ 筛选逻辑 ============
  95. function filterComments(data) {
  96. const validComments = [];
  97. for (const [videoId, comments] of Object.entries(data.comments || {})) {
  98. for (const comment of comments) {
  99. if (isValidComment(comment) && (comment.like_count || 0) >= MIN_LIKES) {
  100. validComments.push({
  101. ...comment,
  102. _video_id: videoId,
  103. _video_caption: data.top_videos?.find(v => v.id === videoId)?.caption || '',
  104. });
  105. }
  106. }
  107. }
  108. return validComments;
  109. }
  110. // ============ 生成报告 ============
  111. function generateMarkdown(allFilteredData, stats) {
  112. const now = new Date().toLocaleString('zh-CN');
  113. let md = '';
  114. md += `# 快手快聘 · 数据筛选报告\n\n`;
  115. md += `> **生成时间**:${now} \n`;
  116. md += `> **筛选条件**:点赞 ≥ ${MIN_LIKES} | 字数 ≥ ${MIN_CHARS} | 噪音过滤已启用 \n`;
  117. md += `> **数据来源**:\`${RAW_DIR.replace(/\\/g, '\\\\')}\`\n\n`;
  118. md += `---\n\n`;
  119. // 统计概览
  120. md += `## 一、筛选统计\n\n`;
  121. md += `| 指标 | 数值 |\n`;
  122. md += `| --- | --- |\n`;
  123. md += `| 关键词文件数 | ${stats.totalFiles} |\n`;
  124. md += `| 视频总数 | ${stats.totalVideos} |\n`;
  125. md += `| 原始评论总数 | ${stats.totalComments} |\n`;
  126. md += `| 筛选后评论数 | ${stats.filteredComments} |\n`;
  127. md += `| 过滤掉 | ${stats.totalComments - stats.filteredComments} (${((1 - stats.filteredComments / stats.totalComments) * 100).toFixed(1)}%) |\n\n`;
  128. // 按假设分组
  129. const byHypothesis = {};
  130. for (const item of allFilteredData) {
  131. const cat = CATEGORY_MAP[item._keyword] || { hypothesis: 'H?', theme: '未分类', color: 'gray' };
  132. const h = cat.hypothesis;
  133. if (!byHypothesis[h]) {
  134. byHypothesis[h] = { comments: [], theme: cat.theme, color: cat.color };
  135. }
  136. byHypothesis[h].comments.push(item);
  137. }
  138. md += `## 二、按假设分组(H1-H8)\n\n`;
  139. const sortedH = Object.keys(byHypothesis).sort();
  140. for (const h of sortedH) {
  141. const group = byHypothesis[h];
  142. const icon = h === 'H1' ? '💼' : h === 'H3' ? '💰' : h === 'H6' ? '⚠️' : '📌';
  143. md += `### ${icon} ${h} · ${group.theme}\n\n`;
  144. md += `**评论数:${group.comments.length}条**\n\n`;
  145. // 按点赞数排序,取前10
  146. const topComments = group.comments
  147. .sort((a, b) => (b.like_count || 0) - (a.like_count || 0))
  148. .slice(0, 10);
  149. for (const comment of topComments) {
  150. const likes = comment.like_count || 0;
  151. const date = formatDate(comment.create_time);
  152. const content = escapeMarkdown(comment.content);
  153. const ip = comment.ip_location ? ` [${comment.ip_location}]` : '';
  154. md += `- **快手 ♥${likes}**${ip} "${content}"\n`;
  155. }
  156. md += `\n`;
  157. }
  158. // 高赞评论TOP20
  159. md += `## 三、高赞评论 TOP20\n\n`;
  160. md += `| 排名 | 平台 | 点赞 | 评论内容摘要 | 关键词 |\n`;
  161. md += `| --- | --- | --- | --- | --- |\n`;
  162. const allTop = allFilteredData
  163. .sort((a, b) => (b.like_count || 0) - (a.like_count || 0))
  164. .slice(0, 20);
  165. allTop.forEach((c, i) => {
  166. const content = c.content.length > 40 ? c.content.slice(0, 40) + '...' : c.content;
  167. md += `| ${i + 1} | 快手 | ♥${c.like_count || 0} | ${escapeMarkdown(content)} | ${c._keyword} |\n`;
  168. });
  169. md += `\n`;
  170. // 原始数据样例
  171. md += `## 四、各关键词数据样例\n\n`;
  172. for (const data of stats.keywordDetails) {
  173. if (data.filteredCount === 0) continue;
  174. const cat = CATEGORY_MAP[data.keyword] || { theme: '未分类', color: 'gray' };
  175. md += `### ${data.keyword} · ${cat.theme}\n\n`;
  176. md += `> 视频数: ${data.videoCount} | 评论数: ${data.commentCount} | 有效评论: ${data.filteredCount}\n\n`;
  177. const top3 = data.comments
  178. .sort((a, b) => (b.like_count || 0) - (a.like_count || 0))
  179. .slice(0, 3);
  180. for (const c of top3) {
  181. const likes = c.like_count || 0;
  182. const content = escapeMarkdown(c.content);
  183. md += `- **♥${likes}** "${content}"\n`;
  184. }
  185. md += `\n`;
  186. }
  187. md += `---\n\n`;
  188. md += `*报告由 ks-filter.js 自动生成 · ${now}*\n`;
  189. return md;
  190. }
  191. // ============ 主流程 ============
  192. function main() {
  193. console.log(`\n📊 KS-VOC 数据筛选脚本`);
  194. console.log(` 筛选条件: 点赞≥${MIN_LIKES}, 字数≥${MIN_CHARS}`);
  195. console.log(` 数据源: ${RAW_DIR}`);
  196. console.log(` 输出: ${OUTPUT_PATH}\n`);
  197. // 1. 读取所有JSON
  198. console.log(`📂 读取数据文件...`);
  199. const allData = loadAllData();
  200. console.log(` 读取了 ${allData.length} 个关键词文件`);
  201. // 2. 统计
  202. const stats = {
  203. totalFiles: allData.length,
  204. totalVideos: 0,
  205. totalComments: 0,
  206. filteredComments: 0,
  207. keywordDetails: [],
  208. };
  209. // 3. 筛选
  210. const allFilteredData = [];
  211. for (const data of allData) {
  212. const keyword = extractKeyword(data._file);
  213. const cat = CATEGORY_MAP[keyword] || { hypothesis: 'H?', theme: '未分类', color: 'gray' };
  214. const videoCount = data.top_videos?.length || 0;
  215. const comments = data.comments || {};
  216. const commentList = Object.values(comments).flat();
  217. const commentCount = commentList.length;
  218. stats.totalVideos += videoCount;
  219. stats.totalComments += commentCount;
  220. // 筛选
  221. const filteredComments = filterComments(data);
  222. stats.filteredComments += filteredComments.length;
  223. // 附加关键词信息
  224. for (const c of filteredComments) {
  225. c._keyword = keyword;
  226. }
  227. allFilteredData.push(...filteredComments);
  228. stats.keywordDetails.push({
  229. keyword,
  230. theme: cat.theme,
  231. videoCount,
  232. commentCount,
  233. filteredCount: filteredComments.length,
  234. comments: filteredComments,
  235. });
  236. console.log(` ${keyword}: ${commentCount} → ${filteredComments.length} 条有效`);
  237. }
  238. console.log(`\n📈 统计结果:`);
  239. console.log(` 总视频: ${stats.totalVideos}`);
  240. console.log(` 总评论: ${stats.totalComments}`);
  241. console.log(` 有效评论: ${stats.filteredComments}`);
  242. console.log(` 过滤率: ${((1 - stats.filteredComments / stats.totalComments) * 100).toFixed(1)}%`);
  243. // 4. 生成Markdown
  244. console.log(`\n📝 生成Markdown报告...`);
  245. const markdown = generateMarkdown(allFilteredData, stats);
  246. // 5. 写入文件
  247. const outputDir = path.dirname(OUTPUT_PATH);
  248. if (!fs.existsSync(outputDir)) {
  249. fs.mkdirSync(outputDir, { recursive: true });
  250. }
  251. fs.writeFileSync(OUTPUT_PATH, '\uFEFF' + markdown, 'utf-8'); // BOM for Chinese
  252. console.log(`\n✅ 报告已生成: ${OUTPUT_PATH}`);
  253. console.log(`\n---`);
  254. console.log(`📌 下一步:`);
  255. console.log(` 1. 审查 docs/KS-Chanel_analysis/5.数据筛选报告.md`);
  256. console.log(` 2. 填充 VOC 洞察报告的证据锚点`);
  257. console.log(` 3. 运行 gen-report.js 生成可视化报告\n`);
  258. }
  259. main();