/ scripts / generate-sample-report.js
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  });