← back to Lawyer Directory Builder
src/enrich/render_mockups.ts
518 lines
/**
* Render mockup screenshots for law firms.
*
* For each firm scope (default: Beverly Hills), generate 4 variants and
* persist into site_mockups:
* a — Editorial Noir (deterministic, mockup_templates.ts)
* b — Clean Modern Light (deterministic, mockup_templates.ts)
* c — Bold Confident (deterministic, mockup_templates.ts)
* x — LLM Bespoke (gemma3:12b custom HTML per firm)
*
* All HTML is saved to public/mockups/firm-{id}-{variant}.html and rendered
* to a PNG at public/mockups/firm-{id}-{variant}.png. Both files are served
* statically by the existing express.static('/').
*
* CLI:
* npx tsx src/enrich/render_mockups.ts --city "Beverly Hills"
* npx tsx src/enrich/render_mockups.ts --city "Beverly Hills" --skip-llm
* npx tsx src/enrich/render_mockups.ts --zips 90210,90211,90212,90213
* npx tsx src/enrich/render_mockups.ts --limit 10 --re-render
* npx tsx src/enrich/render_mockups.ts --la-county
*/
import 'dotenv/config';
import { chromium, Browser } from 'playwright';
import { load } from 'cheerio';
import fs from 'node:fs';
import path from 'node:path';
import { pool, query } from '../db/pool.ts';
import { fetchCompliant } from '../lib/compliance.ts';
const MOCKUP_DIR = path.resolve(process.cwd(), 'public/mockups');
const TEMPLATES_DIR = path.resolve(process.cwd(), 'public/mockup-templates');
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const LLM_MODEL = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 120000);
// ─── load top-N rotation pool of leading-firm templates ─────────────────────
type Template = { id: number; name: string; website: string; score: number; primary_color: string; body_file: string };
let TEMPLATES: Template[] = [];
try {
const tpath = path.join(TEMPLATES_DIR, 'top12.json');
if (fs.existsSync(tpath)) {
TEMPLATES = JSON.parse(fs.readFileSync(tpath, 'utf8'));
}
} catch { /* empty pool — fall back to old prompt */ }
function pickTemplate(firmId: number): Template | null {
if (TEMPLATES.length === 0) return null;
return TEMPLATES[firmId % TEMPLATES.length];
}
function loadTemplateBody(t: Template): string {
try {
// Cap at 2.5KB so the prompt stays under model context budget
return fs.readFileSync(path.join(TEMPLATES_DIR, t.body_file), 'utf8').slice(0, 2500);
} catch { return ''; }
}
const argv = process.argv.slice(2);
const has = (f: string) => argv.includes(f);
const arg = (f: string): string | undefined => {
const i = argv.indexOf(f);
return i >= 0 ? argv[i + 1] : undefined;
};
const CITY = arg('--city') || null;
const ZIPS = (arg('--zips') || '').split(',').map(s => s.trim()).filter(Boolean);
const LIMIT = Number(arg('--limit') || 0);
const ID_MOD = Number(arg('--id-mod') || 0);
const ID_REM = Number(arg('--id-rem') || 0);
const RE_RENDER = has('--re-render');
const SKIP_LLM = has('--skip-llm');
const LA_COUNTY = has('--la-county');
// LA County cities (a workable subset — Beverly Hills, surrounding, downtown LA)
const LA_COUNTY_CITIES = [
'Los Angeles','Beverly Hills','Santa Monica','Pasadena','Long Beach','Glendale',
'Burbank','Culver City','West Hollywood','El Segundo','Torrance','Hermosa Beach',
'Manhattan Beach','Sherman Oaks','Encino','Studio City','Van Nuys','Marina Del Rey',
'Hollywood','North Hollywood','Inglewood','Redondo Beach','Westwood','Brentwood',
'Century City','Pacific Palisades','Malibu','Calabasas','Woodland Hills','Tarzana',
];
// ─── firm loader ────────────────────────────────────────────────────────────
type Firm = {
id: number; name: string;
city: string | null; address: string | null;
zip: string | null; phone: string | null; website: string | null;
practice_areas: string[] | null;
site_intel_title: string | null;
site_intel_h1: string | null;
site_intel_meta_desc: string | null;
site_intel_body: string | null;
site_intel_practice: string[] | null;
site_intel_attorneys: string[] | null;
site_intel_at: Date | null;
audit_primary_color: string | null;
audit_palette: { hex: string; fraction: number }[] | null;
};
type SiteIntel = {
title: string | null;
h1: string | null;
metaDesc: string | null;
body: string | null; // first ~2KB of cleaned text
practice: string[];
attorneys: string[];
};
const SITE_INTEL_TIMEOUT_MS = Number(process.env.SITE_INTEL_TIMEOUT_MS || 12000);
const SITE_INTEL_MAX_AGE_DAYS = Number(process.env.SITE_INTEL_MAX_AGE_DAYS || 30);
// HSL hue from firm.id via golden-ratio scrambling — guarantees ~5000+ distinct hues
// across the queue, with adjacent ids landing on visually distinct colors.
function uniqueAccent(firmId: number): { hex: string; hsl: string } {
const phi = 0.6180339887;
const hue = Math.floor(((firmId * phi) % 1) * 360);
const sat = 65 + ((firmId * 13) % 20); // 65-84
const lit = 52 + ((firmId * 7) % 12); // 52-63
return { hex: hslToHex(hue, sat, lit), hsl: `hsl(${hue} ${sat}% ${lit}%)` };
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100; l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0');
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
const PRACTICE_AREA_KEYWORDS = [
'Personal Injury','Family Law','Divorce','Criminal Defense','Estate Planning',
'Wills','Trusts','Probate','Real Estate','Business Law','Corporate','Employment',
'Immigration','Bankruptcy','Tax','Intellectual Property','Patent','Trademark',
'Civil Litigation','Workers Compensation','Medical Malpractice','Wrongful Death',
'Class Action','Securities','Mergers','Entertainment','Insurance Defense',
'Construction','Environmental','Elder Law','DUI','Traffic',
];
function extractPracticeAreas(body: string): string[] {
const found = new Set<string>();
const lower = body.toLowerCase();
for (const kw of PRACTICE_AREA_KEYWORDS) {
if (lower.includes(kw.toLowerCase())) found.add(kw);
if (found.size >= 6) break;
}
return [...found];
}
function extractAttorneys($: ReturnType<typeof load>): string[] {
const cands = new Set<string>();
// Look for elements that suggest "Attorney/Partner/Counsel" near a name
$('h2, h3, h4, .attorney, .lawyer, .partner, .name, .team-member').each((_, el) => {
const t = $(el).text().trim();
if (!t) return;
// 2-4 capitalized words, optional middle initial, optional comma+suffix
const m = t.match(/^([A-Z][a-z]+(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-z]+){1,3})(?:,?\s*(?:Esq\.?|JD|J\.D\.|Attorney|Partner))?\b/);
if (m && m[1].length < 60) cands.add(m[1].trim());
});
return [...cands].slice(0, 8);
}
async function fetchSiteIntel(firm: Firm): Promise<SiteIntel | null> {
if (!firm.website) return null;
let url: URL;
try { url = new URL(firm.website); } catch { return null; }
try {
const res = await fetchCompliant(url.href, { timeoutMs: SITE_INTEL_TIMEOUT_MS });
if (!res || res.status >= 400) return null;
const html = await res.text();
const $ = load(html);
const title = ($('title').first().text() || '').trim().slice(0, 200) || null;
const h1 = ($('h1').first().text() || '').trim().slice(0, 200) || null;
const metaDesc = ($('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '').trim().slice(0, 300) || null;
$('script, style, noscript, header nav, footer').remove();
const body = ($('main').text() || $('body').text() || '')
.replace(/\s+/g, ' ').trim().slice(0, 2000) || null;
const practice = body ? extractPracticeAreas(body) : [];
const attorneys = extractAttorneys($);
return { title, h1, metaDesc, body, practice, attorneys };
} catch { return null; }
}
async function ensureSiteIntel(firm: Firm): Promise<Firm> {
// Skip if cached and fresh
if (firm.site_intel_at) {
const ageDays = (Date.now() - firm.site_intel_at.getTime()) / 86400000;
if (ageDays < SITE_INTEL_MAX_AGE_DAYS) return firm;
}
const intel = await fetchSiteIntel(firm);
if (!intel) {
// Mark attempted so we don't retry on every variant
await query(`UPDATE organizations SET site_intel_at = NOW() WHERE id = $1`, [firm.id]);
return firm;
}
await query(`
UPDATE organizations
SET site_intel_title=$2, site_intel_h1=$3, site_intel_meta_desc=$4,
site_intel_body=$5, site_intel_practice=$6, site_intel_attorneys=$7,
site_intel_at=NOW()
WHERE id=$1
`, [firm.id, intel.title, intel.h1, intel.metaDesc, intel.body, intel.practice, intel.attorneys]);
return {
...firm,
site_intel_title: intel.title,
site_intel_h1: intel.h1,
site_intel_meta_desc: intel.metaDesc,
site_intel_body: intel.body,
site_intel_practice: intel.practice,
site_intel_attorneys: intel.attorneys,
site_intel_at: new Date(),
};
}
async function loadFirms(): Promise<Firm[]> {
const where: string[] = [`o.type='law_firm'`, `o.website IS NOT NULL`];
const params: any[] = [];
if (LA_COUNTY) {
params.push(LA_COUNTY_CITIES.map(s => s.toLowerCase()));
where.push(`LOWER(o.city) = ANY($${params.length}::text[])`);
} else if (CITY) {
params.push(CITY.toLowerCase());
where.push(`(LOWER(o.city) = $${params.length} OR LOWER(o.neighborhood) = $${params.length})`);
}
if (ZIPS.length) {
params.push(ZIPS);
where.push(`o.zip = ANY($${params.length}::text[])`);
}
if (ID_MOD > 0) {
params.push(ID_MOD); params.push(ID_REM);
where.push(`(o.id % $${params.length - 1}) = $${params.length}`);
}
const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
const r = await query<Firm>(`
SELECT o.id, o.name, o.city, o.address, o.zip, o.phone, o.website,
o.practice_areas,
o.site_intel_title, o.site_intel_h1, o.site_intel_meta_desc,
o.site_intel_body, o.site_intel_practice, o.site_intel_attorneys,
o.site_intel_at,
latest_audit.primary_color AS audit_primary_color,
latest_audit.palette AS audit_palette
FROM organizations o
LEFT JOIN LATERAL (
SELECT primary_color, palette
FROM site_audits sa
WHERE sa.organization_id = o.id
ORDER BY sa.audited_at DESC
LIMIT 1
) latest_audit ON TRUE
WHERE ${where.join(' AND ')}
ORDER BY o.id
${lim}
`, params);
return r.rows;
}
async function alreadyRendered(firmId: number, variant: string): Promise<boolean> {
if (RE_RENDER) return false;
const r = await query<{ id: number }>(
`SELECT id FROM site_mockups WHERE organization_id = $1 AND variant = $2 LIMIT 1`,
[firmId, variant]);
return (r.rowCount || 0) > 0;
}
// ─── LLM bespoke generation ─────────────────────────────────────────────────
const LLM_SYSTEM = `You are a senior web designer. Output ONLY a complete, valid <!doctype html> page — no preamble, no markdown fences, no commentary, just the HTML. The page must be a single self-contained file with all CSS in one <style> tag in the head. No external assets except Google Fonts via <link>.`;
// Each firm gets 3 LLM-generated variants, each with a distinct aesthetic + palette + template.
const AESTHETICS = [
{
key: 'x1', label: 'Editorial Modern',
surface: 'light cream (#faf8f3) with charcoal (#1a1814) text',
typo: 'serif display (Cormorant Garamond, Playfair, EB Garamond) for headlines, Inter sans for body',
flavor: 'magazine/editorial — long-form intro paragraphs, generous tracking, all-caps eyebrows, pull quotes, hairline rules between sections',
},
{
key: 'x2', label: 'Bold Confident',
surface: 'high-contrast white (#ffffff) base with one bold black/accent statement section in middle',
typo: 'extra-bold geometric sans (Inter 700-900, or Geist Bold) for headlines, Inter 400 for body',
flavor: 'confident — oversized headline (90-120px), zero ornament, square-bracket tags, button-as-logo, tight grid with thick rule borders',
},
{
key: 'x3', label: 'Web3 Immersive',
surface: 'deep slate (#0b0d12) with subtle radial-gradient hero glow + glassmorphism header (backdrop-filter blur 12px)',
typo: 'Inter or Geist throughout, weight 600 headlines, letter-spacing -0.02em',
flavor: 'modern dark — hover-lift cards, 1px hairline rgba(255,255,255,0.08) borders, gradient-edge CTAs, 8-14px rounded corners, 21st.dev/shadcn-style segmented controls and glass cards',
},
] as const;
type Aesthetic = typeof AESTHETICS[number];
function pickAccent(firm: Firm, variantIndex: number): string {
// Prefer the firm's actual site primary_color (from site_audits) on x3 (web3 vibe matches dark sites);
// otherwise generate a per-firm-per-variant unique HSL from the firm.id.
if (variantIndex === 2 && firm.audit_primary_color && /^#[0-9a-f]{6}$/i.test(firm.audit_primary_color)) {
return firm.audit_primary_color;
}
return uniqueAccent(firm.id * 7 + variantIndex * 113).hex;
}
function pickTemplateForVariant(firmId: number, variantIndex: number): Template | null {
if (TEMPLATES.length === 0) return null;
// Different template for each (firm, variant) so all 3 variants are structurally distinct
return TEMPLATES[(firmId * 3 + variantIndex * 5) % TEMPLATES.length];
}
function llmPrompt(f: Firm, variantIndex: number, aesthetic: Aesthetic, accent: string): string {
const city = f.city || 'California';
const tpl = pickTemplateForVariant(f.id, variantIndex);
const tplBody = tpl ? loadTemplateBody(tpl) : '';
const tplBlock = tpl && tplBody
? `\n\nSTRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (audit ${tpl.score}/100) LA law firm:\nFirm: ${tpl.name} Site: ${tpl.website}\n--- TEMPLATE BODY (HTML excerpt — for structure inspiration ONLY, do NOT copy text or branding) ---\n${tplBody}\n--- END TEMPLATE ---\n\nFollow the same number/order of sections, the same visual hierarchy, and the same component density. IGNORE the template's color palette and typography — apply the aesthetic specified below instead.\n`
: '';
// Build the real-content block from cached site intel
const intelLines: string[] = [];
if (f.site_intel_title) intelLines.push(`- Page title: "${f.site_intel_title}"`);
if (f.site_intel_h1) intelLines.push(`- H1 on their actual site: "${f.site_intel_h1}"`);
if (f.site_intel_meta_desc) intelLines.push(`- Their meta description: "${f.site_intel_meta_desc}"`);
if (f.site_intel_practice && f.site_intel_practice.length) intelLines.push(`- Practice areas mentioned on their site: ${f.site_intel_practice.join(', ')}`);
if (f.site_intel_attorneys && f.site_intel_attorneys.length) intelLines.push(`- Attorneys named on their site: ${f.site_intel_attorneys.slice(0, 6).join(', ')}`);
if (f.site_intel_body) intelLines.push(`- About-text excerpt from their site (use this to write copy that sounds like THEM): "${f.site_intel_body.slice(0, 800)}"`);
const intelBlock = intelLines.length
? `\nREAL CONTENT FROM THEIR ACTUAL WEBSITE (use this — do not invent generic copy):\n${intelLines.join('\n')}\n`
: `\nNo cached site content available — write reasonable, firm-specific copy based on the name "${f.name}" and city "${city}". Do NOT use placeholder text like "Our team of attorneys".\n`;
const practiceForSeo = (f.site_intel_practice && f.site_intel_practice.length ? f.site_intel_practice : (f.practice_areas || [])).slice(0, 4).join(', ') || 'legal services';
return `Design a UNIQUE law-firm landing page for: ${f.name}
Variant: ${aesthetic.key} — ${aesthetic.label}
Firm city: ${city}
Phone: ${f.phone || '(310) 555-0100'}
Address: ${f.address || `${city}, CA`}
Accent color (use this EXACT hex everywhere accent is needed): ${accent}
${intelBlock}
AESTHETIC for this variant — match strictly:
- Surface: ${aesthetic.surface}
- Typography: ${aesthetic.typo}
- Visual flavor: ${aesthetic.flavor}
- Use ONLY the accent color ${accent} for highlights — no other vibrant colors. Neutrals/grays/black/white are unlimited.
- 21st.dev / shadcn-influenced component patterns where they fit the aesthetic: pill badges with subtle border, segmented filter controls, ghost-bordered buttons, kbd-style chips for stats, divider lines between rows.
- Hero headline 60-110px (depending on aesthetic), tight tracking
- Generous section padding (80-140px), single-column 768px breakpoint
REQUIRED — these MUST appear in the <head> (proper SEO):
- <title>${f.name} — ${practiceForSeo} | ${city}</title>
- <meta name="description" content="..."> — ONE sentence, 140-160 chars, mentions firm name + city + 1-2 practice areas, written in firm's voice
- <meta property="og:title">, <meta property="og:description">, <meta name="robots" content="index,follow">
Required sections, in this order:
1. Sticky banner (1px accent border-bottom): "MOCKUP — DETAILS PENDING VERIFICATION" — uppercase, 10px, opacity 0.6
2. Header: firm name as text logo, 4 nav links (Practice, Attorneys, Results, Contact), 1 CTA button
3. Hero: headline that uses real site H1 or echoes the firm's actual messaging — NOT generic "Trusted Legal Counsel". Sub-headline 1 sentence. Two CTAs.
4. Trust band: 4 distinctive metrics relevant to THIS firm (years, cases, recovery, reviews) — vary numbers per firm so they're not all "30+ Years"
5. Practice areas: render the practice areas FROM THE REAL SITE (or firm DB) as 4-6 cards with firm-specific descriptions. If only 3 known, render 3 — don't fabricate.
6. Attorneys / People section: if site_intel attorneys are present, list them with placeholder bio lines; else a single bio block for the firm
7. Process / approach: 3 steps written in firm-specific language
8. Contact: phone (large), address, form (name/email/matter/message + accent CTA)
9. Footer: hairline border, copyright + state-bar advertising notice, address
CRITICAL — DO NOT:
- Use placeholder/lorem text or generic "Our team of attorneys is dedicated to..."
- Use a different accent color than ${accent}
- Add inline images, pattern backgrounds, or external assets other than Google Fonts <link>
- Output anything before <!doctype html> or after </html>
Self-contained: ALL CSS in one <style> tag. Mobile-responsive. Validates as HTML5.${tplBlock}
Output the full HTML now, starting with <!doctype html>:`;
}
function stripCodeFences(s: string): string {
// Some models wrap in ```html ... ```, strip if present
const fenced = s.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
if (fenced) return fenced[1].trim();
return s.trim();
}
async function generateLLMHtml(f: Firm, variantIndex: number, aesthetic: Aesthetic, accent: string, signal: AbortSignal): Promise<{ html: string; ms: number } | null> {
const t0 = Date.now();
try {
const r = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal,
body: JSON.stringify({
model: LLM_MODEL,
system: LLM_SYSTEM,
prompt: llmPrompt(f, variantIndex, aesthetic, accent),
stream: false,
// higher temp + per-firm seed → variation across firms.
// num_ctx=8192 keeps RAM bounded; default would be 128K for gemma3 → OOM on 24GB Macs.
options: { temperature: 0.7, top_p: 0.92, num_predict: 3500, num_ctx: 8192, seed: f.id * 100 + variantIndex },
}),
});
if (!r.ok) { console.warn(`[llm] http ${r.status} for firm ${f.id}`); return null; }
const j = await r.json() as { response?: string };
const raw = (j.response || '').trim();
if (!raw) return null;
const html = stripCodeFences(raw);
if (!html.toLowerCase().includes('<!doctype') && !html.toLowerCase().includes('<html')) {
console.warn(`[llm] firm ${f.id} no <html> in output (${html.length} chars)`);
return null;
}
return { html, ms: Date.now() - t0 };
} catch (e) {
if ((e as any).name === 'AbortError') return null;
console.warn(`[llm] err firm ${f.id}: ${(e as Error).message}`);
return null;
}
}
// ─── render an HTML string to a PNG via Playwright ──────────────────────────
async function snapshotHtml(browser: Browser, html: string, outPng: string, opts: { fullPage?: boolean } = {}): Promise<void> {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1700 } });
const page = await ctx.newPage();
try {
await page.setContent(html, { waitUntil: 'load', timeout: 20000 });
await page.waitForTimeout(800); // settle fonts
await page.screenshot({ path: outPng, type: 'png', fullPage: opts.fullPage ?? false });
} finally {
await ctx.close().catch(() => {});
}
}
// ─── persist a single mockup ────────────────────────────────────────────────
async function persistMockup(firmId: number, variant: string, label: string, htmlPath: string, pngPath: string, model: string | null, ms: number | null): Promise<void> {
await query(`
INSERT INTO site_mockups (organization_id, variant, template_label, screenshot_path, html_path, llm_model, gen_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (organization_id, variant) DO UPDATE SET
template_label = EXCLUDED.template_label,
screenshot_path = EXCLUDED.screenshot_path,
html_path = EXCLUDED.html_path,
llm_model = EXCLUDED.llm_model,
gen_ms = EXCLUDED.gen_ms,
generated_at = NOW()
`, [firmId, variant, label, pngPath, htmlPath, model, ms]);
}
// ─── render one firm — all 4 variants ───────────────────────────────────────
async function renderFirm(browser: Browser, firmIn: Firm): Promise<{ done: number; skipped: number; errs: number }> {
const out = { done: 0, skipped: 0, errs: 0 };
// Fetch + cache real-site intel ONCE per firm (used by all 3 variants)
const firm = await ensureSiteIntel(firmIn);
for (let i = 0; i < AESTHETICS.length; i++) {
const aesthetic = AESTHETICS[i];
const variantKey = aesthetic.key;
if (await alreadyRendered(firm.id, variantKey)) { out.skipped++; continue; }
if (SKIP_LLM) { out.skipped++; continue; }
const accent = pickAccent(firm, i);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
try {
const r = await generateLLMHtml(firm, i, aesthetic, accent, ac.signal);
if (r) {
const htmlPath = path.join(MOCKUP_DIR, `firm-${firm.id}-${variantKey}.html`);
const pngPath = path.join(MOCKUP_DIR, `firm-${firm.id}-${variantKey}.png`);
fs.writeFileSync(htmlPath, r.html);
await snapshotHtml(browser, r.html, pngPath);
await persistMockup(
firm.id, variantKey,
`${aesthetic.label} (${LLM_MODEL}, ${accent})`,
`/mockups/firm-${firm.id}-${variantKey}.html`,
`/mockups/firm-${firm.id}-${variantKey}.png`,
LLM_MODEL, r.ms,
);
out.done++;
} else {
out.errs++;
}
} catch (e) {
out.errs++;
console.error(`[mockup] firm ${firm.id} variant ${variantKey} err: ${(e as Error).message}`);
} finally {
clearTimeout(timer);
}
}
return out;
}
// ─── main ──────────────────────────────────────────────────────────────────
async function main() {
fs.mkdirSync(MOCKUP_DIR, { recursive: true });
const firms = await loadFirms();
if (firms.length === 0) { console.log('[mockup] no firms match'); await pool.end(); return; }
const scope = LA_COUNTY ? 'LA County' : (CITY ? `city=${CITY}` : (ZIPS.length ? `zips=${ZIPS.join(',')}` : 'all'));
console.log(`[mockup] rendering ${firms.length} firms (scope: ${scope}) llm=${SKIP_LLM ? 'OFF' : LLM_MODEL} re-render=${RE_RENDER} → ${MOCKUP_DIR}`);
const browser = await chromium.launch({ headless: true });
let done = 0, skipped = 0, errs = 0;
try {
for (let i = 0; i < firms.length; i++) {
const firm = firms[i];
const t0 = Date.now();
const r = await renderFirm(browser, firm);
done += r.done; skipped += r.skipped; errs += r.errs;
const dt = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`[mockup] ${i + 1}/${firms.length} #${firm.id} ${firm.name.slice(0, 38).padEnd(38)} done=${r.done} skip=${r.skipped} err=${r.errs} ${dt}s`);
}
} finally {
await browser.close();
}
console.log(`[mockup] complete. done=${done} skipped=${skipped} errs=${errs}`);
await pool.end();
}
main().catch(async err => {
console.error('[mockup] fatal:', err);
try { await pool.end(); } catch {}
process.exit(1);
});