← back to Lawyer Directory Builder
_clickthrough.mjs
191 lines
import { chromium } from 'playwright';
import fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
const BASE = 'http://localhost:9701';
const SHOTS = '/tmp/lawyer-clickthrough';
await fs.mkdir(SHOTS, { recursive: true });
const surfaces = [
{ name: '01-landing', path: '/', caption: 'Landing — every CA-licensed attorney, indexed against the State Bar public roll.' },
{ name: '02-find', path: '/find-a-lawyer', caption: 'Consumer configurator — three short sections; you choose who to call.' },
{ name: '03-thanks', path: '/find-a-lawyer/thanks?zip=90210&area=Personal+Injury', caption: 'Inquiry logged — we don\'t contact firms on your behalf. §6155-safe receipt.' },
{ name: '03b-results', path: '/find-a-lawyer/results?zip=90210&area=Personal+Injury', caption: 'Your firms — real California-licensed firms, sorted by proximity. You choose who to call.' },
{ name: '03c-results-empty', path: '/find-a-lawyer/results?zip=96161', caption: 'Honest empty state — when the directory has no anchor for a ZIP, we say so. Two real paths forward.' },
{ name: '04-signup', path: '/signup', caption: 'Attorney signup — claim your listing from the live CalBar registry.' },
{ name: '05-login', path: '/login', caption: 'Sign in — minimal auth surface.' },
{ name: '06-audit', path: '/audit.html', caption: '1,016 attorney sites independently audited and scored against twelve published signals.' },
{ name: '06b-methodology', path: '/methodology.html', caption: 'Methodology v1.0 — the twelve audit signals enumerated, weighted, and bounded. What this score is not.' },
{ name: '06c-directory', path: '/directory', caption: 'Attorney self-service — find your firm, see your audit, decide later about an upgrade.' },
{ name: '06d-upgrade', path: '/upgrade', caption: 'EZ Upgrade — site-build service, $499 one-time. Not a directory placement fee.' },
{ name: '07-privacy', path: '/privacy.html', caption: 'Privacy — CCPA/CPRA compliant. Lead-form data never sold.' },
{ name: '08-terms', path: '/terms.html', caption: 'Terms — not a law firm; not a lawyer referral service under §6155.' },
{ name: '09-data', path: '/data', caption: 'Data marketplace — CSV exports of CA attorney directory, public-record only.' },
{ name: '10-404', path: '/lawyer-near-me-typo', caption: '404 — path-aware recovery. We surface what you tried; we direct license verification to the State Bar.' },
];
const OPERATOR = 'Steve Abrams';
const PROJECT = 'Counsel & Bar';
const URL_ = 'lawyers.agentabrams.com';
function avatarCss() {
return `
#steve-overlay {
position: fixed; right: 22px; bottom: 22px; z-index: 99999;
width: 320px; padding: 14px 16px 14px 14px;
background: linear-gradient(180deg, rgba(19,19,22,0.97), rgba(10,10,12,0.95));
border: 1px solid #b89968; box-shadow: 0 18px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(184,153,104,0.18);
color: #f4f1ea; font-family: -apple-system, "Inter", system-ui, sans-serif;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: flex; gap: 12px; align-items: flex-start;
transform: translateY(8px); opacity: 0;
animation: so-rise 700ms cubic-bezier(.16,.84,.3,1) forwards;
}
@keyframes so-rise { to { transform: none; opacity: 1; } }
#steve-overlay .so-avatar {
width: 46px; height: 46px; flex: 0 0 46px; border-radius: 50%;
background: #b89968;
display: flex; align-items: center; justify-content: center;
font-family: "Cormorant Garamond", Georgia, serif;
color: #0a0a0c; font-size: 18px; font-weight: 500; letter-spacing: 0.02em;
border: 1px solid #d4b683;
}
#steve-overlay .so-body { flex: 1; min-width: 0; }
#steve-overlay .so-name { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d4b683; font-weight: 500; margin-bottom: 2px; }
#steve-overlay .so-meta { font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: #8b857a; margin-bottom: 10px; font-weight: 500; }
#steve-overlay .so-caption { font-size: 12px; line-height: 1.5; color: #d8d2c5; font-weight: 300; }
#steve-overlay .so-caption b { color: #f4f1ea; font-weight: 500; }
#steve-overlay .so-rec { position: absolute; top: 12px; right: 14px; font-size: 9px; letter-spacing: 0.22em; color: #8b857a; font-weight: 500; font-variant-numeric: tabular-nums; }
`;
}
function avatarHtml(idx, total, caption) {
return `
<div id="steve-overlay" role="presentation">
<div class="so-rec">REC ${String(idx).padStart(2, '0')}/${String(total).padStart(2, '0')}</div>
<div class="so-avatar">SA</div>
<div class="so-body">
<div class="so-name">${OPERATOR}</div>
<div class="so-meta">${PROJECT} · ${URL_}</div>
<div class="so-caption">${caption}</div>
</div>
</div>`;
}
const results = [];
const browser = await chromium.launch();
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: SHOTS, size: { width: 1440, height: 900 } },
});
// Single page reused across all surfaces so the video shows continuous click-through
const page = await ctx.newPage();
let surfaceIdx = 0;
for (const s of surfaces) {
surfaceIdx++;
const consoleErrs = [];
const failedReq = [];
const pageErrs = [];
const onConsole = m => { if (m.type() === 'error') consoleErrs.push(m.text().slice(0, 200)); };
const onPageErr = e => pageErrs.push(String(e).slice(0, 300));
const onReqFail = req => failedReq.push(`${req.method()} ${req.url()} — ${req.failure()?.errorText || '?'}`);
page.on('console', onConsole);
page.on('pageerror', onPageErr);
page.on('requestfailed', onReqFail);
let status = '?', title = '?', err = null;
try {
const resp = await page.goto(BASE + s.path, { waitUntil: 'networkidle', timeout: 15000 });
status = resp ? resp.status() : '?';
// Inject the Steve overlay (refreshes per surface so the caption updates)
await page.addStyleTag({ content: avatarCss() }).catch(() => {});
await page.evaluate(({ html }) => {
const old = document.getElementById('steve-overlay');
if (old) old.remove();
const wrap = document.createElement('div');
wrap.innerHTML = html;
document.body.appendChild(wrap.firstElementChild);
}, { html: avatarHtml(surfaceIdx, surfaces.length, s.caption) });
await page.waitForTimeout(800);
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight / 3, behavior: 'smooth' }));
await page.waitForTimeout(900);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await page.waitForTimeout(700);
title = await page.title();
await page.screenshot({ path: `${SHOTS}/${s.name}.png` });
} catch (e) {
err = String(e).slice(0, 300);
}
page.off('console', onConsole);
page.off('pageerror', onPageErr);
page.off('requestfailed', onReqFail);
// Heuristic checks for public surfaces
let hasMonogram = false, hasVolStrip = false, hasFooterDisclaimer = false;
try {
hasMonogram = await page.evaluate(() => !!document.querySelector('svg circle[stroke*="mono"], svg circle, svg [d^="M9.6 9.5"]')) || await page.evaluate(() => !!document.querySelector('svg.monogram, .brand-mono svg, header svg'));
hasVolStrip = await page.evaluate(() => !!document.querySelector('.vol-strip, .public-vol, .signup-vol'));
hasFooterDisclaimer = await page.evaluate(() => /not a law firm|not a lawyer referral service/i.test(document.body.textContent || ''));
} catch {}
results.push({
name: s.name,
path: s.path,
status,
title,
err,
consoleErrs,
pageErrs,
failedReq: failedReq.filter(r => !/favicon\.ico/.test(r)),
hasMonogram,
hasVolStrip,
hasFooterDisclaimer,
});
}
await page.close();
await ctx.close(); // flushes the video file
await browser.close();
// Find the rendered video and rename it for clarity
const dirEntries = await fs.readdir(SHOTS);
const webm = dirEntries.find(f => f.endsWith('.webm'));
let videoPath = null;
if (webm) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
videoPath = `${SHOTS}/walkthrough-${stamp}.webm`;
await fs.rename(`${SHOTS}/${webm}`, videoPath);
}
await fs.writeFile(`${SHOTS}/report.json`, JSON.stringify(results, null, 2));
// Compact text report
const lines = [];
lines.push('CLICK-THROUGH REPORT — ' + new Date().toISOString());
lines.push('='.repeat(72));
for (const r of results) {
lines.push(`\n[${r.status}] ${r.name.padEnd(14)} ${r.path}`);
lines.push(` title: ${r.title}`);
lines.push(` brand: monogram=${r.hasMonogram} volStrip=${r.hasVolStrip} disclaimer=${r.hasFooterDisclaimer}`);
if (r.err) lines.push(` ERR: ${r.err}`);
if (r.consoleErrs.length) lines.push(` console errors (${r.consoleErrs.length}):`);
r.consoleErrs.slice(0, 5).forEach(e => lines.push(` - ${e}`));
if (r.pageErrs.length) lines.push(` page errors (${r.pageErrs.length}):`);
r.pageErrs.slice(0, 3).forEach(e => lines.push(` - ${e}`));
if (r.failedReq.length) lines.push(` failed reqs (${r.failedReq.length}):`);
r.failedReq.slice(0, 5).forEach(e => lines.push(` - ${e}`));
}
if (videoPath) {
lines.push('');
lines.push('VIDEO ' + videoPath);
}
const txt = lines.join('\n');
await fs.writeFile(`${SHOTS}/report.txt`, txt);
console.log(txt);
if (videoPath) {
spawn('open', [videoPath], { detached: true, stdio: 'ignore' }).unref();
}