cro-report-generator.js
1 #!/usr/bin/env node 2 3 /** 4 * CRO Report Generator 5 * Generates PDF reports from site scoring data 6 * Requires: npm install pdfkit 7 */ 8 9 import PDFDocument from 'pdfkit'; 10 import { createWriteStream, existsSync, mkdirSync } from 'fs'; 11 import { join, dirname } from 'path'; 12 import { fileURLToPath } from 'url'; 13 import { run, getOne } from '../utils/db.js'; 14 import Logger from '../utils/logger.js'; 15 import { getScoreDataWithFallback } from '../utils/score-storage.js'; 16 import '../utils/load-env.js'; 17 18 const __filename = fileURLToPath(import.meta.url); 19 const __dirname = dirname(__filename); 20 const projectRoot = join(__dirname, '../..'); 21 22 const logger = new Logger('CROReport'); 23 24 // Report output directory 25 const REPORTS_DIR = join(projectRoot, 'reports'); 26 27 // Ensure reports directory exists 28 if (!existsSync(REPORTS_DIR)) { 29 mkdirSync(REPORTS_DIR, { recursive: true }); 30 } 31 32 /** 33 * Generate CRO report PDF 34 * @param {number} siteId - Site ID to generate report for 35 * @param {number} conversationId - Conversation ID for tracking 36 * @returns {Promise<string>} Path to generated PDF 37 */ 38 export async function generateReport(siteId, conversationId) { 39 logger.info(`Generating CRO report for site ${siteId}`); 40 41 // Fetch site data 42 const site = await getOne( 43 `SELECT id, domain, landing_page_url, keyword, score, grade, scored_at 44 FROM sites 45 WHERE id = $1`, 46 [siteId] 47 ); 48 49 if (!site) { 50 throw new Error(`Site ${siteId} not found`); 51 } 52 53 // Parse scoring data (filesystem first, DB fallback) 54 const scoreData = getScoreDataWithFallback(siteId, site); 55 if (!scoreData) { 56 throw new Error(`Site ${siteId} has no scoring data`); 57 } 58 59 // Generate PDF filename 60 const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 61 const filename = `cro-report-${site.domain}-${timestamp}.pdf`; 62 const filepath = join(REPORTS_DIR, filename); 63 64 // Create PDF 65 await createReportPDF(site, scoreData, filepath); 66 67 // Update message with report URL 68 await run( 69 `UPDATE messages 70 SET report_url = $1, 71 status = 'report_delivered' 72 WHERE id = $2`, 73 [filename, conversationId] 74 ); 75 76 logger.success(`Generated report: ${filename}`); 77 78 return filepath; 79 } 80 81 /** 82 * Create PDF document with CRO analysis 83 */ 84 // eslint-disable-next-line require-await -- Wraps Promise-based PDF generation 85 async function createReportPDF(site, scoreData, filepath) { 86 return new Promise((resolve, reject) => { 87 try { 88 const doc = new PDFDocument({ size: 'A4', margin: 50 }); 89 const stream = createWriteStream(filepath); 90 91 doc.pipe(stream); 92 93 // Header 94 doc 95 .fontSize(24) 96 .font('Helvetica-Bold') 97 .text('Conversion Rate Optimization Report', { align: 'center' }); 98 99 doc.moveDown(0.5); 100 101 doc.fontSize(16).font('Helvetica').fillColor('#666').text(site.domain, { align: 'center' }); 102 103 doc.moveDown(1); 104 105 // Summary Section 106 addSection(doc, 'Executive Summary'); 107 108 doc 109 .fontSize(12) 110 .font('Helvetica') 111 .fillColor('#000') 112 .text(`Website: ${site.landing_page_url}`); 113 114 doc.text(`Analysis Date: ${new Date(site.scored_at).toLocaleDateString()}`); 115 116 doc.moveDown(0.5); 117 118 // Overall Score Box 119 doc.rect(50, doc.y, 495, 80).fillAndStroke('#f0f0f0', '#333').fillColor('#000'); 120 121 const gradeColor = getGradeColor(site.grade); 122 123 doc 124 .fontSize(48) 125 .font('Helvetica-Bold') 126 .fillColor(gradeColor) 127 .text(site.grade, 70, doc.y - 70, { width: 100, align: 'center' }); 128 129 doc 130 .fontSize(14) 131 .font('Helvetica') 132 .fillColor('#000') 133 .text(`Overall Score: ${site.score}/100`, 200, doc.y - 105); 134 135 doc.text(`Conversion Potential: ${getConversionPotential(site.score)}`, 200, doc.y + 15); 136 137 doc.moveDown(4); 138 139 // Category Scores 140 if (scoreData.category_scores) { 141 addSection(doc, 'Category Analysis'); 142 143 const categories = scoreData.category_scores; 144 145 addCategoryScore(doc, 'Above-the-Fold Experience', categories.above_fold_score); 146 addCategoryScore(doc, 'Call-to-Action (CTA) Effectiveness', categories.cta_score); 147 addCategoryScore(doc, 'Trust & Credibility Signals', categories.trust_score); 148 addCategoryScore(doc, 'Mobile Optimization', categories.mobile_score); 149 addCategoryScore(doc, 'User Experience (UX)', categories.ux_score); 150 151 doc.moveDown(1); 152 } 153 154 // Key Findings 155 if (scoreData.major_issues || scoreData.strengths) { 156 addSection(doc, 'Key Findings'); 157 158 if (scoreData.major_issues && scoreData.major_issues.length > 0) { 159 doc.fontSize(12).font('Helvetica-Bold').fillColor('#d32f2f').text('Critical Issues:'); 160 161 doc.font('Helvetica').fillColor('#000'); 162 163 scoreData.major_issues.slice(0, 5).forEach((issue, index) => { 164 doc.text(`${index + 1}. ${issue}`, { indent: 20 }); 165 }); 166 167 doc.moveDown(1); 168 } 169 170 if (scoreData.strengths && scoreData.strengths.length > 0) { 171 doc.fontSize(12).font('Helvetica-Bold').fillColor('#388e3c').text('Strengths:'); 172 173 doc.font('Helvetica').fillColor('#000'); 174 175 scoreData.strengths.slice(0, 5).forEach((strength, index) => { 176 doc.text(`${index + 1}. ${strength}`, { indent: 20 }); 177 }); 178 179 doc.moveDown(1); 180 } 181 } 182 183 // Recommendations 184 if (scoreData.recommendations || scoreData.quick_wins) { 185 doc.addPage(); 186 addSection(doc, 'Recommendations'); 187 188 if (scoreData.quick_wins && scoreData.quick_wins.length > 0) { 189 doc 190 .fontSize(12) 191 .font('Helvetica-Bold') 192 .fillColor('#1976d2') 193 .text('Quick Wins (High Impact, Low Effort):'); 194 195 doc.font('Helvetica').fillColor('#000'); 196 197 scoreData.quick_wins.forEach((win, index) => { 198 doc.text(`${index + 1}. ${win}`, { indent: 20 }); 199 }); 200 201 doc.moveDown(1); 202 } 203 204 if (scoreData.recommendations && scoreData.recommendations.length > 0) { 205 doc.fontSize(12).font('Helvetica-Bold').text('Strategic Recommendations:'); 206 207 doc.font('Helvetica'); 208 209 scoreData.recommendations.slice(0, 10).forEach((rec, index) => { 210 doc.text(`${index + 1}. ${rec}`, { indent: 20 }); 211 }); 212 } 213 } 214 215 // Footer 216 doc 217 .fontSize(10) 218 .fillColor('#666') 219 .text( 220 `Report generated on ${new Date().toLocaleDateString()} | Confidential`, 221 50, 222 doc.page.height - 50, 223 { 224 align: 'center', 225 } 226 ); 227 228 // Finalize PDF 229 doc.end(); 230 231 stream.on('finish', () => { 232 logger.info(`PDF written to ${filepath}`); 233 resolve(filepath); 234 }); 235 236 stream.on('error', reject); 237 } catch (error) { 238 reject(error); 239 } 240 }); 241 } 242 243 /** 244 * Add section header to PDF 245 */ 246 function addSection(doc, title) { 247 doc.fontSize(18).font('Helvetica-Bold').fillColor('#333').text(title); 248 249 doc.moveDown(0.5); 250 doc.strokeColor('#ddd').lineWidth(2).moveTo(50, doc.y).lineTo(545, doc.y).stroke(); 251 doc.moveDown(0.5); 252 } 253 254 /** 255 * Add category score bar to PDF 256 */ 257 function addCategoryScore(doc, label, score) { 258 const barWidth = 400; 259 const barHeight = 20; 260 const fillWidth = (score / 100) * barWidth; 261 262 doc.fontSize(11).font('Helvetica').fillColor('#000').text(label); 263 264 const yPos = doc.y + 5; 265 266 // Background bar 267 doc.rect(50, yPos, barWidth, barHeight).fillAndStroke('#e0e0e0', '#999'); 268 269 // Filled bar 270 const barColor = getScoreColor(score); 271 doc.rect(50, yPos, fillWidth, barHeight).fillAndStroke(barColor, '#999'); 272 273 // Score text 274 doc 275 .fontSize(11) 276 .font('Helvetica-Bold') 277 .fillColor('#000') 278 .text(`${score}/100`, 460, yPos + 3); 279 280 doc.moveDown(1.5); 281 } 282 283 /** 284 * Get color for score 285 */ 286 function getScoreColor(score) { 287 if (score >= 85) return '#4caf50'; // Green 288 if (score >= 70) return '#8bc34a'; // Light green 289 if (score >= 50) return '#ffc107'; // Amber 290 if (score >= 30) return '#ff9800'; // Orange 291 return '#f44336'; // Red 292 } 293 294 /** 295 * Get color for grade 296 */ 297 function getGradeColor(grade) { 298 if (grade?.startsWith('A')) return '#4caf50'; 299 if (grade?.startsWith('B')) return '#8bc34a'; 300 if (grade?.startsWith('C')) return '#ffc107'; 301 if (grade?.startsWith('D')) return '#ff9800'; 302 return '#f44336'; 303 } 304 305 /** 306 * Get conversion potential description 307 */ 308 function getConversionPotential(score) { 309 if (score >= 90) return 'Excellent - Minor optimizations only'; 310 if (score >= 82) return 'Good - Some improvement opportunities'; 311 if (score >= 70) return 'Fair - Significant improvements needed'; 312 if (score >= 50) return 'Poor - Major conversion issues'; 313 return 'Critical - Urgent optimization required'; 314 } 315 316 // CLI functionality 317 if (import.meta.url === `file://${process.argv[1]}`) { 318 const command = process.argv[2]; 319 320 if (command === 'generate') { 321 const siteId = parseInt(process.argv[3]); 322 const conversationId = parseInt(process.argv[4]); 323 324 if (!siteId || !conversationId) { 325 console.error( 326 'Usage: node src/reports/cro-report-generator.js generate <site_id> <conversation_id>' 327 ); 328 process.exit(1); 329 } 330 331 generateReport(siteId, conversationId) 332 .then(filepath => { 333 console.log(`\n✅ Report Generated\n`); 334 console.log(`Location: ${filepath}\n`); 335 process.exit(0); 336 }) 337 .catch(error => { 338 logger.error('Report generation failed', error); 339 process.exit(1); 340 }); 341 } else { 342 console.log('CRO Report Generator'); 343 console.log(''); 344 console.log('Usage:'); 345 console.log(' generate <site_id> <conversation_id> - Generate PDF report'); 346 console.log(''); 347 console.log('Examples:'); 348 console.log(' node src/reports/cro-report-generator.js generate 123 456'); 349 console.log(''); 350 console.log('Note: Requires pdfkit package (npm install pdfkit)\n'); 351 process.exit(1); 352 } 353 } 354 355 // Exported for testing 356 export { getScoreColor, getGradeColor, getConversionPotential }; 357 358 export default { 359 generateReport, 360 };