← back to Professional Directory
scripts/generate-mockups.js
283 lines
#!/usr/bin/env node
/**
* Generate per-org bespoke website mockups using template-rotation + local LLM.
*
* For each org in the input set:
* 1. Pick template via org.id % N from data/site-templates/top10.json
* 2. Inject template body excerpt as STRUCTURAL inspiration (LLM emulates
* section ordering + density, ignores the template's colors/fonts)
* 3. Apply our healthcare-modern aesthetic (clean cream-on-moss editorial,
* OR dark-mode glass, OR warm-rose photo-led — picked by org type)
* 4. Inject AI-chat widget (right-bottom floating; posts to /api/chat)
* 5. Save to data/mockups/<orgId>.html + record metadata
*
* Adapted from lawyer-directory-builder/src/enrich/render_mockups.ts.
*
* Run:
* node scripts/generate-mockups.js # default: top 20 by lead_score
* LIMIT=50 node scripts/generate-mockups.js
* ONLY_DOCTORS=1 node scripts/generate-mockups.js # restrict to medical types
* FORCE=1 node scripts/generate-mockups.js # regenerate even if .html exists
*/
const fs = require('fs');
const path = require('path');
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 20;
const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
const FORCE = process.env.FORCE === '1';
const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];
const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
const MODEL = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
// gemma3:12b on MS2 takes 90-300s to write 4-6KB of HTML. Budget 6 min/org.
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 360_000);
const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
const OUT_DIR = path.resolve(__dirname, '../data/mockups');
fs.mkdirSync(OUT_DIR, { recursive: true });
// ─── load template manifest ────────────────────────────────────────────────
let TEMPLATES = [];
try { TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8')); }
catch (_) { console.error('[gen] no template manifest — run scripts/cache-healthcare-templates.js first'); process.exit(2); }
function pickTemplate(id) {
if (!TEMPLATES.length) return null;
return TEMPLATES[id % TEMPLATES.length];
}
function loadTemplateBody(t) {
try { return fs.readFileSync(path.join(TPL_DIR, t.body_file), 'utf8'); }
catch { return ''; }
}
// ─── chat widget HTML — appended to every generated mockup ────────────────
function chatWidget(orgId) {
return `
<!-- drvouch ai-chat widget -->
<style>
#vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:#28412f;color:#fbf7f0;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
#vw-chat-fab:hover{background:#3f5f4a}
#vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
#vw-chat-panel.open{display:flex}
#vw-chat-head{padding:14px 18px;background:#28412f;color:#fbf7f0;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
#vw-chat-close{background:transparent;border:0;color:#fbf7f0;font-size:18px;cursor:pointer}
#vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
#vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
#vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
#vw-chat-log .vw-me{align-self:flex-end;background:#28412f;color:#fbf7f0}
#vw-chat-log .vw-bot.vw-typing{font-style:italic;opacity:.7}
#vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
#vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
#vw-chat-input:focus{border-color:#28412f}
#vw-chat-send{background:#28412f;color:#fbf7f0;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
</style>
<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
<div id="vw-chat-panel" role="dialog" aria-label="Practice chat">
<div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div>
<div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, location, or anything else about this practice.</div></div>
<form id="vw-chat-form" onsubmit="return vwChatSend(event)">
<input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off" />
<button id="vw-chat-send" type="submit">Send</button>
</form>
</div>
<script>
(function(){
const ORG_ID = ${orgId};
const API = (window.PD_API_BASE || '') + '/api/chat';
const log = () => document.getElementById('vw-chat-log');
const history = [];
function add(text, who) {
const d = document.createElement('div');
d.className = 'vw-msg vw-' + who;
d.textContent = text;
log().appendChild(d);
log().scrollTop = log().scrollHeight;
return d;
}
window.vwChatSend = async function(ev) {
ev.preventDefault();
const i = document.getElementById('vw-chat-input');
const msg = i.value.trim();
if (!msg) return false;
add(msg, 'me');
i.value = '';
history.push({ role: 'user', content: msg });
const typing = add('typing…', 'bot vw-typing');
try {
const r = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgId: ORG_ID, message: msg, history: history.slice(-6) }),
});
const j = await r.json();
typing.classList.remove('vw-typing');
typing.textContent = j.reply || ('(error: ' + (j.error || 'no response') + ')');
if (j.reply) history.push({ role: 'assistant', content: j.reply });
} catch (e) {
typing.classList.remove('vw-typing');
typing.textContent = '(network error — chat backend may be offline)';
}
return false;
};
})();
</script>
`;
}
// ─── LLM prompt ────────────────────────────────────────────────────────────
const LLM_SYSTEM = `You are a senior web designer and SEO specialist building a single self-contained landing page for a healthcare practice. Output ONLY a complete, valid <!doctype html> page — no preamble, no markdown fences, no commentary, just the HTML. All CSS in one <style> tag in the head. No external assets except Google Fonts via <link>. Include thoughtful SEO meta + JSON-LD schema (Organization or Dentist or MedicalBusiness as appropriate).`;
function llmPrompt(o, tpl, tplBody) {
const city = o.city || 'Los Angeles';
const phone = o.phone || '';
const addr = o.address || '';
const orgName = o.name;
const kind = (o.type || 'practice').replace(/_/g, ' ');
const tplBlock = tpl && tplBody
? `\n\nSTRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (audit ${tpl.score}/100) healthcare site:\nReference: ${tpl.name} (${tpl.site})\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, the same component density.\nIGNORE the template's color palette, typography, and copy — apply the aesthetic below instead.\n`
: '';
return `Design a clean, SEO-optimized, mobile-friendly landing page for: ${orgName}
Practice type: ${kind}
City: ${city}
Phone: ${phone || '(310) 555-0100 — placeholder'}
Address: ${addr || `${city}, CA`}
Aesthetic — modern healthcare editorial, NEVER flashy:
- Cream + moss-green palette: #fbf7f0 background, #28412f primary, #b48a3a gold accent, #1c1f1c body text
- Serif headlines (Cormorant Garamond or Iowan Old Style), sans-serif body (Inter)
- Hero h1: 56-72px, weight 500, line-height 1.05, -0.015em letter-spacing
- Body: 16px/1.6 Inter weight 400
- Generous whitespace: 80-120px section padding
- Subtle hover-lift on cards (translateY(-2px) + soft box-shadow)
- Rounded corners 8-12px max
- NO gradient text, NO glow effects, NO drop shadows over 0 8px 24px rgba(0,0,0,0.12)
- Calm, trustworthy, premium-feel — think Mayo Clinic + One Medical, not flashy
Required sections, in order:
1. Sticky header — practice name in serif (text logo), 3-4 nav links, phone CTA
2. Hero — tagline, 1-line subhead, 2 CTAs (filled "${kind === 'dental_office' ? 'Book a Cleaning' : kind === 'optometrist_office' ? 'Book Eye Exam' : 'Book an Appointment'}" + ghost "Learn More")
3. Trust band — 4 metrics in horizontal row (years serving city / patients seen / 5-star reviews / same-week appointments)
4. Services grid — 6 cards in 3-column responsive grid, hover-lift, gold-color icon (Unicode: ✦ ✧ ◆ ◇ ★)
5. Meet the team — placeholder cards for providers (3-4 in a row)
6. Visit / location — split: address + phone + hours on left, embedded map placeholder on right
7. FAQ — 4 expand/collapse questions on insurance, scheduling, services, telehealth
8. Footer — minimal, hairline border, copyright + non-medical-advice disclaimer
SEO requirements:
- <title>${orgName} — ${kind === 'dental_office' ? 'Dentist' : kind === 'optometrist_office' ? 'Optometrist' : 'Healthcare'} in ${city}, CA</title>
- <meta name="description"> 145-160 chars, includes practice name + city + primary service
- Open Graph tags (og:title, og:description, og:type=website)
- JSON-LD <script type="application/ld+json"> with @type appropriate to ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital), name, address, telephone, openingHoursSpecification
- semantic HTML (header, nav, main, section, article, footer)
- alt text on all images (use placeholder gradient divs if no real images)
Self-contained: all CSS in one <style> tag in <head>. Mobile breakpoint at 768px. Must validate as HTML5.${tplBlock}
Output the full HTML now, starting with <!doctype html>:`;
}
function stripCodeFences(s) {
// Lenient: handle ```html\n…\n```, ```\n…\n```, AND missing trailing newlines.
const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
return (m ? m[1] : s).trim();
}
async function generateLLMHtml(o, signal) {
const t = pickTemplate(Number(o.id));
const body = t ? loadTemplateBody(t) : '';
const t0 = Date.now();
try {
const r = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal,
body: JSON.stringify({
model: MODEL,
system: LLM_SYSTEM,
prompt: llmPrompt(o, t, body),
stream: false,
options: { temperature: 0.55, top_p: 0.9, num_predict: 6500 },
}),
});
if (!r.ok) { console.warn(`[llm] HTTP ${r.status} for org ${o.id}`); return null; }
const j = await r.json();
const raw = (j.response || '').trim();
if (!raw) return null;
let html = stripCodeFences(raw);
if (!/<!doctype|<html/i.test(html)) {
console.warn(`[llm] org ${o.id} missing <html>`);
return null;
}
// Inject AI chat widget right before </body>.
if (/<\/body>/i.test(html)) {
html = html.replace(/<\/body>/i, chatWidget(o.id) + '</body>');
} else {
html += chatWidget(o.id);
}
return { html, ms: Date.now() - t0, template_id: t?.id || null, template_score: t?.score || null };
} catch (e) {
if (e.name === 'AbortError') { console.warn(`[llm] org ${o.id} timed out after ${LLM_TIMEOUT_MS}ms`); return null; }
console.warn(`[llm] org ${o.id} err: ${e.message}`);
return null;
}
}
async function main() {
const where = [
`o.opted_out = false`,
`o.lat IS NOT NULL`,
`o.claim_status = 'unclaimed'`,
`EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)`,
];
const params = [];
if (ONLY_DOCTORS) { where.push(`o.type = ANY($1)`); params.push(DOCTOR_TYPES); }
const r = await query(`
SELECT o.id, o.name, o.type, o.address, o.city, o.state, o.zip, o.phone, o.lead_score
FROM organizations o
WHERE ${where.join(' AND ')}
ORDER BY o.lead_score DESC NULLS LAST, o.id
LIMIT $${params.length + 1}
`, [...params, LIMIT]);
console.log(`[gen] processing ${r.rowCount} orgs, ${TEMPLATES.length} templates available, model=${MODEL}`);
const results = [];
for (const o of r.rows) {
const out = path.join(OUT_DIR, `${o.id}.html`);
if (fs.existsSync(out) && !FORCE) {
console.log(`[gen] org ${o.id} ${o.name} — skip (exists, set FORCE=1 to regen)`);
results.push({ id: o.id, name: o.name, status: 'cached' });
continue;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
const t = pickTemplate(Number(o.id));
process.stdout.write(`[gen] org ${o.id} ${o.name.slice(0, 36).padEnd(36)} tpl=${(t?.id || 'none').padEnd(11)} ... `);
const result = await generateLLMHtml(o, ac.signal);
clearTimeout(timer);
if (!result) { console.log('FAIL'); results.push({ id: o.id, name: o.name, status: 'fail' }); continue; }
fs.writeFileSync(out, result.html);
console.log(`${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb`);
results.push({ id: o.id, name: o.name, status: 'ok', ms: result.ms, kb: Math.round(result.html.length / 1024), template: result.template_id });
}
// Manifest for the index page.
fs.writeFileSync(path.join(OUT_DIR, '_manifest.json'), JSON.stringify({
generated_at: new Date().toISOString(),
model: MODEL,
template_count: TEMPLATES.length,
results,
}, null, 2));
const ok = results.filter(r => r.status === 'ok').length;
console.log(`\n[gen] done. ok=${ok} cached=${results.filter(r=>r.status==='cached').length} fail=${results.filter(r=>r.status==='fail').length}`);
await pool.end();
}
main().catch(async (e) => { console.error(e); try { await pool.end(); } catch (_) {} process.exit(1); });