← back to Professional Directory
scripts/audit-mockups.js
165 lines
#!/usr/bin/env node
/**
* audit-mockups.js — run the v5 validator over every existing mockup HTML
* file in data/mockups/ and report tainted vs clean outputs.
*
* Read-only: writes a single _v5_audit_report.jsonl + prints a summary.
* Does NOT delete or regenerate anything. Use the output to decide which
* orgs need a v5 re-gen pass.
*/
const fs = require('fs');
const path = require('path');
const { pool, query } = require('../agents/shared/db');
const MOCKUP_DIR = path.join(__dirname, '..', 'data', 'mockups');
const REPORT_PATH = path.join(MOCKUP_DIR, '_v5_audit_report.jsonl');
// ── lifted from generate-mockups-v5.js ────────────────────────────────────
function classifyOrgType(o) {
const n = String(o.name || '').toLowerCase();
const t = String(o.type || '').toLowerCase();
if (/\b(rcfe|arf|hco|assisted living|memory care|skilled nursing|nursing home|board.{1,4}care|hospice|home health)\b/.test(t + ' ' + n)) return 'eldercare';
if (/\b(salon|hair|barber|nail|lash|brow|wax|spa|aesthetic|beauty|massage|sauna|float|color bar)\b/.test(n)) {
if (/\b(med ?spa|medspa|medical spa|botox|filler|laser|coolsculpt|injection)\b/.test(n)) return 'spa_aesthetics';
if (/\b(salon|hair|barber|nail|lash|brow|wax|color bar)\b/.test(n)) return 'salon';
return 'spa_aesthetics';
}
if (/\b(gym|fitness|crossfit|pilates|yoga|barre|cycling|cycle bar|spin|strength|f45)\b/.test(n)) return 'gym_wellness';
if (/\b(dental|dentist|dds|orthodont|endodont|periodont|prosthodont|oral surg)\b/.test(t + ' ' + n)) return 'dentist';
if (/\b(chiropract|chiro\b|dc\s)/i.test(t + ' ' + n)) return 'chiropractor';
if (/\b(acupunct|herb|tcm|traditional chinese|oriental medicine|l\.?ac\.?\b)\b/.test(t + ' ' + n)) return 'acupuncture';
if (/\b(optom|optical|eye care|vision center|ophthalm)\b/.test(t + ' ' + n)) return 'optometry';
if (/\b(podiat|foot.{0,5}clinic|dpm)\b/.test(t + ' ' + n)) return 'podiatry';
if (/\b(physical therapy|pt clinic|sports med|rehab|orthoped)\b/.test(t + ' ' + n)) return 'physical_therapy';
if (/\b(mental health|psychiatr|psycholog|therapy|counsel|behavioral)\b/.test(t + ' ' + n)) return 'mental_health';
if (/\b(veterin|animal hosp|pet clinic|dvm)\b/.test(t + ' ' + n)) return 'vet';
if (/\b(urgent care|walk.?in clinic|express care)\b/.test(t + ' ' + n)) return 'urgent_care';
if (/\b(hospital|medical center)\b/.test(t + ' ' + n) && !/clinic\b/.test(n)) return 'hospital';
if (/\b(md|m\.d\.|physician|primary care|family medicine|internal medicine|pediatric|cardiolog|dermatolog|gastro|endocrin|neuro|onc|gyne|obgyn|ob.?gyn)\b/.test(t + ' ' + n)) {
if (/\b(primary care|family|internal|pediatric)\b/.test(t + ' ' + n)) return 'primary_care';
return 'specialty_md';
}
return 'generic';
}
const BANNED_WORDS = [
/\bexceptional\b/i, /\bpremium\b/i, /\belevat(e|ed|ing)\b/i, /\bworld[- ]class\b/i,
/\bcutting[- ]edge\b/i, /\bstate[- ]of[- ]the[- ]art\b/i, /\bleverage\b/i, /\bempower\b/i,
/\bstreamline\b/i, /\bunparalleled\b/i, /\bpassionate about\b/i,
/our team of dedicated professionals/i, /we are committed to providing/i,
];
function validateHtml(html, orgType) {
const issues = [];
for (const r of BANNED_WORDS) {
const m = html.match(r);
if (m) issues.push(`banned phrase: "${m[0]}"`);
}
if (/via\.placeholder\.com|placeholder\.com\/\d|placehold\.co|placeimg\.com|loremflickr\.com/i.test(html)) issues.push('placeholder image URL');
if (/<img[^>]+src=["']\s*["']/i.test(html)) issues.push('empty image src');
if (/\b(Team Member|Doctor|Provider|Practitioner|Member|Stylist|Coach)\s+\d\b/i.test(html)) issues.push('"Team Member N" placeholder name');
if (/\b(Dr\.|MD|DDS|DC|LAc|L\.Ac\.)\s+(Lastname|Smith|Doe|Person)\b/i.test(html)) issues.push('placeholder doctor name');
const hasFaClasses = /<i\s+class=["'](fa[srlb]?|fab|fas|far|fal)\s+fa-/i.test(html);
const hasFaLoaded = /font.?awesome|use\.fontawesome|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i.test(html);
if (hasFaClasses && !hasFaLoaded) issues.push('Font Awesome classes used without FA stylesheet loaded');
const nonMedical = ['salon', 'spa_aesthetics', 'gym_wellness'];
if (nonMedical.includes(orgType)) {
if (/\b(insurance|copay|telehealth|medical advice|HIPAA|prescription|refill)\b/i.test(html)) issues.push(`non-medical org (${orgType}) page mentions clinical/insurance topics`);
}
if ((orgType === 'salon' || orgType === 'gym_wellness') && /not (medical|clinical) advice|in[- ]person (clinical|medical) (exam|consultation)/i.test(html)) {
issues.push(`${orgType} should not carry medical-advice disclaimer`);
}
if (!/<title>[^<]+<\/title>/i.test(html)) issues.push('missing <title>');
if (!/<meta\s+name=["']description["']/i.test(html)) issues.push('missing meta description');
if (!/application\/ld\+json/i.test(html)) issues.push('missing JSON-LD');
return { ok: issues.length === 0, issues };
}
// ── walk + report ─────────────────────────────────────────────────────────
async function main() {
const files = fs.readdirSync(MOCKUP_DIR)
.filter(f => f.endsWith('.html') && !f.startsWith('_'))
.sort();
console.log(`[audit] scanning ${files.length} mockup files`);
// collect distinct org IDs
const orgIds = [...new Set(files.map(f => f.split('-')[0]).filter(s => /^\d+$/.test(s)))];
console.log(`[audit] looking up ${orgIds.length} distinct orgs from PG`);
const r = await query(
`SELECT id, name, type FROM organizations WHERE id = ANY($1::int[])`,
[orgIds.map(Number)]
);
const orgById = new Map(r.rows.map(o => [String(o.id), o]));
// Truncate previous report
fs.writeFileSync(REPORT_PATH, '');
const stats = {
total: 0, ok: 0, tainted: 0, missing_org: 0,
issue_counts: {},
by_orgtype: {},
};
const taintedFiles = [];
for (const f of files) {
stats.total++;
const orgId = f.split('-')[0];
const org = orgById.get(orgId);
if (!org) {
stats.missing_org++;
const rec = { ts: new Date().toISOString(), file: f, ok: false, issues: ['org not in DB'], orgType: null };
fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
continue;
}
const orgType = classifyOrgType(org);
const html = fs.readFileSync(path.join(MOCKUP_DIR, f), 'utf8');
const v = validateHtml(html, orgType);
stats.by_orgtype[orgType] = stats.by_orgtype[orgType] || { ok: 0, tainted: 0 };
if (v.ok) {
stats.ok++;
stats.by_orgtype[orgType].ok++;
} else {
stats.tainted++;
stats.by_orgtype[orgType].tainted++;
taintedFiles.push({ file: f, orgId, orgType, issues: v.issues });
for (const issue of v.issues) {
const key = issue.replace(/"[^"]+"/g, '"…"').slice(0, 80);
stats.issue_counts[key] = (stats.issue_counts[key] || 0) + 1;
}
}
const rec = { ts: new Date().toISOString(), file: f, orgId, orgName: org.name, orgType, ok: v.ok, issues: v.issues };
fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
}
// Print summary
console.log('\n=== AUDIT SUMMARY ===');
console.log(`total files: ${stats.total}`);
console.log(`clean (ok): ${stats.ok} (${(stats.ok/stats.total*100).toFixed(1)}%)`);
console.log(`tainted: ${stats.tainted} (${(stats.tainted/stats.total*100).toFixed(1)}%)`);
console.log(`missing org: ${stats.missing_org}`);
console.log('\n--- by org type ---');
for (const [t, v] of Object.entries(stats.by_orgtype).sort((a,b)=>(b[1].ok+b[1].tainted)-(a[1].ok+a[1].tainted))) {
const tot = v.ok + v.tainted;
console.log(` ${t.padEnd(18)} ${String(v.ok).padStart(4)} ok / ${String(v.tainted).padStart(4)} tainted (${tot} total, ${(v.tainted/tot*100).toFixed(0)}% bad)`);
}
console.log('\n--- top issues ---');
const top = Object.entries(stats.issue_counts).sort((a,b)=>b[1]-a[1]).slice(0, 15);
for (const [k, n] of top) console.log(` ${String(n).padStart(4)} ${k}`);
// Top tainted orgs (need re-gen)
const orgFails = {};
for (const t of taintedFiles) orgFails[t.orgId] = (orgFails[t.orgId] || 0) + 1;
const orgsToRegen = Object.entries(orgFails).filter(([_, n]) => n >= 2).sort((a,b)=>b[1]-a[1]);
console.log(`\n--- orgs with 2+ tainted variants (regen candidates): ${orgsToRegen.length} ---`);
for (const [oid, n] of orgsToRegen.slice(0, 20)) {
const o = orgById.get(oid);
console.log(` ${oid} ${n} bad ${o ? o.name : '?'}`);
}
console.log(`\nfull report: ${REPORT_PATH}`);
await pool.end();
}
main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });