industry-trend-report.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const DEFAULT_RESULT_PREFIX = 'INDUSTRY_TREND_REPORT_RESULT';
  5. const SIGNAL_DICTIONARY = {
  6. style: [
  7. '奶油风', '原木风', '中古风', '法式', '侘寂', '极简', '现代', '轻奢',
  8. '北欧', '复古', '黑白灰', '松弛感', '温柔', '高级感'
  9. ],
  10. element: [
  11. '弧形', '一门到顶', '玻璃柜', '开放格', '灯带', '木纹', '隐形拉手',
  12. '肤感膜', '柜门', '岛台', '餐边柜', '玄关柜', '衣柜', '橱柜', '抽屉',
  13. '无主灯', '收纳', '转角', '嵌入式'
  14. ],
  15. decision: [
  16. '预算', '环保', '甲醛', '翻车', '好打理', '耐看', '显大', '采光',
  17. '落灰', '售后', '增项', '尺寸', '动线', '收纳不够', '柜子太满',
  18. '怕过时', '质感'
  19. ],
  20. action: [
  21. '避坑', '怎么选', '真实体验', '对比', '测评', '后悔', '建议', '清单',
  22. '案例', '改造', '装修日记'
  23. ]
  24. };
  25. function parseArgs(argv) {
  26. const args = {};
  27. for (let i = 0; i < argv.length; i++) {
  28. const token = argv[i];
  29. if (!token.startsWith('--')) continue;
  30. const eq = token.indexOf('=');
  31. if (eq >= 0) {
  32. args[token.slice(2, eq)] = token.slice(eq + 1);
  33. } else {
  34. const key = token.slice(2);
  35. const next = argv[i + 1];
  36. if (next && !next.startsWith('--')) {
  37. args[key] = next;
  38. i++;
  39. } else {
  40. args[key] = true;
  41. }
  42. }
  43. }
  44. return args;
  45. }
  46. function ensureDir(dirPath) {
  47. fs.mkdirSync(dirPath, { recursive: true });
  48. }
  49. function readJson(filePath) {
  50. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  51. }
  52. function writeJson(filePath, data) {
  53. fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
  54. }
  55. function asArray(value) {
  56. if (!value) return [];
  57. return Array.isArray(value) ? value : [value];
  58. }
  59. function cleanText(value) {
  60. return String(value || '').replace(/\s+/g, ' ').trim();
  61. }
  62. function truncate(value, length = 72) {
  63. const text = cleanText(value);
  64. if (text.length <= length) return text;
  65. return `${text.slice(0, Math.max(0, length - 1))}…`;
  66. }
  67. function uniq(values) {
  68. return [...new Set(values.filter(value => value !== undefined && value !== null && value !== ''))];
  69. }
  70. function parseList(value) {
  71. if (!value) return [];
  72. if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
  73. const text = String(value).trim();
  74. if (!text) return [];
  75. if (text.startsWith('[')) {
  76. try {
  77. const parsed = JSON.parse(text);
  78. return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : [];
  79. } catch {
  80. return [];
  81. }
  82. }
  83. return text.split(/[,,、;\n\r]+/).map(item => item.trim()).filter(Boolean);
  84. }
  85. function normalizeCount(value) {
  86. const numeric = Number(value || 0);
  87. return Number.isFinite(numeric) ? numeric : 0;
  88. }
  89. function noteScore(note) {
  90. return normalizeCount(note.likeCount) +
  91. normalizeCount(note.collectCount) * 1.5 +
  92. normalizeCount(note.commentCount) * 3 +
  93. normalizeCount(note.shareCount) * 2;
  94. }
  95. function commentScore(comment) {
  96. return normalizeCount(comment.likeCount) * 2 + cleanText(comment.text || comment.content).length / 30;
  97. }
  98. function countSignals(records, terms) {
  99. const counts = new Map();
  100. records.forEach(record => {
  101. const text = `${record.title || ''} ${record.content || ''} ${record.text || ''}`.toLowerCase();
  102. terms.forEach(term => {
  103. if (text.includes(String(term).toLowerCase())) {
  104. counts.set(term, (counts.get(term) || 0) + 1);
  105. }
  106. });
  107. });
  108. return [...counts.entries()]
  109. .map(([label, count]) => ({ label, count }))
  110. .sort((a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
  111. }
  112. function isHomeDesignIndustry(profile) {
  113. const text = [
  114. profile.industry,
  115. profile.businessType,
  116. ...asArray(profile.keywords),
  117. ...asArray(profile.mustTrackSignals)
  118. ].join(' ');
  119. return /家装|全屋定制|装修|衣柜|橱柜|门店|设计师/.test(text);
  120. }
  121. function countProfileSignals(records, profile) {
  122. const terms = uniq([
  123. ...asArray(profile.mustTrackSignals),
  124. ...asArray(profile.trendQuestions).flatMap(question => String(question).split(/[、,,。??\s]+/)),
  125. ...asArray(profile.keywords).slice(0, 8)
  126. ]).filter(term => String(term).length >= 2);
  127. return countSignals(records, terms).slice(0, 10);
  128. }
  129. function buildOneLine(profile, styleSignals, elementSignals, decisionSignals, profileSignals) {
  130. if (isHomeDesignIndustry(profile)) {
  131. return '本轮最值得关注的不是单一风格,而是“风格审美 + 好打理 + 收纳/预算风险”一起进入女性客户决策。';
  132. }
  133. const topTopic = profileSignals[0]?.label || elementSignals[0]?.label || styleSignals[0]?.label || '高互动内容主题';
  134. const topDecision = decisionSignals[0]?.label || '用户真实顾虑';
  135. return `本轮最值得关注的是“${topTopic} + ${topDecision} + 可验证行动机会”的组合,而不是单条爆款样本。`;
  136. }
  137. function keywordMatrixFromProfile(profile) {
  138. const baseKeywords = uniq([
  139. ...asArray(profile.keywords),
  140. ...asArray(profile.productsOrServices).slice(0, 4),
  141. profile.industry
  142. ]).filter(Boolean);
  143. const questions = asArray(profile.trendQuestions);
  144. const matrix = baseKeywords.slice(0, 12).map((keyword, index) => ({
  145. keyword,
  146. batch: 'P0',
  147. purpose: index === 0 ? '抓取行业基础讨论' : '追踪趋势与用户决策信号',
  148. expectedSignal: asArray(profile.mustTrackSignals).slice(0, 6).join('、') || '趋势、痛点、决策、内容机会',
  149. source: 'profile'
  150. }));
  151. questions.slice(0, 4).forEach(question => {
  152. const keyword = question.replace(/[??。,.,]/g, '').slice(0, 18);
  153. if (keyword && !matrix.some(row => row.keyword === keyword)) {
  154. matrix.push({
  155. keyword,
  156. batch: 'P1',
  157. purpose: '回答趋势问题',
  158. expectedSignal: question,
  159. source: 'trendQuestion'
  160. });
  161. }
  162. });
  163. return matrix;
  164. }
  165. function buildSampleDataset(profile, keywordMatrix) {
  166. const keywords = keywordMatrix.length ? keywordMatrix.map(row => row.keyword) : parseList(profile.keywords);
  167. const seed = keywords.length ? keywords : ['全屋定制', '奶油风装修', '小户型收纳'];
  168. const notes = [
  169. {
  170. id: 'xhs-home-001',
  171. platform: 'xiaohongshu',
  172. keyword: seed[0],
  173. title: '全屋定制最容易翻车的不是价格,是柜子做完不好住',
  174. content: '评论里很多女生说,装修前只看了奶油风效果图,真正入住后才发现开放格落灰、柜门不好打理、收纳不够。全屋定制要先想生活动线,再看风格。',
  175. author: '住进理想家',
  176. likeCount: 4280,
  177. collectCount: 1900,
  178. commentCount: 386,
  179. shareCount: 220,
  180. url: 'https://www.xiaohongshu.com/explore/sample-home-001',
  181. tags: ['全屋定制', '装修避坑', '收纳']
  182. },
  183. {
  184. id: 'xhs-home-002',
  185. platform: 'xiaohongshu',
  186. keyword: seed[1] || seed[0],
  187. title: '奶油风下半年还会流行吗?关键看材质和灯光',
  188. content: '奶油风不是越白越好看,真正高级的是低饱和颜色、木纹、弧形和无主灯配合。很多人担心过时,评论更关心耐看、好打理、显大。',
  189. author: '软装设计阿晴',
  190. likeCount: 6920,
  191. collectCount: 4100,
  192. commentCount: 612,
  193. shareCount: 488,
  194. url: 'https://www.xiaohongshu.com/explore/sample-home-002',
  195. tags: ['奶油风', '装修风格', '设计元素']
  196. },
  197. {
  198. id: 'xhs-home-003',
  199. platform: 'xiaohongshu',
  200. keyword: seed[2] || seed[0],
  201. title: '小户型收纳别再一屋子柜子,女生真正怕的是压抑',
  202. content: '一门到顶、玄关柜、餐边柜都很火,但评论区反复提到采光、显大、动线和预算。收纳不是柜子越多越好,而是高频物品要顺手。',
  203. author: '小户型研究所',
  204. likeCount: 5110,
  205. collectCount: 2800,
  206. commentCount: 455,
  207. shareCount: 306,
  208. url: 'https://www.xiaohongshu.com/explore/sample-home-003',
  209. tags: ['小户型', '收纳', '玄关柜']
  210. },
  211. {
  212. id: 'xhs-home-004',
  213. platform: 'xiaohongshu',
  214. keyword: seed[3] || seed[0],
  215. title: '衣柜设计避坑:肤感膜、玻璃柜、开放格到底怎么选',
  216. content: '高赞评论都在问环保、落灰、预算和售后。女性客户不是不喜欢设计感,而是怕好看但不好住、好看但难打理。',
  217. author: '定制柜设计师Lynn',
  218. likeCount: 3760,
  219. collectCount: 2100,
  220. commentCount: 334,
  221. shareCount: 180,
  222. url: 'https://www.xiaohongshu.com/explore/sample-home-004',
  223. tags: ['衣柜设计', '环保', '好打理']
  224. }
  225. ];
  226. const comments = [
  227. { id: 'c001', noteId: 'xhs-home-001', keyword: notes[0].keyword, text: '我家就是开放格太多,现在每天擦灰,真的后悔。', likeCount: 92, theme: '翻车风险' },
  228. { id: 'c002', noteId: 'xhs-home-001', keyword: notes[0].keyword, text: '全屋定制最怕增项,前期报价看不懂,后面预算一路涨。', likeCount: 118, theme: '预算顾虑' },
  229. { id: 'c003', noteId: 'xhs-home-002', keyword: notes[1].keyword, text: '奶油风好看但怕过时,想要耐看一点的,不要太网红。', likeCount: 156, theme: '风格决策' },
  230. { id: 'c004', noteId: 'xhs-home-002', keyword: notes[1].keyword, text: '低饱和颜色加木纹真的比纯白高级,灯光也很重要。', likeCount: 84, theme: '设计元素' },
  231. { id: 'c005', noteId: 'xhs-home-003', keyword: notes[2].keyword, text: '小户型柜子做满会很压抑,还是要留一点呼吸感。', likeCount: 121, theme: '空间体验' },
  232. { id: 'c006', noteId: 'xhs-home-003', keyword: notes[2].keyword, text: '我最关心玄关能不能放下鞋子、包、快递和雨伞。', likeCount: 77, theme: '使用场景' },
  233. { id: 'c007', noteId: 'xhs-home-004', keyword: notes[3].keyword, text: '肤感膜到底好不好打理?有小孩家庭是不是很容易留印子?', likeCount: 103, theme: '材质顾虑' },
  234. { id: 'c008', noteId: 'xhs-home-004', keyword: notes[3].keyword, text: '环保真的要讲清楚,不然再好看也不敢下单。', likeCount: 141, theme: '信任门槛' }
  235. ];
  236. return {
  237. metadata: {
  238. platform: 'xiaohongshu',
  239. dataNature: 'P0 sample dataset',
  240. generatedAt: new Date().toISOString()
  241. },
  242. notes,
  243. comments
  244. };
  245. }
  246. function normalizeDataset(input) {
  247. if (!input) return { notes: [], comments: [] };
  248. if (Array.isArray(input)) return { notes: input, comments: [] };
  249. return {
  250. metadata: input.metadata || {},
  251. notes: asArray(input.notes || input.items),
  252. comments: asArray(input.comments || input.commentsFlat)
  253. };
  254. }
  255. function buildTrendHypotheses(profile, styleSignals, elementSignals, decisionSignals, profileSignals = []) {
  256. const industry = profile.industry || '当前行业';
  257. const audience = asArray(profile.targetAudience)[0] || '目标客户';
  258. const topStyle = styleSignals[0]?.label || '高互动风格';
  259. const topElement = elementSignals[0]?.label || profileSignals[0]?.label || '高频内容元素';
  260. const topDecision = decisionSignals[0]?.label || '关键决策顾虑';
  261. if (!isHomeDesignIndustry(profile)) {
  262. return [
  263. {
  264. title: `${topElement} 可以作为下周期内容和产品沟通的切入点`,
  265. confidence: elementSignals[0] || profileSignals[0] ? '中' : '低',
  266. action: `把 ${topElement} 拆成“用户为什么关心、现在怎么判断、下一步怎么验证”三个回答。`
  267. },
  268. {
  269. title: `${audience} 的核心阻力不是没有兴趣,而是担心 ${topDecision}`,
  270. confidence: decisionSignals[0] ? '中' : '低',
  271. action: `内容和销售话术要先回应 ${topDecision},再展示 ${industry} 的方案优势。`
  272. },
  273. {
  274. title: `高互动样本更适合沉淀为“趋势假设”,不要直接照搬结论`,
  275. confidence: '中',
  276. action: '把高互动样本拆成主题、证据、评论原声和可验证动作,再决定是否进入下轮监听。'
  277. }
  278. ];
  279. }
  280. return [
  281. {
  282. title: `${topStyle} 仍有热度,但用户会从“好看”追问到“耐看和好打理”`,
  283. confidence: styleSignals[0] ? '中' : '低',
  284. action: `整理 3 套 ${topStyle} 的真实案例,重点讲清颜色、材质、灯光和维护成本。`
  285. },
  286. {
  287. title: `${topElement} 可以作为下周期内容和门店讲解的切入点`,
  288. confidence: elementSignals[0] ? '中' : '低',
  289. action: `把 ${topElement} 拆成“适合什么户型、不适合什么家庭、预算影响”三个回答。`
  290. },
  291. {
  292. title: `${audience} 的核心阻力不是不喜欢设计,而是担心 ${topDecision}`,
  293. confidence: decisionSignals[0] ? '中' : '低',
  294. action: `销售和设计师话术要先回应 ${topDecision},再展示 ${industry} 的方案优势。`
  295. }
  296. ];
  297. }
  298. function buildSuggestedActions(profile) {
  299. if (isHomeDesignIndustry(profile)) {
  300. return [
  301. '门店:把高频顾虑整理成“预算、环保、好打理、收纳”四张解释卡。',
  302. '设计师:讲方案时先回应翻车风险,再展示风格效果图。',
  303. '内容:优先拍“真实案例 + 避坑 + 选择标准”,少发无评论支撑的纯美图。'
  304. ];
  305. }
  306. return [
  307. '内容:优先选择“真实案例 + 用户顾虑 + 判断标准”的选题,不要只搬运高互动标题。',
  308. '产品/服务:把高频评论里的疑问整理成可验证假设,下一轮用样本继续确认。',
  309. '销售/转化:先回应用户最担心的阻力,再给方案、案例或对比证据。'
  310. ];
  311. }
  312. function buildCalibrationQuestions(profile) {
  313. if (isHomeDesignIndustry(profile)) {
  314. return [
  315. '今天这些风格/元素里,哪 3 个最值得继续跟踪?',
  316. '哪些方向明显不适合你的客户或门店定位?',
  317. '下一版更偏设计趋势、客户决策、门店转化,还是小红书内容选题?',
  318. '是否要新增关注区域、户型、预算带或竞品品牌?'
  319. ];
  320. }
  321. return [
  322. '今天这些趋势信号里,哪 3 个最值得继续跟踪?',
  323. '哪些方向明显不适合你的客户、产品或品牌定位?',
  324. '下一版更偏趋势判断、用户洞察、内容选题、销售转化,还是竞品观察?',
  325. '是否要新增关注平台、关键词、人群、价格带或竞品品牌?'
  326. ];
  327. }
  328. function markdownTable(rows, headers) {
  329. const safeRows = rows.length ? rows : [headers.map(() => '暂无')];
  330. return [
  331. `| ${headers.join(' | ')} |`,
  332. `| ${headers.map(() => '---').join(' | ')} |`,
  333. ...safeRows.map(row => `| ${row.map(cell => String(cell ?? '').replace(/\|/g, '/')).join(' | ')} |`)
  334. ].join('\n');
  335. }
  336. function buildReport({ profile, dataset, keywordMatrix, outputDir }) {
  337. const normalized = normalizeDataset(dataset);
  338. const notes = normalized.notes;
  339. const comments = normalized.comments;
  340. const allRecords = [...notes, ...comments];
  341. const styleSignals = countSignals(allRecords, SIGNAL_DICTIONARY.style);
  342. const elementSignals = countSignals(allRecords, SIGNAL_DICTIONARY.element);
  343. const decisionSignals = countSignals(allRecords, SIGNAL_DICTIONARY.decision);
  344. const actionSignals = countSignals(allRecords, SIGNAL_DICTIONARY.action);
  345. const profileSignals = countProfileSignals(allRecords, profile);
  346. const highValueNotes = [...notes].sort((a, b) => noteScore(b) - noteScore(a)).slice(0, 5);
  347. const highValueComments = [...comments].sort((a, b) => commentScore(b) - commentScore(a)).slice(0, 8);
  348. const trendHypotheses = buildTrendHypotheses(profile, styleSignals, elementSignals, decisionSignals, profileSignals);
  349. const suggestedActions = buildSuggestedActions(profile);
  350. const calibrationQuestions = buildCalibrationQuestions(profile);
  351. const keywordRows = keywordMatrix.slice(0, 8).map(row => [
  352. row.keyword,
  353. row.purpose || '趋势监听',
  354. row.expectedSignal || '趋势/痛点/决策信号'
  355. ]);
  356. const sampleLine = `${notes.length} 篇笔记 / ${comments.length} 条评论 / ${uniq(notes.map(note => note.keyword)).length || keywordMatrix.length} 个关键词`;
  357. const oneLine = buildOneLine(profile, styleSignals, elementSignals, decisionSignals, profileSignals);
  358. const report = {
  359. status: 'ok',
  360. project: profile.project || 'industry-trend-intelligence',
  361. industry: profile.industry || '未填写行业',
  362. generatedAt: new Date().toISOString(),
  363. sample: {
  364. noteCount: notes.length,
  365. commentCount: comments.length,
  366. keywordCount: uniq(notes.map(note => note.keyword)).length || keywordMatrix.length
  367. },
  368. oneLineJudgement: oneLine,
  369. signals: {
  370. style: styleSignals.slice(0, 8),
  371. element: elementSignals.slice(0, 8),
  372. decision: decisionSignals.slice(0, 8),
  373. action: actionSignals.slice(0, 8),
  374. profile: profileSignals.slice(0, 8)
  375. },
  376. highValueNotes,
  377. highValueComments,
  378. trendHypotheses,
  379. keywordMatrix
  380. };
  381. const signalLines = [
  382. profileSignals.length ? `- 重点信号:${profileSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、')}` : '',
  383. `- 风格信号:${styleSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显风格词'}`,
  384. `- 内容/元素:${elementSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显元素词'}`,
  385. `- 决策顾虑:${decisionSignals.slice(0, 5).map(item => `${item.label}(${item.count})`).join('、') || '暂无明显顾虑词'}`
  386. ].filter(Boolean);
  387. const assistantMessage = [
  388. `# 行业趋势情报日报`,
  389. '',
  390. `行业:${report.industry}`,
  391. `样本:${sampleLine}`,
  392. '',
  393. `## 一句话判断`,
  394. '',
  395. oneLine,
  396. '',
  397. `## 高热趋势信号`,
  398. '',
  399. ...signalLines,
  400. '',
  401. `## 高价值样本`,
  402. '',
  403. ...highValueNotes.slice(0, 3).map((note, index) => {
  404. return `${index + 1}. ${truncate(note.title, 42)} / ${note.author || '未知作者'} / 点赞 ${normalizeCount(note.likeCount)} / 评论 ${normalizeCount(note.commentCount)} / 收藏 ${normalizeCount(note.collectCount)}`;
  405. }),
  406. '',
  407. `## 下周期可验证趋势假设`,
  408. '',
  409. ...trendHypotheses.map((item, index) => `${index + 1}. ${item.title}(置信度:${item.confidence})`),
  410. '',
  411. `## 建议动作`,
  412. '',
  413. ...suggestedActions.map(item => `- ${item}`),
  414. '',
  415. `## 校准问题`,
  416. '',
  417. ...calibrationQuestions.map((item, index) => `${index + 1}. ${item}`)
  418. ].join('\n');
  419. const fullMarkdown = [
  420. assistantMessage,
  421. '',
  422. `## 关键词矩阵`,
  423. '',
  424. markdownTable(keywordRows, ['关键词', '采集目的', '预期信号']),
  425. '',
  426. `## 高赞评论原声`,
  427. '',
  428. ...highValueComments.slice(0, 6).map(comment => `- ${comment.text || comment.content}(赞 ${normalizeCount(comment.likeCount)} / ${comment.theme || '未标注'})`)
  429. ].join('\n');
  430. if (outputDir) {
  431. ensureDir(outputDir);
  432. fs.writeFileSync(path.join(outputDir, 'trend-report.md'), fullMarkdown, 'utf8');
  433. writeJson(path.join(outputDir, 'trend-report.json'), report);
  434. }
  435. return {
  436. ...report,
  437. assistantMessage,
  438. markdown: fullMarkdown,
  439. files: outputDir ? [
  440. path.join(outputDir, 'trend-report.md'),
  441. path.join(outputDir, 'trend-report.json')
  442. ] : []
  443. };
  444. }
  445. function main() {
  446. const args = parseArgs(process.argv.slice(2));
  447. const profile = args.profile ? readJson(path.resolve(args.profile)) : {};
  448. const input = args.input ? readJson(path.resolve(args.input)) : undefined;
  449. const keywordMatrix = args['keyword-matrix']
  450. ? readJson(path.resolve(args['keyword-matrix']))
  451. : keywordMatrixFromProfile(profile);
  452. const outputDir = args.output ? path.resolve(args.output) : undefined;
  453. const dataset = input || buildSampleDataset(profile, keywordMatrix);
  454. const result = buildReport({ profile, dataset, keywordMatrix, outputDir });
  455. const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX;
  456. console.log(`${prefix}=${JSON.stringify(result)}`);
  457. }
  458. if (require.main === module) {
  459. try {
  460. main();
  461. } catch (error) {
  462. console.error(error && error.stack ? error.stack : String(error));
  463. process.exit(1);
  464. }
  465. }
  466. module.exports = {
  467. buildReport,
  468. buildSampleDataset,
  469. keywordMatrixFromProfile,
  470. parseList
  471. };