05-phase5-analysis-rendering.md 15 KB

Phase 5 · 分析 + 渲染 Analysis + Rendering

_merged.json8 章 × 28-42 子章 × HTML —— 这是工作流中最耗时也最能体现价值的阶段。


🎯 目标

输入docs/<品类>/raw/_merged.json(1500-8000 条统一 VOC)
输出reports/<品类>-voc-insight-report.html(400-750 KB)


🧩 代码架构(5 层模块化)

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 用)

为什么拆 3 层 + 8 章模块?

单文件 >2700 行会导致 edit 工具超时(实测)。拆分后:

  • analyze.js ~300 行
  • components.js ~500 行
  • 每个 ch*.js ~400-600 行
  • render.js ~200 行(主要是 COVER + AGENDA + 装配)

全部控制在单文件 ≤ 700 行。


🔍 Analyze 层:<品类>-analyze.js

核心 API · getEvidence(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 层:<品类>-components.js

BRAND tokens(品类专属色板)

每个品类应该有一组差异化颜色 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,  // 分龄色(儿童品专属)
};

核心组件清单(10 个必备)

组件 用途 典型位置
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>`;
}

📖 Chapter 层:<品类>-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,
    // ...
};

子章内容 5 件套(必有)

每个子章 HTML 必须包含:

  1. renderSectionHead(eyebrow + title + subtitle)—— 视觉锚
  2. VOC 证据卡 ≥ 3 张(来自 getEvidence)—— 事实基础
  3. 至少 1 个结构化组件(表格 / 矩阵 / 统计条)—— 可读性
  4. renderDecisionList 4-5 条—— 落地闭环
  5. data-slide-num 属性—— 导航索引

🎬 Render 层:<品类>-render.js

主装配函数

function 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>`;
}

CSS 变量设计

: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;
}

导航 JS(键盘导航 + URL hash)

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;
    }
})();
`;

🧠 增量开发策略

策略 1 · 自底向上(推荐)

  1. 先写 collect.js + analyze.js → MVP 可产 1500 条 VOC
  2. components.js + render.js + Ch1 → MVP HTML 能跑出封面 + Ch1
  3. 再写 Ch2、Ch3 ... 每写完一个就 gen 预览
  4. 最后写 Ch8 + audit

策略 2 · 自顶向下

  1. 先全写好 CHAPTERS 数组 + renderComingSoon 占位 → 出 38 slide 空架子
  2. 每个 subchapter 独立填充 VOC 内容
  3. 填到 0 Coming Soon = 交付

自顶向下更适合多人协作(每人一章),自底向上更适合AI 单兵(每步可见)。


🐛 调试工具

预览 HTML

node scripts/tools/preview-reports.js
# 浏览器打开 http://localhost:8787

查某个 VOC

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"

🚫 常见失误

失误 1 · VOC 证据与章节主题不一致

  • ❌ 差:Ch5.3 讲挑食,证据里却是"换季腹泻"
  • ✅ 好:在 getEvidencecontentMatch严格 regex(如 /挑食|饭渣|不爱吃饭/

失误 2 · 同一条高赞 VOC 被多章引用

  • ❌ 差:♥4848 丝雨的出现在 Ch1/Ch2/Ch3
  • ✅ 好:用 seed 参数微扰 + 章间 evidence 互斥清单

失误 3 · 单章文件超过 700 行

  • ❌ 症状:edit 工具超时
  • ✅ 做法:按子章拆成 ch5-1.js / ch5-2.js

失误 4 · 组件循环依赖

  • ❌ 症状:require('./ch3.js')require('./components.js')require('./ch3.js')
  • ✅ 做法:章节 → 组件 → 分析单向依赖,不允许反向

失误 5 · 忘记填 data-slide-num

  • ❌ 症状:阅读时看不到进度
  • ✅ 做法:每 <section> 必带 data-slide-num="${slideNum}/${slideTotal}"

✅ 退出门控

Phase 5 完成的 7 个条件

  1. ☐ HTML 可在浏览器打开
  2. 0 个 Coming Soon 占位
  3. ☐ slide 数量与 CHAPTERS × subs 匹配
  4. ☐ 每子章 ≥ 3 VOC 证据卡 + 1 决策列表
  5. ☐ 封面统计数字与 _merged.json.meta 一致
  6. ☐ 键盘导航(← → Space)可用
  7. ☐ 文件大小 400-750 KB(过小说明内容不够,过大说明图片/重复)

📚 模板