voc-single-platform-report.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { spawnSync } = require('child_process');
  6. function parseArgs(argv) {
  7. const args = {};
  8. for (let i = 0; i < argv.length; i++) {
  9. const token = argv[i];
  10. if (!token.startsWith('--')) continue;
  11. const eq = token.indexOf('=');
  12. if (eq >= 0) {
  13. args[token.slice(2, eq)] = token.slice(eq + 1);
  14. } else {
  15. const key = token.slice(2);
  16. const next = argv[i + 1];
  17. if (next && !next.startsWith('--')) {
  18. args[key] = next;
  19. i++;
  20. } else {
  21. args[key] = true;
  22. }
  23. }
  24. }
  25. return args;
  26. }
  27. function usage() {
  28. return [
  29. 'Usage:',
  30. ' node voc-single-platform-report.js --platform <platform> --category <category> --raw-dir <raw-data-dir> [--output <out-dir>]',
  31. ' node voc-single-platform-report.js --platform xiaohongshu --category <category> --sample-mode true [--output <out-dir>]',
  32. '',
  33. 'Inputs:',
  34. ' --raw-dir Raw platform data directory for normalization',
  35. ' --normalized-dir Existing normalized VOC directory containing _merged.json and comments-flat.jsonl',
  36. ' --sample-mode Use packaged Xiaohongshu course sample data when true',
  37. '',
  38. 'Outputs:',
  39. ' normalized/_merged.json',
  40. ' platform-mini-report/platform-mini-report.md',
  41. ' platform-mini-report/platform-mini-report.json',
  42. ' platform-mini-report/platform-mini-report.html',
  43. ' audit/audit-report.md',
  44. ' audit/audit-result.json'
  45. ].join('\n');
  46. }
  47. function toBool(value) {
  48. if (typeof value === 'boolean') return value;
  49. if (value === undefined || value === null) return false;
  50. return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase());
  51. }
  52. function slugify(value) {
  53. return String(value || 'voc-report')
  54. .trim()
  55. .replace(/[\\/:*?"<>|\s]+/g, '-')
  56. .replace(/-+/g, '-')
  57. .replace(/^-|-$/g, '') || 'voc-report';
  58. }
  59. function parseKeywords(value) {
  60. if (!value) return [];
  61. if (Array.isArray(value)) return value;
  62. const text = String(value).trim();
  63. if (!text) return [];
  64. if (text.startsWith('[')) {
  65. try {
  66. const parsed = JSON.parse(text);
  67. return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : [];
  68. } catch {
  69. return [];
  70. }
  71. }
  72. return text.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean);
  73. }
  74. function readJson(filePath) {
  75. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  76. }
  77. function ensureDir(dirPath) {
  78. fs.mkdirSync(dirPath, { recursive: true });
  79. }
  80. function unique(values) {
  81. return Array.from(new Set(values.filter(Boolean)));
  82. }
  83. function existingPath(candidates) {
  84. return unique(candidates).find(candidate => fs.existsSync(candidate));
  85. }
  86. function resolveTool(relativePath) {
  87. const home = os.homedir();
  88. const openclawTools = path.join(home, '.openclaw', 'tools');
  89. const openclawWorkspace = path.join(home, '.openclaw', 'workspace');
  90. const projectRoot = path.resolve(__dirname, '..', '..');
  91. const baseName = path.basename(relativePath);
  92. return existingPath([
  93. path.isAbsolute(relativePath) ? relativePath : '',
  94. path.join(process.cwd(), relativePath),
  95. path.join(projectRoot, relativePath),
  96. path.join(__dirname, relativePath),
  97. path.join(__dirname, baseName),
  98. path.join(openclawTools, relativePath.replace(/^scripts[\\/]tools[\\/]/, '')),
  99. path.join(openclawTools, relativePath),
  100. path.join(openclawWorkspace, relativePath)
  101. ]);
  102. }
  103. function resolveSampleDir(platform) {
  104. const normalizedPlatform = platform === 'xhs' ? 'xiaohongshu' : platform;
  105. const home = os.homedir();
  106. return existingPath([
  107. path.join(process.cwd(), 'demo', normalizedPlatform),
  108. path.join(path.resolve(__dirname, '..', '..'), 'demo', normalizedPlatform),
  109. path.join(__dirname, 'course-samples', normalizedPlatform),
  110. path.join(home, '.openclaw', 'tools', 'course-samples', normalizedPlatform),
  111. path.join(home, '.openclaw', 'workspace', 'scripts', 'tools', 'course-samples', normalizedPlatform)
  112. ]);
  113. }
  114. function parseLastJson(stdout) {
  115. const text = String(stdout || '').trim();
  116. if (!text) return {};
  117. for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) {
  118. try {
  119. return JSON.parse(text.slice(i));
  120. } catch {
  121. continue;
  122. }
  123. }
  124. return {};
  125. }
  126. function runNode(scriptPath, args, label) {
  127. const child = spawnSync(process.execPath, [scriptPath, ...args], {
  128. cwd: process.cwd(),
  129. encoding: 'utf8',
  130. maxBuffer: 1024 * 1024 * 100
  131. });
  132. if (child.stdout) process.stdout.write(child.stdout);
  133. if (child.stderr) process.stderr.write(child.stderr);
  134. if (child.error) throw child.error;
  135. if (child.status !== 0) throw new Error(`${label} failed with exit code ${child.status}`);
  136. return parseLastJson(child.stdout);
  137. }
  138. function requiredTool(relativePath) {
  139. const resolved = resolveTool(relativePath);
  140. if (!resolved) throw new Error(`Required tool not found: ${relativePath}`);
  141. return resolved;
  142. }
  143. function main() {
  144. const args = parseArgs(process.argv.slice(2));
  145. if (args.help) {
  146. console.log(usage());
  147. return;
  148. }
  149. const platform = args.platform || 'xiaohongshu';
  150. const category = args.category || '';
  151. const project = args.project || 'openclaw-voc-course';
  152. const outputFormat = args['output-format'] || args.outputFormat || 'both';
  153. const sampleMode = toBool(args['sample-mode'] ?? args.sampleMode);
  154. const keywords = parseKeywords(args.keywords);
  155. const date = args.date || new Date().toISOString().slice(0, 10);
  156. const owner = args.owner || 'OpenClaw VOC Skills';
  157. if (!category) throw new Error('Missing required argument: --category');
  158. if (!['markdown', 'html', 'both'].includes(outputFormat)) throw new Error(`Invalid --output-format: ${outputFormat}`);
  159. const outputRoot = path.resolve(args.output || path.join(process.cwd(), 'openclaw-voc-output', `${slugify(platform)}-${slugify(category)}-${date}`));
  160. const normalizedDir = path.resolve(args['normalized-dir'] || args.normalizedDir || path.join(outputRoot, 'normalized'));
  161. const reportDir = path.join(outputRoot, 'platform-mini-report');
  162. const auditDir = path.join(outputRoot, 'audit');
  163. ensureDir(outputRoot);
  164. const rawDir = args['raw-dir'] || args.rawDir
  165. ? path.resolve(args['raw-dir'] || args.rawDir)
  166. : sampleMode
  167. ? resolveSampleDir(platform)
  168. : undefined;
  169. if (!rawDir && !fs.existsSync(path.join(normalizedDir, '_merged.json'))) {
  170. 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.');
  171. }
  172. const normalizer = requiredTool('scripts/tools/voc-data-normalizer.js');
  173. const miniReport = requiredTool('scripts/tools/platform-mini-report-generator.js');
  174. const auditor = requiredTool('scripts/tools/voc-report-auditor.js');
  175. const htmlGenerator = outputFormat === 'html' || outputFormat === 'both'
  176. ? requiredTool('voc-report-factory/html-v2/gen-report-template.js')
  177. : undefined;
  178. const steps = {};
  179. if (rawDir) {
  180. steps.normalizer = runNode(normalizer, [
  181. '--input', rawDir,
  182. '--output', normalizedDir,
  183. '--project', project,
  184. '--category', category
  185. ], 'voc-data-normalizer');
  186. }
  187. steps.miniReport = runNode(miniReport, [
  188. '--input', normalizedDir,
  189. '--output', reportDir,
  190. '--project', project,
  191. '--category', category,
  192. '--platform', platform,
  193. '--owner', owner,
  194. '--date', date,
  195. '--business-question', args['business-question'] || args.businessQuestion || '',
  196. '--keywords', keywords.join(',')
  197. ], 'platform-mini-report-generator');
  198. let htmlReportPath;
  199. if (htmlGenerator) {
  200. htmlReportPath = path.join(reportDir, 'platform-mini-report.html');
  201. steps.htmlReport = runNode(htmlGenerator, [
  202. '--data', path.join(reportDir, 'platform-mini-report.json'),
  203. '--output', htmlReportPath
  204. ], 'voc-html-report-generator');
  205. }
  206. steps.audit = runNode(auditor, [
  207. '--report', path.join(reportDir, 'platform-mini-report.md'),
  208. '--merged', path.join(normalizedDir, '_merged.json'),
  209. '--comments', path.join(normalizedDir, 'comments-flat.jsonl'),
  210. '--output', auditDir
  211. ], 'voc-report-auditor');
  212. const merged = readJson(path.join(normalizedDir, '_merged.json'));
  213. const report = readJson(path.join(reportDir, 'platform-mini-report.json'));
  214. const audit = readJson(path.join(auditDir, 'audit-result.json'));
  215. const result = {
  216. status: audit.status === 'fail' ? 'needs_review' : 'ok',
  217. metadata: {
  218. project,
  219. platform,
  220. category,
  221. businessQuestion: args['business-question'] || args.businessQuestion || '',
  222. keywords,
  223. generatedAt: new Date().toISOString()
  224. },
  225. summary: {
  226. itemCount: merged.metadata?.itemCount || 0,
  227. commentCount: merged.metadata?.validVocCount || report.stats?.commentCount || 0,
  228. keywordCount: keywords.length || merged.metadata?.keywords?.length || 0,
  229. evidenceCardCount: report.audit?.evidenceCardCount || 0,
  230. warningCount: (report.audit?.warningCount || 0) + (audit.summary?.warn || 0),
  231. auditStatus: audit.status
  232. },
  233. outputs: {
  234. outputRoot,
  235. normalized: normalizedDir,
  236. markdownReport: path.join(reportDir, 'platform-mini-report.md'),
  237. jsonReport: path.join(reportDir, 'platform-mini-report.json'),
  238. htmlReport: htmlReportPath,
  239. auditReport: path.join(auditDir, 'audit-report.md'),
  240. auditResult: path.join(auditDir, 'audit-result.json')
  241. },
  242. steps,
  243. nextActions: [
  244. '复核 audit-report.md 中的 warn/fail 项',
  245. '补充真实采集数据后复跑同一链路',
  246. '将 P0 机会点转成内容或产品验证动作'
  247. ]
  248. };
  249. console.log(`SINGLE_PLATFORM_REPORT_RESULT=${JSON.stringify(result)}`);
  250. }
  251. try {
  252. main();
  253. } catch (error) {
  254. const result = {
  255. status: 'error',
  256. message: error.message,
  257. generatedAt: new Date().toISOString()
  258. };
  259. console.error(error.message);
  260. console.log(`SINGLE_PLATFORM_REPORT_RESULT=${JSON.stringify(result)}`);
  261. process.exit(1);
  262. }