report-orchestrator.js
1 #!/usr/bin/env node 2 3 /** 4 * Report Orchestrator 5 * 6 * Orchestrates the full audit report pipeline: 7 * 1. Fetch purchase record 8 * 2. Find or create site record 9 * 3. Capture full-page screenshot 10 * 4. Refresh score via LLM 11 * 5. Crop problem areas 12 * 6. Generate PDF report 13 * 7. Update purchase record 14 */ 15 16 import { run, getOne } from '../utils/db.js'; 17 import { join, dirname } from 'path'; 18 import { fileURLToPath } from 'url'; 19 import { captureFullPage } from './full-page-capture.js'; 20 import { refreshScore } from './score-refresh.js'; 21 import { cropProblemAreas } from './problem-area-cropper.js'; 22 import { generateMockups } from './mockup-generator.js'; 23 import { generateReportHTML } from './html-report-template.js'; 24 import { renderReportPDF } from './html-to-pdf.js'; 25 import Logger from '../utils/logger.js'; 26 import { extractDomain } from '../utils/error-handler.js'; 27 import { setScoreJson } from '../utils/score-storage.js'; 28 import '../utils/load-env.js'; 29 30 const __filename = fileURLToPath(import.meta.url); 31 const __dirname = dirname(__filename); 32 const projectRoot = join(__dirname, '../..'); 33 34 const logger = new Logger('ReportOrchestrator'); 35 36 /** 37 * Generate a complete audit report for a purchase 38 * @param {number} purchaseId - Purchase ID 39 * @param {Object} [options] 40 * @param {'full'|'quick-fixes'} [options.variant='full'] - Report variant 41 * @returns {Object} { reportPath, score, grade } 42 */ 43 export async function generateAuditReportForPurchase(purchaseId, { variant = 'full' } = {}) { 44 // 1. Fetch purchase 45 const purchase = await getOne('SELECT * FROM purchases WHERE id = $1', [purchaseId]); 46 if (!purchase) { 47 throw new Error(`Purchase ${purchaseId} not found`); 48 } 49 50 const url = purchase.landing_page_url; 51 const domain = extractDomain(url); 52 53 logger.info(`Generating report for purchase #${purchaseId}: ${domain}`); 54 55 // 2. Find or create site record 56 let siteId = purchase.site_id; 57 if (!siteId) { 58 // Check if site exists by domain or URL 59 const existingSite = await getOne( 60 'SELECT id FROM sites WHERE landing_page_url = $1 OR domain = $2', 61 [url, domain] 62 ); 63 64 if (existingSite) { 65 siteId = existingSite.id; 66 } else { 67 // Create new site record 68 const result = await run( 69 `INSERT INTO sites (domain, landing_page_url, keyword, status, country_code) 70 VALUES ($1, $2, $3, 'found', $4) 71 RETURNING id`, 72 [domain, url, 'audit-report-purchase', purchase.country_code] 73 ); 74 siteId = result.lastInsertRowid; 75 } 76 77 // Link site to purchase 78 await run('UPDATE purchases SET site_id = $1 WHERE id = $2', [siteId, purchaseId]); 79 } 80 81 // 3. Full-page capture (initial pass — no selectors yet, we don't know them until step 4) 82 logger.info('Capturing full-page screenshot...'); 83 const captureResult = await captureFullPage(url); 84 85 // 4. Score refresh via Opus with extended thinking 86 logger.info('Refreshing score via Opus...'); 87 const scoreJson = await refreshScore({ 88 url, 89 fullPageBuffer: captureResult.fullPageBuffer, 90 aboveFoldBuffer: captureResult.aboveFoldBuffer, 91 htmlContent: captureResult.htmlContent, 92 httpHeaders: captureResult.httpHeaders, 93 siteId, 94 countryCode: purchase.country_code, 95 }); 96 97 const score = scoreJson.overall_calculation?.conversion_score || 0; 98 const grade = scoreJson.overall_calculation?.letter_grade || 'N/A'; 99 100 // Quick Fixes variant: keep only the 5 worst-scoring factors and trim related sections 101 if (variant === 'quick-fixes') { 102 logger.info('Quick Fixes variant — filtering to top 5 worst factors'); 103 const factors = scoreJson.factor_scores || {}; 104 const sorted = Object.entries(factors).sort((a, b) => (a[1].score ?? 10) - (b[1].score ?? 10)); 105 scoreJson.factor_scores = Object.fromEntries(sorted.slice(0, 5)); 106 if (scoreJson.problem_areas) scoreJson.problem_areas = scoreJson.problem_areas.slice(0, 5); 107 if (scoreJson.strategic_recommendations) scoreJson.strategic_recommendations = scoreJson.strategic_recommendations.slice(0, 7); 108 if (scoreJson.report_narratives) delete scoreJson.report_narratives.action_plan_quarter; 109 } 110 111 // 5. Generate before/after mockups for each problem area. 112 // Opens the live page again, screenshots each selector, applies mockup_changes, 113 // then screenshots the "after" state. Falls back to y% crop if selector fails. 114 const problemAreas = scoreJson.problem_areas || []; 115 let problemCrops; 116 117 const hasMockupChanges = problemAreas.some(a => a.mockup_changes?.length > 0); 118 if (hasMockupChanges) { 119 logger.info('Generating before/after mockups...'); 120 try { 121 problemCrops = await generateMockups(url, problemAreas, captureResult.fullPageBuffer); 122 } catch (err) { 123 logger.warn(`Mockup generation failed — falling back to static crops: ${err.message}`); 124 problemCrops = await cropProblemAreas(captureResult.fullPageBuffer, problemAreas); 125 } 126 } else { 127 // Legacy path: no mockup_changes in score_json — use static crop 128 logger.info('No mockup_changes found — using static crops...'); 129 problemCrops = await cropProblemAreas(captureResult.fullPageBuffer, problemAreas); 130 } 131 132 // 6. Generate HTML report and render to PDF 133 const reportDir = join(projectRoot, 'reports', 'purchases', String(purchaseId)); 134 const reportFilename = variant === 'quick-fixes' ? 'quick-fixes-report.pdf' : 'audit-report.pdf'; 135 const reportPath = join(reportDir, reportFilename); 136 137 logger.info('Generating HTML report...'); 138 const visionUsed = 139 scoreJson.scoring_method === 'vision' || scoreJson.scoring_method === 'vision+html'; 140 const html = generateReportHTML({ 141 domain, 142 url, 143 scoreJson, 144 aboveFoldBuffer: captureResult.aboveFoldBuffer, 145 problemCrops, 146 narrativeSections: scoreJson.report_narratives, 147 reportDate: new Date(), 148 visionUsed, 149 variant, 150 }); 151 152 logger.info('Rendering PDF...'); 153 await renderReportPDF({ html, outputPath: reportPath }); 154 155 // 7. Update purchase and site records 156 await run( 157 `UPDATE purchases 158 SET status = 'report_generated', report_path = $1, report_score = $2, report_grade = $3, updated_at = NOW() 159 WHERE id = $4`, 160 [reportPath, score, grade, purchaseId] 161 ); 162 163 setScoreJson(siteId, JSON.stringify(scoreJson)); 164 await run( 165 `UPDATE sites 166 SET score = $1, grade = $2, score_json = '{"_fs":true}', scored_at = NOW(), updated_at = NOW() 167 WHERE id = $3`, 168 [score, grade, siteId] 169 ); 170 171 logger.success(`Report generated for purchase #${purchaseId}: ${score} (${grade})`); 172 173 return { reportPath, score, grade }; 174 } 175 176 // CLI 177 if (import.meta.url === `file://${process.argv[1]}`) { 178 const purchaseId = parseInt(process.argv[2]); 179 180 if (!purchaseId) { 181 console.log('Usage: node src/reports/report-orchestrator.js <purchase_id>'); 182 process.exit(1); 183 } 184 185 generateAuditReportForPurchase(purchaseId) 186 .then(result => { 187 console.log('Report generated:', result); 188 process.exit(0); 189 }) 190 .catch(error => { 191 logger.error('Report generation failed:', error); 192 process.exit(1); 193 }); 194 }