generate-sample-report.js
1 #!/usr/bin/env node 2 3 /** 4 * Generate Sample Report 5 * 6 * Creates a full HTML-based sample audit report for auditandfix.com. 7 * Uses a real scored site from the DB, replaces PII with fakes, 8 * generates a full PDF with "SAMPLE REPORT" watermark, 9 * and captures 4 montage PNGs + hero preview for the landing page. 10 * 11 * Usage: npm run report:sample 12 * or: node scripts/generate-sample-report.js [--site-id <id>] 13 */ 14 15 import Database from 'better-sqlite3'; 16 import { join, dirname } from 'path'; 17 import { fileURLToPath } from 'url'; 18 import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs'; 19 import sharp from 'sharp'; 20 import Logger from '../src/utils/logger.js'; 21 import { generateReportHTML } from '../src/reports/html-report-template.js'; 22 import { renderReportPDF, capturePageScreenshots } from '../src/reports/html-to-pdf.js'; 23 import { cropProblemAreas } from '../src/reports/problem-area-cropper.js'; 24 import { getScoreDataWithFallback } from '../src/utils/score-storage.js'; 25 import dotenv from 'dotenv'; 26 27 dotenv.config(); 28 29 const __filename = fileURLToPath(import.meta.url); 30 const __dirname = dirname(__filename); 31 const projectRoot = join(__dirname, '..'); 32 33 const logger = new Logger('SampleReport'); 34 const dbPath = process.env.DATABASE_PATH || join(projectRoot, 'db/sites.db'); 35 36 // Parse CLI args 37 const args = process.argv.slice(2); 38 const siteIdArg = args.includes('--site-id') ? parseInt(args[args.indexOf('--site-id') + 1]) : null; 39 const fullMode = args.includes('--full'); 40 const screenshotsOnly = args.includes('--screenshots-only'); 41 42 /** 43 * Replace PII in score_json with fake data 44 */ 45 function fuzzScoreJson(scoreJson, fakeDomain) { 46 const fakeUrl = `https://${fakeDomain}`; 47 const json = JSON.parse(JSON.stringify(scoreJson)); // deep clone 48 49 // Replace URLs 50 if (json.website_url) json.website_url = fakeUrl; 51 52 // Anonymise location to city level only 53 if (json.overall_calculation) { 54 json.overall_calculation.country_detection_evidence = ['Domain and content analysis']; 55 } 56 57 // Remove any contact details that might have leaked into score_json 58 delete json.contact_details; 59 60 return json; 61 } 62 63 /** 64 * Blur the top bar region of a screenshot to hide URL/domain 65 */ 66 async function blurUrlBar(buffer) { 67 if (!buffer) return buffer; 68 69 const metadata = await sharp(buffer).metadata(); 70 if (!metadata.width || !metadata.height) return buffer; 71 72 // URL bar is typically in the top ~40px of a 900px viewport capture 73 // For full-page captures starting from content, there's no URL bar 74 // But above-fold captures may include browser chrome 75 // We'll blur the top 5% just in case 76 const blurHeight = Math.max(30, Math.round(metadata.height * 0.05)); 77 78 // Extract, blur, and composite back 79 const blurred = await sharp(buffer) 80 .extract({ left: 0, top: 0, width: metadata.width, height: blurHeight }) 81 .blur(15) 82 .toBuffer(); 83 84 return sharp(buffer) 85 .composite([{ input: blurred, top: 0, left: 0 }]) 86 .toBuffer(); 87 } 88 89 async function main() { 90 const sampleReportsDir = join(projectRoot, 'auditandfix.com', 'assets', 'sample-reports'); 91 const imgDir = join(projectRoot, 'auditandfix.com', 'assets', 'img'); 92 const savedHtmlPath = join(sampleReportsDir, 'sample-cro-audit.html'); 93 94 // --screenshots-only: re-capture montage images from the last saved HTML 95 if (screenshotsOnly) { 96 if (!existsSync(savedHtmlPath)) { 97 logger.error('No saved HTML found — run without --screenshots-only first'); 98 process.exit(1); 99 } 100 logger.info('Screenshots-only mode: re-capturing from saved HTML...'); 101 const html = readFileSync(savedHtmlPath, 'utf8'); 102 mkdirSync(imgDir, { recursive: true }); 103 const screenshots = await capturePageScreenshots({ html, outputDir: imgDir }); 104 for (const s of screenshots) { 105 logger.success(`Montage image: ${s.path}`); 106 } 107 logger.success(`Done — ${screenshots.length} images re-captured`); 108 return; 109 } 110 111 logger.info('Generating sample report...'); 112 113 const db = new Database(dbPath, { readonly: true }); 114 115 // Find a good candidate site 116 let site; 117 if (siteIdArg) { 118 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteIdArg); 119 if (!site) { 120 logger.error(`Site ${siteIdArg} not found`); 121 process.exit(1); 122 } 123 } else { 124 // Pick a site with score 40-70, has score_json, ideally with varied factor scores 125 site = db 126 .prepare( 127 `SELECT * FROM sites 128 WHERE score IS NOT NULL 129 AND score_json IS NOT NULL 130 AND score BETWEEN 40 AND 70 131 AND screenshot_path IS NOT NULL 132 AND status IN ('prog_scored', 'semantic_scored', 'vision_scored', 'enriched', 'proposals_drafted', 'outreach_sent') 133 ORDER BY RANDOM() 134 LIMIT 1` 135 ) 136 .get(); 137 } 138 139 db.close(); 140 141 if (!site) { 142 logger.error( 143 'No suitable site found in DB for sample report (need score 40-70 with score_json)' 144 ); 145 process.exit(1); 146 } 147 148 logger.info( 149 `Using site #${site.id}: ${site.domain} (score: ${site.score}, grade: ${site.grade})` 150 ); 151 152 let scoreJson; 153 let aboveFoldBuffer = null; 154 let fullPageBuffer = null; 155 let problemCrops = []; 156 // Derive fake domain from industry_classification or keyword 157 const industryRaw = 158 site.keyword 159 ?.split(' ') 160 .find(w => w.length > 3 && !['north', 'south', 'east', 'west', 'the', 'and'].includes(w)) || 161 'trades'; 162 const industrySlug = industryRaw.toLowerCase().replace(/[^a-z0-9]/g, ''); 163 const fakeDomain = `acme-${industrySlug}.com`; 164 165 if (fullMode) { 166 // ──── FULL MODE: Live Opus scoring with fresh capture ──── 167 logger.info('Full mode: capturing fresh screenshots and scoring with Opus...'); 168 const { captureFullPage } = await import('../src/reports/full-page-capture.js'); 169 const { refreshScore } = await import('../src/reports/score-refresh.js'); 170 171 logger.info(`Capturing full-page screenshot of ${site.landing_page_url}...`); 172 const captureResult = await captureFullPage(site.landing_page_url); 173 fullPageBuffer = captureResult.fullPageBuffer; 174 aboveFoldBuffer = await blurUrlBar(captureResult.aboveFoldBuffer); 175 176 logger.info('Scoring with Opus extended thinking (this may take 1-2 minutes)...'); 177 scoreJson = await refreshScore({ 178 url: site.landing_page_url, 179 fullPageBuffer: captureResult.fullPageBuffer, 180 aboveFoldBuffer: captureResult.aboveFoldBuffer, 181 htmlContent: captureResult.htmlContent, 182 httpHeaders: captureResult.httpHeaders, 183 siteId: site.id, 184 countryCode: site.country_code, 185 }); 186 187 logger.info( 188 `Opus score: ${scoreJson.overall_calculation?.conversion_score} ` + 189 `(${scoreJson.overall_calculation?.letter_grade}) — ` + 190 `${scoreJson.problem_areas?.length || 0} problem areas, ` + 191 `${scoreJson.strategic_recommendations?.length || 0} recommendations` 192 ); 193 194 // Generate before/after mockups for problem areas 195 if (scoreJson.problem_areas?.length > 0) { 196 const hasMockups = scoreJson.problem_areas.some(a => a.mockup_changes?.length > 0); 197 if (hasMockups) { 198 logger.info('Generating before/after mockups...'); 199 try { 200 const { generateMockups } = await import('../src/reports/mockup-generator.js'); 201 problemCrops = await generateMockups( 202 site.landing_page_url, 203 scoreJson.problem_areas, 204 fullPageBuffer 205 ); 206 logger.info( 207 `Generated ${problemCrops.length} mockups (${problemCrops.filter(m => m.afterBuffer).length} with before/after)` 208 ); 209 } catch (err) { 210 logger.warn(`Mockup generation failed — falling back to static crops: ${err.message}`); 211 if (fullPageBuffer) { 212 problemCrops = await cropProblemAreas(fullPageBuffer, scoreJson.problem_areas); 213 } 214 } 215 } else if (fullPageBuffer) { 216 logger.info('No mockup_changes — using static crops...'); 217 problemCrops = await cropProblemAreas(fullPageBuffer, scoreJson.problem_areas); 218 logger.info(`Cropped ${problemCrops.length} problem area screenshots`); 219 } 220 } 221 222 // Blur the full-page buffer too (for any embedded screenshots) 223 fullPageBuffer = await blurUrlBar(fullPageBuffer); 224 } else { 225 // ──── DB MODE: Use existing score_json from database ──── 226 scoreJson = getScoreDataWithFallback(site.id, site); 227 if (!scoreJson) { 228 logger.error('Failed to load score_json — try --full to generate with Opus'); 229 process.exit(1); 230 } 231 232 // Load screenshots from disk 233 if (site.screenshot_path) { 234 const screenshotDir = site.screenshot_path; 235 236 const aboveFoldPaths = [ 237 join(screenshotDir, 'desktop-above-fold.jpg'), 238 join(screenshotDir, 'desktop-above-fold.png'), 239 join(screenshotDir, 'desktop.jpg'), 240 join(screenshotDir, 'desktop.png'), 241 ]; 242 for (const p of aboveFoldPaths) { 243 if (existsSync(p)) { 244 aboveFoldBuffer = await blurUrlBar(readFileSync(p)); 245 break; 246 } 247 } 248 249 const fullPagePaths = [ 250 join(screenshotDir, 'desktop-full.jpg'), 251 join(screenshotDir, 'desktop-full.png'), 252 join(screenshotDir, 'desktop-below-fold.jpg'), 253 join(screenshotDir, 'desktop-below-fold.png'), 254 ]; 255 for (const p of fullPagePaths) { 256 if (existsSync(p)) { 257 fullPageBuffer = readFileSync(p); 258 break; 259 } 260 } 261 } 262 263 // Crop problem areas from existing screenshots 264 if (fullPageBuffer && scoreJson.problem_areas?.length > 0) { 265 logger.info('Cropping problem areas...'); 266 try { 267 problemCrops = await cropProblemAreas(fullPageBuffer, scoreJson.problem_areas); 268 } catch (err) { 269 logger.warn(`Problem area cropping failed: ${err.message}`); 270 } 271 } 272 } 273 274 // Fuzz PII 275 scoreJson = fuzzScoreJson(scoreJson, fakeDomain); 276 277 // Use Opus narratives if present, fall back to mock 278 const narratives = scoreJson.report_narratives || { 279 executive_summary: 280 `This site makes a reasonable first impression with professional imagery, but several key conversion elements are missing or buried. ` + 281 `The biggest issues: there's no clear call to action visible when you first land on the page, the headline is generic, and trust signals like reviews or certifications aren't visible until you scroll well past the fold.\n\n` + 282 `The good news is that most of these fixes are straightforward — a developer could implement the top three recommendations in an afternoon. Based on similar sites we've audited, addressing the call-to-action and headline issues alone could meaningfully improve your enquiry rate.`, 283 action_plan_week: 284 `1. Move your main call-to-action button above the fold so visitors see it without scrolling\n` + 285 `2. Rewrite your headline to mention your city and primary service\n` + 286 `3. Add your Google Reviews rating next to the headline`, 287 action_plan_month: 288 `1. Add a secondary CTA in the navigation bar that's always visible\n` + 289 `2. Replace stock imagery with real photos of your team and work\n` + 290 `3. Create a simple urgency message (e.g., "Book this week for 10% off")`, 291 action_plan_quarter: 292 `1. Add a testimonials section with real customer photos and names\n` + 293 `2. Build dedicated landing pages for each core service\n` + 294 `3. Implement A/B testing on your hero section`, 295 }; 296 297 // Generate HTML report 298 logger.info('Generating HTML report...'); 299 const html = generateReportHTML({ 300 domain: fakeDomain, 301 url: `https://${fakeDomain}`, 302 scoreJson, 303 aboveFoldBuffer, 304 problemCrops, 305 narrativeSections: narratives, 306 reportDate: new Date(), 307 isSample: true, 308 }); 309 310 // Output paths 311 mkdirSync(sampleReportsDir, { recursive: true }); 312 mkdirSync(imgDir, { recursive: true }); 313 314 const pdfPath = join(sampleReportsDir, 'sample-cro-audit.pdf'); 315 316 // Save HTML for screenshot re-takes without re-running Opus 317 writeFileSync(savedHtmlPath, html, 'utf8'); 318 319 // Render PDF 320 logger.info('Rendering PDF...'); 321 await renderReportPDF({ html, outputPath: pdfPath }); 322 logger.success(`Sample PDF saved: ${pdfPath}`); 323 324 // Capture montage screenshots for the landing page 325 logger.info('Capturing montage screenshots...'); 326 const screenshots = await capturePageScreenshots({ html, outputDir: imgDir }); 327 for (const s of screenshots) { 328 logger.success(`Montage image: ${s.path}`); 329 } 330 331 logger.success(`Sample report generation complete — ${screenshots.length + 1} files created`); 332 } 333 334 main().catch(error => { 335 logger.error('Failed to generate sample report:', error); 336 process.exit(1); 337 });