#!/usr/bin/env node const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); 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 voc-single-platform-report.js --platform --category --raw-dir [--output ]', ' node voc-single-platform-report.js --platform xiaohongshu --category --sample-mode true [--output ]', '', 'Inputs:', ' --raw-dir Raw platform data directory for normalization', ' --normalized-dir Existing normalized VOC directory containing _merged.json and comments-flat.jsonl', ' --sample-mode Use packaged Xiaohongshu course sample data when true', '', 'Outputs:', ' normalized/_merged.json', ' platform-mini-report/platform-mini-report.md', ' platform-mini-report/platform-mini-report.json', ' platform-mini-report/platform-mini-report.html', ' audit/audit-report.md', ' audit/audit-result.json' ].join('\n'); } function toBool(value) { if (typeof value === 'boolean') return value; if (value === undefined || value === null) return false; return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase()); } function slugify(value) { return String(value || 'voc-report') .trim() .replace(/[\\/:*?"<>|\s]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') || 'voc-report'; } function parseKeywords(value) { if (!value) return []; if (Array.isArray(value)) return value; const text = String(value).trim(); if (!text) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : []; } catch { return []; } } return text.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function unique(values) { return Array.from(new Set(values.filter(Boolean))); } function existingPath(candidates) { return unique(candidates).find(candidate => fs.existsSync(candidate)); } function resolveTool(relativePath) { const home = os.homedir(); const openclawTools = path.join(home, '.openclaw', 'tools'); const openclawWorkspace = path.join(home, '.openclaw', 'workspace'); const projectRoot = path.resolve(__dirname, '..', '..'); const baseName = path.basename(relativePath); return existingPath([ path.isAbsolute(relativePath) ? relativePath : '', path.join(process.cwd(), relativePath), path.join(projectRoot, relativePath), path.join(__dirname, relativePath), path.join(__dirname, baseName), path.join(openclawTools, relativePath.replace(/^scripts[\\/]tools[\\/]/, '')), path.join(openclawTools, relativePath), path.join(openclawWorkspace, relativePath) ]); } function resolveSampleDir(platform) { const normalizedPlatform = platform === 'xhs' ? 'xiaohongshu' : platform; const home = os.homedir(); return existingPath([ path.join(process.cwd(), 'demo', normalizedPlatform), path.join(path.resolve(__dirname, '..', '..'), 'demo', normalizedPlatform), path.join(__dirname, 'course-samples', normalizedPlatform), path.join(home, '.openclaw', 'tools', 'course-samples', normalizedPlatform), path.join(home, '.openclaw', 'workspace', 'scripts', 'tools', 'course-samples', normalizedPlatform) ]); } function parseLastJson(stdout) { const text = String(stdout || '').trim(); if (!text) return {}; for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { try { return JSON.parse(text.slice(i)); } catch { continue; } } return {}; } function runNode(scriptPath, args, label) { const child = spawnSync(process.execPath, [scriptPath, ...args], { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 * 100 }); if (child.stdout) process.stdout.write(child.stdout); if (child.stderr) process.stderr.write(child.stderr); if (child.error) throw child.error; if (child.status !== 0) throw new Error(`${label} failed with exit code ${child.status}`); return parseLastJson(child.stdout); } function requiredTool(relativePath) { const resolved = resolveTool(relativePath); if (!resolved) throw new Error(`Required tool not found: ${relativePath}`); return resolved; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const platform = args.platform || 'xiaohongshu'; const category = args.category || ''; const project = args.project || 'openclaw-voc-course'; const outputFormat = args['output-format'] || args.outputFormat || 'both'; const sampleMode = toBool(args['sample-mode'] ?? args.sampleMode); const keywords = parseKeywords(args.keywords); const date = args.date || new Date().toISOString().slice(0, 10); const owner = args.owner || 'OpenClaw VOC Skills'; if (!category) throw new Error('Missing required argument: --category'); if (!['markdown', 'html', 'both'].includes(outputFormat)) throw new Error(`Invalid --output-format: ${outputFormat}`); const outputRoot = path.resolve(args.output || path.join(process.cwd(), 'openclaw-voc-output', `${slugify(platform)}-${slugify(category)}-${date}`)); const normalizedDir = path.resolve(args['normalized-dir'] || args.normalizedDir || path.join(outputRoot, 'normalized')); const reportDir = path.join(outputRoot, 'platform-mini-report'); const auditDir = path.join(outputRoot, 'audit'); ensureDir(outputRoot); const rawDir = args['raw-dir'] || args.rawDir ? path.resolve(args['raw-dir'] || args.rawDir) : sampleMode ? resolveSampleDir(platform) : undefined; if (!rawDir && !fs.existsSync(path.join(normalizedDir, '_merged.json'))) { throw new Error('Provide --raw-dir, --normalized-dir, or --sample-mode true with packaged sample data. The local executable does not perform remote data collection by itself.'); } const normalizer = requiredTool('scripts/tools/voc-data-normalizer.js'); const miniReport = requiredTool('scripts/tools/platform-mini-report-generator.js'); const auditor = requiredTool('scripts/tools/voc-report-auditor.js'); const htmlGenerator = outputFormat === 'html' || outputFormat === 'both' ? requiredTool('voc-report-factory/html-v2/gen-report-template.js') : undefined; const steps = {}; if (rawDir) { steps.normalizer = runNode(normalizer, [ '--input', rawDir, '--output', normalizedDir, '--project', project, '--category', category ], 'voc-data-normalizer'); } steps.miniReport = runNode(miniReport, [ '--input', normalizedDir, '--output', reportDir, '--project', project, '--category', category, '--platform', platform, '--owner', owner, '--date', date, '--business-question', args['business-question'] || args.businessQuestion || '', '--keywords', keywords.join(',') ], 'platform-mini-report-generator'); let htmlReportPath; if (htmlGenerator) { htmlReportPath = path.join(reportDir, 'platform-mini-report.html'); steps.htmlReport = runNode(htmlGenerator, [ '--data', path.join(reportDir, 'platform-mini-report.json'), '--output', htmlReportPath ], 'voc-html-report-generator'); } steps.audit = runNode(auditor, [ '--report', path.join(reportDir, 'platform-mini-report.md'), '--merged', path.join(normalizedDir, '_merged.json'), '--comments', path.join(normalizedDir, 'comments-flat.jsonl'), '--output', auditDir ], 'voc-report-auditor'); const merged = readJson(path.join(normalizedDir, '_merged.json')); const report = readJson(path.join(reportDir, 'platform-mini-report.json')); const audit = readJson(path.join(auditDir, 'audit-result.json')); const result = { status: audit.status === 'fail' ? 'needs_review' : 'ok', metadata: { project, platform, category, businessQuestion: args['business-question'] || args.businessQuestion || '', keywords, generatedAt: new Date().toISOString() }, summary: { itemCount: merged.metadata?.itemCount || 0, commentCount: merged.metadata?.validVocCount || report.stats?.commentCount || 0, keywordCount: keywords.length || merged.metadata?.keywords?.length || 0, evidenceCardCount: report.audit?.evidenceCardCount || 0, warningCount: (report.audit?.warningCount || 0) + (audit.summary?.warn || 0), auditStatus: audit.status }, outputs: { outputRoot, normalized: normalizedDir, markdownReport: path.join(reportDir, 'platform-mini-report.md'), jsonReport: path.join(reportDir, 'platform-mini-report.json'), htmlReport: htmlReportPath, auditReport: path.join(auditDir, 'audit-report.md'), auditResult: path.join(auditDir, 'audit-result.json') }, steps, nextActions: [ '复核 audit-report.md 中的 warn/fail 项', '补充真实采集数据后复跑同一链路', '将 P0 机会点转成内容或产品验证动作' ] }; console.log(`SINGLE_PLATFORM_REPORT_RESULT=${JSON.stringify(result)}`); } try { main(); } catch (error) { const result = { status: 'error', message: error.message, generatedAt: new Date().toISOString() }; console.error(error.message); console.log(`SINGLE_PLATFORM_REPORT_RESULT=${JSON.stringify(result)}`); process.exit(1); }