| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- 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 <normalized-dir|_merged.json|comments-flat.jsonl> --output <out-dir> [--project <name>] [--category <name>] [--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
- };
|