# Phase 5 · 分析 + 渲染 Analysis + Rendering > 从 `_merged.json` → **8 章 × 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 池里取出证据卡。 ```js /** * @param {Array} 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} [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} */ 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)` **功能**:判断内容是否"实质性"(过滤纯表情 / 水字)。 ```js 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}` ```js 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()` ```js 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**: ```js 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)` 标准格式 ```js function renderVocCard(item, opts = {}) { const accent = opts.accent || BRAND.MAIN; const maxChars = opts.maxChars || 200; const compact = opts.compact || false; return `
${item.platform.toUpperCase()} · ${esc(item.nickname || '匿名')} ${item.ip ? ` · ${esc(item.ip)}` : ''}
♥${fmtLikes(item.likes)}
「${esc((item.content || '').slice(0, maxChars))}${item.content?.length > maxChars ? '…' : ''}」
`; } ``` --- ## 📖 Chapter 层:`<品类>-ch*-*.js` ### 标准章节模块结构 每个章节文件导出一个**子章渲染器 map**: ```js 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 `
${C.renderSectionHead({ eyebrow: `${chapter.num}.${subIdx} · 副标题`, title: '主标题', subtitle: '副标题解释', color: BRAND.MAIN, })}
${hitVoc.map(it => C.renderVocCard(it, { accent: BRAND.MAIN })).join('')}
${C.renderDecisionList([ 'P0 决策 1:具体动作', 'P1 决策 2:具体动作', ], { title: '落地决策', accent: BRAND.MAIN, columns: 2 })}
`; } 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` ### 主装配函数 ```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 ` ${data.title} ${slides.join('\n')} `; } ``` ### CSS 变量设计 ```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) ```js 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 ```powershell node scripts/tools/preview-reports.js # 浏览器打开 http://localhost:8787 ``` ### 查某个 VOC ```powershell 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)) ); " ``` ### 检查某章是否渲染成功 ```powershell # 看 HTML 里有没有 Coming Soon Select-String reports/<品类>-voc-insight-report.html -Pattern "Coming in Batch" ``` --- ## 🚫 常见失误 ### 失误 1 · VOC 证据与章节主题不一致 - ❌ 差:Ch5.3 讲挑食,证据里却是"换季腹泻" - ✅ 好:在 `getEvidence` 的 `contentMatch` 里**严格 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 - ❌ 症状:阅读时看不到进度 - ✅ 做法:每 `
` 必带 `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**(过小说明内容不够,过大说明图片/重复) --- ## 📚 模板 - [`templates/analyze.template.js`](./templates/analyze.template.js) - [`templates/components.template.js`](./templates/components.template.js) - [`templates/render.template.js`](./templates/render.template.js) - [`templates/chapter.template.js`](./templates/chapter.template.js) - [`prompts/P5-rendering.prompt.md`](./prompts/P5-rendering.prompt.md)