从
_merged.json→ 8 章 × 28-42 子章 × HTML —— 这是工作流中最耗时也最能体现价值的阶段。
输入:docs/<品类>/raw/_merged.json(1500-8000 条统一 VOC)
输出:reports/<品类>-voc-insight-report.html(400-750 KB)
scripts/tools/
├── <品类>-collect.js # 采集层(P4 产物)
├── <品类>-analyze.js # 分析层 ← 数据查询 API
├── <品类>-components.js # 组件层 ← 可复用 UI
├── <品类>-ch1-*.js # 章节模块 ×8(独立文件,避免单文件超长)
├── <品类>-ch2-*.js
├── ...
├── <品类>-ch8-*.js
├── <品类>-render.js # 渲染层 ← 装配 + cover + agenda
├── gen-<品类>.js # 入口
└── _audit-<品类>-voc.js # 审计(P6 用)
单文件 >2700 行会导致 edit 工具超时(实测)。拆分后:
analyze.js ~300 行components.js ~500 行ch*.js ~400-600 行render.js ~200 行(主要是 COVER + AGENDA + 装配)全部控制在单文件 ≤ 700 行。
<品类>-analyze.jsgetEvidence(items, opts)功能:根据条件从 items 池里取出证据卡。
/**
* @param {Array<Item>} items - _merged.json 的 items
* @param {Object} opts
* @param {string} [opts.keyword] - 精确匹配 keyword 字段
* @param {string} [opts.product] - 精确匹配 product 字段
* @param {string} [opts.platform] - xhs | douyin | amazon
* @param {RegExp} [opts.contentMatch] - content 正则过滤
* @param {Array<string>} [opts.hypotheses] - 必含的 H 标签
* @param {number} [opts.minChars=10] - 最小有效字符数
* @param {number} [opts.minLikes=0] - 最小点赞
* @param {number} [opts.top=5] - 取前 N 条(按点赞排序)
* @param {number} [opts.seed=0] - 乱序种子(避免每章都是同几条)
* @returns {Array<Item>}
*/
function getEvidence(items, opts = {}) {
let pool = items;
if (opts.keyword) pool = pool.filter(i => i.keyword === opts.keyword);
if (opts.product) pool = pool.filter(i => i.product === opts.product);
if (opts.platform) pool = pool.filter(i => i.platform === opts.platform);
if (opts.contentMatch) pool = pool.filter(i => opts.contentMatch.test(i.content || ''));
if (opts.hypotheses?.length) {
pool = pool.filter(i => opts.hypotheses.every(h => (i.hypotheses || []).includes(h)));
}
pool = pool.filter(i => isSubstantive(i.content, opts.minChars || 10));
if (opts.minLikes) pool = pool.filter(i => (i.likes || 0) >= opts.minLikes);
// 去重(同内容前 40 字合并)
const seen = new Set();
pool = pool.filter(i => {
const key = (i.content || '').slice(0, 40);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// 排序:按点赞降序 + seed 乱序微扰
pool.sort((a, b) => (b.likes || 0) - (a.likes || 0));
// 取前 top * 2 再按 seed 选前 top
const top2 = pool.slice(0, (opts.top || 5) * 2);
if (opts.seed) return shuffleSeed(top2, opts.seed).slice(0, opts.top || 5);
return top2.slice(0, opts.top || 5);
}
isSubstantive(content, minChars)功能:判断内容是否"实质性"(过滤纯表情 / 水字)。
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));
}
filterBy{Platform,Hypothesis,Product}const filterByPlatform = (items, p) => items.filter(i => i.platform === p);
const filterByHypothesis = (items, h) => items.filter(i => (i.hypotheses || []).includes(h));
const filterByProduct = (items, prod) => items.filter(i => i.product === prod);
loadMerged()function loadMerged() {
const fp = path.join(__dirname, '..', '..', 'docs', '<品类>', 'raw', '_merged.json');
if (!fs.existsSync(fp)) throw new Error('Run collect --merge first');
return JSON.parse(fs.readFileSync(fp, 'utf8'));
}
module.exports = { loadMerged, getEvidence, isSubstantive, filterByPlatform, ... };
<品类>-components.js每个品类应该有一组差异化颜色 token:
const BRAND = {
// 品类主色
MAIN: '#XXXXXX', // 例:乳酸菌 = LACTIC_MINT #8FD3B8
MAIN_GLOW: '#XXXXXX20',
// 品牌色
JIANG_GREEN: '#1A7C5F', // 江中绿
OTC_BLUE: '#0071CE', // OTC 蓝帽
// 情绪色
CORAL: '#FF6B6B', // 痛点 / 焦虑
LACTIC_MINT: '#8FD3B8', // 平和 / 方案
JIANG_GREEN: '#1A7C5F', // 权威
WINE_RED: '#722F37', // 反向 / 禁区
// 辅助色(12-16 个)
TECH_BLUE, PURPLE, SAGE, BERRY_PINK, GOLD, ROSE,
SKY_BLUE, WARM_ORANGE, SUNNY_YELLOW,
BABY_PEACH, PRESCHOOL_LIME, SCHOOL_LAVENDER, // 分龄色(儿童品专属)
};
| 组件 | 用途 | 典型位置 |
|---|---|---|
renderCover(data) |
封面(标题 + KPI + 采集量) | 报告首页 |
renderAgenda(chapters) |
目录(全章节索引) | cover 之后 |
renderDivider(chapter) |
章分隔(纯标题大屏) | 每章首 |
renderSectionHead(opts) |
子章开头(eyebrow + title + subtitle) | 每子章首 |
renderVocCard(item, opts) |
VOC 证据卡(平台+昵称+IP+♥+原文) | 章节内部 |
renderStatStrip(stats) |
KPI 数字条(3-5 个数字横排) | 数据支撑 slide |
renderCompareTable(opts) |
对比表格(2 列/3 列/4 列) | 竞品对比 / 4P 决策 |
renderMatrix2x2(opts) |
2×2 矩阵(四象限) | 定位 / 竞品地图 |
renderInsightHero(opts) |
金句卡(大字观点 + 解释) | 每章结论 |
renderDecisionList(items, opts) |
决策列表(落地动作 2-4 列) | 每子章收尾 |
renderVocCard(item, opts) 标准格式function renderVocCard(item, opts = {}) {
const accent = opts.accent || BRAND.MAIN;
const maxChars = opts.maxChars || 200;
const compact = opts.compact || false;
return `
<div style="padding:14px 16px; background:var(--bg-1); border:1px solid var(--border);
border-left:3px solid ${accent}; border-radius:6px;">
<div style="display:flex; justify-content:space-between; gap:10px; margin-bottom:8px; font-size:0.78rem;">
<div>
<span class="text-mono" style="color:${accent}; font-weight:700; letter-spacing:0.1em;">
${item.platform.toUpperCase()}
</span>
<span style="color:var(--text-3);"> · ${esc(item.nickname || '匿名')}</span>
${item.ip ? `<span style="color:var(--text-3);"> · ${esc(item.ip)}</span>` : ''}
</div>
<div style="color:${accent}; font-family:var(--font-mono); font-weight:700;">
♥${fmtLikes(item.likes)}
</div>
</div>
<div style="color:var(--text-1); font-size:0.88rem; line-height:1.7;">
「${esc((item.content || '').slice(0, maxChars))}${item.content?.length > maxChars ? '…' : ''}」
</div>
</div>`;
}
<品类>-ch*-*.js每个章节文件导出一个子章渲染器 map:
const LA = require('./<品类>-analyze.js');
const C = require('./<品类>-components.js');
const { BRAND, esc, fmtLikes } = C;
function renderSub1(ctx) {
const { chapter, subIdx, slideNum, slideTotal, data } = ctx;
const items = data.items;
// 1. 查询证据
const hitVoc = LA.getEvidence(items, {
keyword: 'xxx',
contentMatch: /xxx/,
top: 4, seed: 7,
});
// 2. 渲染 section HTML
return `<section class="report-section" id="${chapter.key}-${subIdx}"
data-tone="${chapter.tone}" data-slide-num="${slideNum}/${slideTotal}">
<div class="section-inner">
${C.renderSectionHead({
eyebrow: `${chapter.num}.${subIdx} · 副标题`,
title: '主标题',
subtitle: '副标题解释',
color: BRAND.MAIN,
})}
<!-- VOC 证据卡(至少 3 张)-->
<div class="grid" style="grid-template-columns:1fr 1fr; gap:14px; margin-top:20px;">
${hitVoc.map(it => C.renderVocCard(it, { accent: BRAND.MAIN })).join('')}
</div>
<!-- 决策列表(落地动作)-->
${C.renderDecisionList([
'<strong>P0 决策 1</strong>:具体动作',
'<strong>P1 决策 2</strong>:具体动作',
], { title: '落地决策', accent: BRAND.MAIN, columns: 2 })}
</div>
</section>`;
}
module.exports = {
'ch1-1': renderSub1,
'ch1-2': renderSub2,
// ...
};
每个子章 HTML 必须包含:
renderSectionHead(eyebrow + title + subtitle)—— 视觉锚getEvidence)—— 事实基础renderDecisionList 4-5 条—— 落地闭环data-slide-num 属性—— 导航索引<品类>-render.jsfunction buildHTML(data) {
const CHAPTERS = [
{ num: 1, key: 'challenge', title: '诘问起点', tone: 'urgent', subs: ['1-1', '1-2', '1-3'] },
{ num: 2, key: 'drug-voc', title: '药品益生菌 VOC', tone: 'critical', subs: ['2-1', ..., '2-4'] },
// ...
];
const SLIDE_RENDERERS = {
...require('./<品类>-ch1-challenge.js'),
...require('./<品类>-ch2-xxx.js'),
// ...
};
const slides = [];
slides.push(renderCover(data));
slides.push(renderAgenda(CHAPTERS));
let slideNum = 3;
const slideTotal = CHAPTERS.reduce((acc, c) => acc + c.subs.length, 0) + 2; // +cover+agenda
for (const chapter of CHAPTERS) {
slides.push(renderDivider(chapter));
for (const subKey of chapter.subs) {
const subIdx = subKey.split('-')[1];
const renderer = SLIDE_RENDERERS[`${chapter.key}-${subIdx}`];
if (!renderer) {
slides.push(renderComingSoon(chapter, subIdx));
continue;
}
slides.push(renderer({ chapter, subIdx, slideNum, slideTotal, data }));
slideNum++;
}
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<title>${data.title}</title>
<style>${CSS}</style>
</head>
<body>
${slides.join('\n')}
<script>${NAV_JS}</script>
</body>
</html>`;
}
:root {
/* 背景层次 */
--bg-0: #0A0A0A; /* 最底层 */
--bg-1: #111111; /* 卡片 */
--bg-2: #1A1A1A; /* 次级卡片 */
--bg-3: #252525; /* 强调卡片 */
/* 边框 */
--border: rgba(255,255,255,0.08);
--border-strong: rgba(255,255,255,0.16);
/* 文字层次 */
--text-1: #F5F5F5; /* 主文字 */
--text-2: #A8A8A8; /* 次文字 */
--text-3: #6B6B6B; /* 三级文字 */
/* 字体 */
--font-head: 'Inter', 'Noto Sans SC', ...;
--font-mono: 'JetBrains Mono', ...;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
}
const NAV_JS = `
(function() {
const sections = document.querySelectorAll('.report-section');
let currentIdx = 0;
function scrollTo(i) {
currentIdx = Math.max(0, Math.min(sections.length - 1, i));
sections[currentIdx].scrollIntoView({ behavior: 'smooth' });
location.hash = sections[currentIdx].id;
}
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === ' ') scrollTo(currentIdx + 1);
if (e.key === 'ArrowLeft') scrollTo(currentIdx - 1);
if (e.key === 'Home') scrollTo(0);
if (e.key === 'End') scrollTo(sections.length - 1);
});
// 从 URL hash 恢复
if (location.hash) {
const idx = [...sections].findIndex(s => '#' + s.id === location.hash);
if (idx >= 0) currentIdx = idx;
}
})();
`;
collect.js + analyze.js → MVP 可产 1500 条 VOCcomponents.js + render.js + Ch1 → MVP HTML 能跑出封面 + Ch1gen 预览自顶向下更适合多人协作(每人一章),自底向上更适合AI 单兵(每步可见)。
node scripts/tools/preview-reports.js
# 浏览器打开 http://localhost:8787
node -e "
const LA = require('./scripts/tools/<品类>-analyze.js');
const d = LA.loadMerged();
LA.getEvidence(d.items, { keyword: '妈咪爱', top: 3 }).forEach(it =>
console.log(it.nickname, '♥'+it.likes, ':', it.content.slice(0, 100))
);
"
# 看 HTML 里有没有 Coming Soon
Select-String reports/<品类>-voc-insight-report.html -Pattern "Coming in Batch"
getEvidence 的 contentMatch 里严格 regex(如 /挑食|饭渣|不爱吃饭/)seed 参数微扰 + 章间 evidence 互斥清单ch5-1.js / ch5-2.js 等require('./ch3.js') → require('./components.js') → require('./ch3.js')<section> 必带 data-slide-num="${slideNum}/${slideTotal}"Phase 5 完成的 7 个条件:
_merged.json.meta 一致