audit.template.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. #!/usr/bin/env node
  2. // ==============================================================================
  3. // <品类名> · VOC 质量审计脚本(模板)
  4. // ==============================================================================
  5. // 检查:
  6. // 1. 各章节核心 VOC 证据主题一致性
  7. // 2. H1-H8 假设覆盖(应每条 ≥ 100)
  8. // 3. 重复内容(前 40 字相同)
  9. // 4. 高赞跳题(无相关词 & likes > 100)
  10. // 5. HTML 产出质量(大小 / sections / Coming Soon)
  11. // 6. VOC 证据卡数量
  12. // 7. 决策列表数量
  13. // 8. 空 section 检查
  14. // 9. 封面数字与 merged 一致性
  15. // ==============================================================================
  16. const fs = require('fs');
  17. const path = require('path');
  18. const CATEGORY = '<品类>'; // TODO
  19. const REPORT_HTML = path.resolve(__dirname, '..', '..', 'reports', `<品类>-voc-insight-report.html`);
  20. const LA = require('./<品类>-analyze.js');
  21. const data = LA.loadMerged();
  22. const items = data.items;
  23. // 通用 dump 工具
  24. function dump(title, arr, maxShow = 5) {
  25. console.log('\n' + '─'.repeat(60));
  26. console.log(`【${title}】`);
  27. console.log('─'.repeat(60));
  28. arr.slice(0, maxShow).forEach((it, i) => {
  29. console.log(` [${i + 1}] ${(it.platform || '').toUpperCase()} · ${it.keyword} · ${it.nickname} · ♥${it.likes}`);
  30. console.log(` 「${String(it.content || '').slice(0, 160)}」`);
  31. });
  32. }
  33. console.log('==========================================');
  34. console.log(`<品类名> · VOC 质量审计`);
  35. console.log(`总条数:${items.length} / 平台:${Object.keys(data.meta?.platforms || {}).join('+')}`);
  36. console.log('==========================================');
  37. // ==========================================================
  38. // 1. 各章节核心 VOC 抽查
  39. // TODO: 按实际章节填写探针
  40. // ==========================================================
  41. const CH_PROBES = [
  42. {
  43. ch: 'Ch1.1 <章节主题>',
  44. opts: { keyword: '<本品名>', contentMatch: /<相关词>/, top: 3 },
  45. },
  46. {
  47. ch: 'Ch2.1 <竞品主题>',
  48. opts: { keyword: '<竞品名>', contentMatch: /<场景词>/, top: 3 },
  49. },
  50. // ... 每章至少 1-2 项探针
  51. ];
  52. console.log('\n▶ 章节 VOC 证据一致性抽查');
  53. for (const p of CH_PROBES) {
  54. const hit = LA.getEvidence(items, p.opts);
  55. const ok = hit.length >= 3;
  56. console.log(` ${ok ? '✓' : '⚠️'} ${p.ch}: ${hit.length} 条 ${ok ? '' : '← 证据不足'}`);
  57. }
  58. // ==========================================================
  59. // 2. H1-H8 假设覆盖
  60. // ==========================================================
  61. console.log('\n▶ H1-H8 假设覆盖检查');
  62. for (let i = 1; i <= 8; i++) {
  63. const n = items.filter((it) => (it.hypotheses || []).includes('H' + i)).length;
  64. const status = n >= 100 ? '✓' : n >= 50 ? '⚠️' : '❌';
  65. console.log(` H${i}: ${String(n).padStart(5)} 条 ${status} ${n < 100 ? '(建议补采)' : ''}`);
  66. }
  67. // ==========================================================
  68. // 3. 重复检查(前 40 字相同)
  69. // ==========================================================
  70. const bucket = new Map();
  71. for (const it of items) {
  72. const key = String(it.content || '').slice(0, 40);
  73. if (!bucket.has(key)) bucket.set(key, []);
  74. bucket.get(key).push(it);
  75. }
  76. const dupGroups = [...bucket.values()].filter((g) => g.length > 1).sort((a, b) => b.length - a.length);
  77. console.log(`\n▶ 重复组 ${dupGroups.length} 组(前 5):`);
  78. dupGroups.slice(0, 5).forEach((g, i) => {
  79. console.log(` [${i + 1}] ${g.length} 条 · 「${String(g[0].content || '').slice(0, 60)}」`);
  80. console.log(` kw: ${[...new Set(g.map((x) => x.keyword))].slice(0, 5).join(', ')}`);
  81. });
  82. // ==========================================================
  83. // 4. 跳题检查(高赞 & 无相关词)
  84. // TODO: 修改 relevantRe 为品类相关正则
  85. // ==========================================================
  86. const relevantRe = /<品类词 1>|<品类词 2>|<人群词>|<场景词>/;
  87. const offtopic = items.filter((it) => {
  88. const c = it.content || '';
  89. if (c.length < 15) return false;
  90. const hasRelevant = relevantRe.test(c);
  91. return !hasRelevant && (it.likes || 0) >= 100;
  92. });
  93. console.log(`\n▶ 高赞跳题 VOC(likes ≥ 100 且无相关词)${offtopic.length} 条(前 10):`);
  94. offtopic.sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, 10).forEach((it, i) => {
  95. console.log(` [${i + 1}] ♥${it.likes} · ${it.keyword} · ${it.nickname}: ${String(it.content || '').slice(0, 100)}`);
  96. });
  97. // ==========================================================
  98. // 5-9. HTML 产出质量检查
  99. // ==========================================================
  100. console.log('\n▶ HTML 产出质量');
  101. if (!fs.existsSync(REPORT_HTML)) {
  102. console.log(` ❌ HTML 不存在: ${REPORT_HTML}`);
  103. console.log(` 请先跑 node scripts/tools/gen-<品类>.js`);
  104. } else {
  105. const html = fs.readFileSync(REPORT_HTML, 'utf8');
  106. const sizeKB = (html.length / 1024).toFixed(1);
  107. const sections = (html.match(/<section class="report-section"/g) || []).length;
  108. const comingSoon = (html.match(/Coming in Batch|COMING IN BATCH/gi) || []).length;
  109. const vocCards = (html.match(/border-left:3px solid/g) || []).length;
  110. const decisionLists = (html.match(/落地决策|🎯/g) || []).length;
  111. const emptySection = (html.match(/<section[^>]*>\s*<div class="section-inner">\s*<\/div>\s*<\/section>/g) || []).length;
  112. console.log(` 大小: ${sizeKB} KB ${sizeKB < 300 ? '❌ 过小' : sizeKB > 1000 ? '⚠️ 过大' : '✓'}`);
  113. console.log(` sections: ${sections}`);
  114. console.log(` Coming Soon: ${comingSoon} ${comingSoon > 0 ? '❌ 应为 0' : '✓'}`);
  115. console.log(` VOC 证据卡: ${vocCards} ${vocCards < 60 ? '❌ 过少' : vocCards < 100 ? '⚠️' : '✓'}`);
  116. console.log(` 决策列表: ${decisionLists} ${decisionLists < 20 ? '⚠️ 过少' : '✓'}`);
  117. console.log(` 空 section: ${emptySection} ${emptySection > 0 ? '❌' : '✓'}`);
  118. // 9. 封面数字 vs merged
  119. const coverMatch = html.match(/(\d{3,5})\s*条真实 VOC/);
  120. if (coverMatch && Math.abs(parseInt(coverMatch[1]) - items.length) > 10) {
  121. console.log(` ⚠️ 封面 ${coverMatch[1]} 条 ≠ merged ${items.length} 条`);
  122. }
  123. }
  124. // ==========================================================
  125. // 总结
  126. // ==========================================================
  127. const errs = (dupGroups.filter((g) => g.length > 10).length)
  128. + (offtopic.length > 50 ? 1 : 0);
  129. console.log('\n==========================================');
  130. if (errs === 0) {
  131. console.log('✅ 审计完成 · 未发现严重问题');
  132. } else {
  133. console.log(`⚠️ 审计完成 · 发现 ${errs} 项红灯,建议处理后再交付`);
  134. }
  135. console.log('==========================================');