const fs = require('fs'); const path = require('path'); const HYPOTHESIS_LABELS = { H1: '品类机会', H2: '用户需求', H3: '产品缺口', H4: '价格认知', H5: '渠道内容', H6: '竞品策略', H7: '定位表达', H8: '增长行动' }; const LIGHT_CHAPTERS = [ { id: 'Ch1', title: '结论先行', hypothesisTags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8'], focus: '回答核心决策问题' }, { id: 'Ch2', title: '用户声音', hypothesisTags: ['H2', 'H3'], focus: '呈现痛点、场景、需求结构' }, { id: 'Ch3', title: '竞品/平台发现', hypothesisTags: ['H5', 'H6', 'H7'], focus: '解释平台内容、竞品策略和可用话术' }, { id: 'Ch4', title: '行动建议', hypothesisTags: ['H8'], focus: '输出下一步 P0/P1/P2 动作和验证指标' } ]; const ENTERPRISE_CHAPTERS = [ { id: 'Ch1', title: '执行摘要与核心判断', hypothesisTags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8'], focus: '面向决策人给出结论' }, { id: 'Ch2', title: '品类与需求全景', hypothesisTags: ['H1', 'H2'], focus: '判断市场机会和需求结构' }, { id: 'Ch3', title: '用户 VOC 与痛点结构', hypothesisTags: ['H2', 'H3'], focus: '展示真实用户语言' }, { id: 'Ch4', title: '竞品与替代方案分析', hypothesisTags: ['H3', 'H6'], focus: '找到竞争位置和缺口' }, { id: 'Ch5', title: '定位、话术、产品/内容机会', hypothesisTags: ['H5', 'H7'], focus: '转化为表达与产品机会' }, { id: 'Ch6', title: '渠道与场景策略', hypothesisTags: ['H5', 'H8'], focus: '明确不同平台打法' }, { id: 'Ch7', title: '行动计划与验证路径', hypothesisTags: ['H8'], focus: '形成落地闭环' } ]; function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/chapter-insight-writer.js --input --output [--project ] [--category ] [--version light|enterprise] [--min-evidence 3]', '', 'Outputs:', ' chapter-insights.md', ' chapter-insights.json' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function readJsonl(filePath) { return fs.readFileSync(filePath, 'utf8') .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map(line => JSON.parse(line)); } function uniq(values) { return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))]; } function asArray(value) { if (!value) return []; if (Array.isArray(value)) return value; return [value]; } function countBy(records, getter) { const counts = new Map(); records.forEach(record => { const key = getter(record) || '未标注'; counts.set(key, (counts.get(key) || 0) + 1); }); return [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label))); } function scoreEvidence(record) { return Number(record.likeCount || 0) + Number(record.replyCount || 0) * 2 + String(record.text || '').length / 80; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function truncate(value, length = 96) { const text = cleanText(value); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } function resolveInput(inputPath) { const absolute = path.resolve(inputPath); const stat = fs.statSync(absolute); if (stat.isDirectory()) { const merged = path.join(absolute, '_merged.json'); const flat = path.join(absolute, 'comments-flat.jsonl'); if (fs.existsSync(merged)) return { type: 'merged', filePath: merged, data: readJson(merged) }; if (fs.existsSync(flat)) return { type: 'jsonl', filePath: flat, data: readJsonl(flat) }; throw new Error(`no _merged.json or comments-flat.jsonl found in ${absolute}`); } if (absolute.endsWith('.jsonl')) return { type: 'jsonl', filePath: absolute, data: readJsonl(absolute) }; return { type: 'merged', filePath: absolute, data: readJson(absolute) }; } function normalizeInput(resolved) { if (resolved.type === 'jsonl') { return { metadata: {}, items: [], comments: resolved.data, sources: [{ file: path.basename(resolved.filePath), platform: 'jsonl', commentCount: resolved.data.length }] }; } const data = resolved.data; if (Array.isArray(data)) return { metadata: {}, items: [], comments: data, sources: [] }; return { metadata: data.metadata || {}, items: asArray(data.items), comments: asArray(data.comments), sources: asArray(data.sources) }; } function evidenceCard(record) { return { id: record.id || record.commentId, platform: record.platform, keyword: record.keyword, batch: record.batch, hypothesisTags: asArray(record.hypothesisTags), text: cleanText(record.text || record.content), author: record.author, likeCount: Number(record.likeCount || 0), replyCount: Number(record.replyCount || 0), url: record.url, parentId: record.parentId, commentId: record.commentId || record.id, theme: record.theme, source: `${record.platform || 'unknown'} / ${record.keyword || 'unknown'} / ${record.batch || 'P0'} / ${record.commentId || record.id || 'no-id'}` }; } function filterByTags(records, tags) { const set = new Set(tags); return records.filter(record => asArray(record.hypothesisTags).some(tag => set.has(tag))); } function selectEvidence(records, limit) { const seen = new Set(); return [...records] .filter(record => cleanText(record.text || record.content)) .sort((a, b) => scoreEvidence(b) - scoreEvidence(a)) .filter(record => { const key = cleanText(record.text || record.content).slice(0, 80); if (seen.has(key)) return false; seen.add(key); return true; }) .slice(0, limit) .map(evidenceCard); } function inferConclusion(chapter, records, category) { if (!records.length) return `当前样本不足,暂不支持对「${chapter.title}」做结论。`; const themes = countBy(records, record => record.theme).slice(0, 3); const keywords = countBy(records, record => record.keyword).slice(0, 3); const themeText = themes.map(item => `${item.label}(${item.count})`).join('、'); const keywordText = keywords.map(item => item.label).join('、'); return `当前 ${records.length} 条可追溯 VOC 显示,${category || '该品类'} 在「${chapter.title}」中最稳定的信号集中在 ${themeText || '未标注主题'};主要由 ${keywordText || '未标注关键词'} 等关键词触发。`; } function inferExplanation(chapter, records) { if (!records.length) return '该章节没有足够 VOC 支撑,只能作为补采方向,不能写成业务结论。'; const tags = chapter.hypothesisTags.map(tag => `${tag}${HYPOTHESIS_LABELS[tag] ? ` ${HYPOTHESIS_LABELS[tag]}` : ''}`).join('、'); const platforms = countBy(records, record => record.platform).map(item => `${item.label} ${item.count}`).join('、'); return `本章对应 ${tags}。证据来自 ${platforms || '未标注平台'},因此适合写成“当前样本支持的阶段性判断”,不应外推为全市场结论。`; } function inferAction(chapter, records, minEvidence) { if (records.length < minEvidence) return `补采 ${chapter.hypothesisTags.join('/')} 相关关键词,至少补足 ${minEvidence} 条可引用 VOC 后再定稿。`; if (chapter.id === 'Ch4' || chapter.title.includes('行动')) return '把证据最高的 3 个用户表达转成 P0/P1/P2 行动,并为每条行动指定验证指标。'; if (chapter.title.includes('用户')) return '优先把高频痛点、场景和决策标准转成 VOC 证据卡,进入报告正文。'; if (chapter.title.includes('平台') || chapter.title.includes('定位')) return '把高赞评论中的用户原话转成内容标题、详情页 FAQ 或话术 AB 测试。'; return '保留本章结论作为报告开头判断,并在后续章节用证据卡展开。'; } function buildChapter(chapter, allComments, context, options) { const records = filterByTags(allComments, chapter.hypothesisTags); const evidence = selectEvidence(records, options.evidenceLimit); const warnings = []; if (evidence.length < options.minEvidence) warnings.push(`证据不足:${chapter.id} 只有 ${evidence.length} 条去重证据,低于 ${options.minEvidence} 条。`); chapter.hypothesisTags.forEach(tag => { const count = filterByTags(allComments, [tag]).length; if (count < options.minEvidence) warnings.push(`${tag} ${HYPOTHESIS_LABELS[tag] || ''} 证据量不足:${count}/${options.minEvidence}。`); }); return { ...chapter, evidenceCount: records.length, uniqueEvidenceCount: evidence.length, topThemes: countBy(records, record => record.theme).slice(0, 6), topKeywords: countBy(records, record => record.keyword).slice(0, 6), conclusion: inferConclusion(chapter, records, context.category), evidence, explanation: inferExplanation(chapter, records), action: inferAction(chapter, records, options.minEvidence), warnings: uniq(warnings) }; } function buildDraft(normalized, args) { const version = args.version === 'enterprise' ? 'enterprise' : 'light'; const chapters = version === 'enterprise' ? ENTERPRISE_CHAPTERS : LIGHT_CHAPTERS; const comments = normalized.comments.filter(comment => cleanText(comment.text || comment.content)); const context = { project: args.project || normalized.metadata.project || 'chapter-insight-draft', category: args.category || normalized.metadata.category || '', version, generatedAt: new Date().toISOString(), platforms: uniq(comments.map(comment => comment.platform)), keywords: uniq(comments.map(comment => comment.keyword)), batches: uniq(comments.map(comment => comment.batch)), hypothesisTags: uniq(comments.flatMap(comment => asArray(comment.hypothesisTags))), commentCount: comments.length, itemCount: normalized.items.length }; const options = { minEvidence: Number(args['min-evidence'] || 3), evidenceLimit: Number(args['evidence-limit'] || 5) }; const chapterDrafts = chapters.map(chapter => buildChapter(chapter, comments, context, options)); const audit = { generatedAt: context.generatedAt, minEvidence: options.minEvidence, chapterCount: chapterDrafts.length, warningCount: chapterDrafts.reduce((sum, chapter) => sum + chapter.warnings.length, 0), unsupportedConclusionCount: chapterDrafts.filter(chapter => !chapter.evidence.length && !chapter.conclusion.includes('暂不支持')).length, totalEvidenceCards: chapterDrafts.reduce((sum, chapter) => sum + chapter.evidence.length, 0) }; return { metadata: context, chapters: chapterDrafts, audit }; } function renderEvidenceMd(evidence) { if (!evidence.length) return '- 暂无可引用证据。'; return evidence.map((item, index) => [ `### 证据 ${index + 1}:${truncate(item.text, 28)}`, '', `- **来源**:${item.source}`, `- **原声**:“${item.text}”`, `- **标签**:${item.hypothesisTags.join(', ') || '未标注'}`, `- **互动**:${item.likeCount} 赞 / ${item.replyCount} 回复`, `- **解释**:这条原声可支撑本章关于「${item.theme || '未标注主题'}」的阶段性判断。` ].join('\n')).join('\n\n'); } function renderMarkdown(draft) { const meta = draft.metadata; const lines = []; lines.push('# Chapter Insight Draft'); lines.push(''); lines.push('| 字段 | 内容 |'); lines.push('|---|---|'); lines.push(`| 项目 | ${meta.project} |`); lines.push(`| 品类 | ${meta.category || '未填写'} |`); lines.push(`| 版本 | ${meta.version} |`); lines.push(`| 平台 | ${meta.platforms.join('、') || '未标注'} |`); lines.push(`| 关键词 | ${meta.keywords.join('、') || '未标注'} |`); lines.push(`| VOC 数 | ${meta.commentCount} |`); lines.push(`| 生成时间 | ${meta.generatedAt} |`); lines.push(''); lines.push('## 0. 审计摘要'); lines.push(''); lines.push(`- **章节数**:${draft.audit.chapterCount}`); lines.push(`- **证据卡数**:${draft.audit.totalEvidenceCards}`); lines.push(`- **警告数**:${draft.audit.warningCount}`); lines.push(`- **无证据结论数**:${draft.audit.unsupportedConclusionCount}`); lines.push(''); draft.chapters.forEach(chapter => { lines.push(`## ${chapter.id}. ${chapter.title}`); lines.push(''); lines.push(`- **章节目标**:${chapter.focus}`); lines.push(`- **对应假设**:${chapter.hypothesisTags.join(', ')}`); lines.push(`- **证据量**:${chapter.evidenceCount} 条 VOC / ${chapter.uniqueEvidenceCount} 条证据卡`); lines.push(`- **章节结论**:${chapter.conclusion}`); lines.push(`- **业务解释**:${chapter.explanation}`); lines.push(`- **行动建议**:${chapter.action}`); if (chapter.warnings.length) lines.push(`- **警告**:${chapter.warnings.join(';')}`); lines.push(''); lines.push('### 高频主题'); lines.push(''); lines.push('| 主题 | 频次 |'); lines.push('|---|---:|'); chapter.topThemes.forEach(item => lines.push(`| ${item.label} | ${item.count} |`)); lines.push(''); lines.push('### VOC 证据卡'); lines.push(''); lines.push(renderEvidenceMd(chapter.evidence)); lines.push(''); }); return `${lines.join('\n')}\n`; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.input || !args.output) { console.log(usage()); process.exit(args.help ? 0 : 1); } const resolved = resolveInput(args.input); const normalized = normalizeInput(resolved); const draft = buildDraft(normalized, args); const outputDir = path.resolve(args.output); ensureDir(outputDir); fs.writeFileSync(path.join(outputDir, 'chapter-insights.json'), JSON.stringify(draft, null, 2) + '\n', 'utf8'); fs.writeFileSync(path.join(outputDir, 'chapter-insights.md'), renderMarkdown(draft), 'utf8'); console.log(JSON.stringify({ outputDir, files: ['chapter-insights.md', 'chapter-insights.json'], chapterCount: draft.audit.chapterCount, evidenceCards: draft.audit.totalEvidenceCards, warnings: draft.audit.warningCount, unsupportedConclusionCount: draft.audit.unsupportedConclusionCount }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { parseArgs, buildDraft, renderMarkdown, selectEvidence, countBy };