← back to Small Business Builder
scripts/audit-public-sites.mjs
263 lines
// Real site audit — fetches every website_analyses row that has a public URL,
// parses with cheerio, extracts SEO/quality signals, and writes the report
// into summary_json.live_audit. Adds REAL HTML-derived signal to the audit
// grade (beyond the synthesis-only rubric in audit-grade.js).
//
// USAGE:
// node scripts/audit-public-sites.mjs # all public URLs
// node scripts/audit-public-sites.mjs --slug <slug> # one
// node scripts/audit-public-sites.mjs --rebuild # re-audit even
// # those with live_audit
import 'dotenv/config';
import * as cheerio from 'cheerio';
import { many, query } from '../src/lib/db.js';
const args = process.argv.slice(2);
const REBUILD = args.includes('--rebuild');
const SLUG = (() => { const i = args.indexOf('--slug'); return i >= 0 ? args[i + 1] : null; })();
const TIMEOUT_MS = 15_000;
const UA = 'SmallBusinessBuilderAuditBot/1.0 (+https://builds.agentabrams.com)';
function isPublicUrl(u) {
try {
const p = new URL(u);
if (p.protocol !== 'http:' && p.protocol !== 'https:') return false;
const h = p.hostname;
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(h)) return false;
if (h === 'localhost' || h.endsWith('.local')) return false;
return true;
} catch { return false; }
}
async function fetchHtml(url) {
const res = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8' },
});
// Re-validate post-redirect target hasn't slipped into private space.
if (!isPublicUrl(res.url)) throw new Error(`redirected to non-public ${res.url}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('html')) throw new Error(`non-html content-type ${ct}`);
const buf = await res.arrayBuffer();
if (buf.byteLength > 5_000_000) throw new Error(`html too large (${buf.byteLength} bytes)`);
return { html: new TextDecoder().decode(buf), bytes: buf.byteLength, finalUrl: res.url, status: res.status };
}
function parse(html, finalUrl) {
const $ = cheerio.load(html);
const u = new URL(finalUrl);
const title = ($('title').first().text() || '').trim();
const metaDesc = ($('meta[name="description"]').attr('content') || '').trim();
const ogImage = ($('meta[property="og:image"]').attr('content') || '').trim();
const ogTitle = ($('meta[property="og:title"]').attr('content') || '').trim();
const ogDesc = ($('meta[property="og:description"]').attr('content') || '').trim();
const twitterCard = ($('meta[name="twitter:card"]').attr('content') || '').trim();
const viewport = ($('meta[name="viewport"]').attr('content') || '').trim();
const charset = ($('meta[charset]').attr('charset') || '').trim();
const lang = ($('html').attr('lang') || '').trim();
const canonical = ($('link[rel="canonical"]').attr('href') || '').trim();
const favicon = $('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]').length > 0;
const robots = ($('meta[name="robots"]').attr('content') || '').trim();
const h1s = $('h1').map((_, el) => $(el).text().trim()).get().filter(Boolean);
const h2Count = $('h2').length;
const h3Count = $('h3').length;
const imgs = $('img').toArray();
const imgsWithAlt = imgs.filter(el => ($(el).attr('alt') || '').trim().length > 0).length;
const imgCount = imgs.length;
const links = $('a[href]').toArray();
let internal = 0, external = 0;
for (const el of links) {
const href = $(el).attr('href') || '';
if (href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) continue;
try {
const abs = new URL(href, finalUrl);
if (abs.host === u.host) internal += 1; else external += 1;
} catch {}
}
const jsonLd = $('script[type="application/ld+json"]').map((_, el) => {
try { return JSON.parse($(el).contents().text()); } catch { return null; }
}).get().filter(Boolean);
const jsonLdTypes = [...new Set(jsonLd.flatMap(o => Array.isArray(o['@graph']) ? o['@graph'].map(g => g['@type']) : [o['@type']]).filter(Boolean))];
return {
title, title_len: title.length,
meta_description: metaDesc, meta_description_len: metaDesc.length,
og_image: ogImage || null,
og_title: ogTitle || null,
og_description: ogDesc || null,
twitter_card: twitterCard || null,
viewport: viewport || null,
charset: charset || null,
lang: lang || null,
canonical: canonical || null,
favicon,
robots: robots || null,
h1_count: h1s.length,
h1_first: h1s[0] || null,
h2_count: h2Count,
h3_count: h3Count,
img_count: imgCount,
img_with_alt: imgsWithAlt,
img_alt_pct: imgCount > 0 ? Math.round((imgsWithAlt / imgCount) * 100) : null,
links_internal: internal,
links_external: external,
jsonld_types: jsonLdTypes,
is_https: u.protocol === 'https:',
};
}
function scoreReport(rep) {
let score = 0;
const reasons = [];
const wins = [];
const issues = [];
if (rep.title_len >= 30 && rep.title_len <= 60) { score += 10; wins.push('title length 30-60 chars'); }
else if (rep.title_len > 0) { score += 4; issues.push(`title ${rep.title_len} chars (target 30-60)`); }
else { issues.push('no <title> tag'); }
if (rep.meta_description_len >= 70 && rep.meta_description_len <= 160) { score += 10; wins.push('meta description 70-160 chars'); }
else if (rep.meta_description_len > 0) { score += 4; issues.push(`meta description ${rep.meta_description_len} chars (target 70-160)`); }
else { issues.push('no meta description'); }
if (rep.og_image) { score += 8; wins.push('og:image set'); } else { issues.push('no og:image — bad social previews'); }
if (rep.og_title || rep.og_description) { score += 5; wins.push('og:title/description set'); }
if (rep.twitter_card) { score += 3; wins.push('twitter:card set'); }
if (rep.favicon) { score += 5; wins.push('favicon present'); } else { issues.push('no favicon'); }
if (rep.viewport) { score += 10; wins.push('mobile viewport meta'); } else { issues.push('no viewport meta — broken mobile'); }
if (rep.charset) { score += 3; wins.push('charset declared'); }
if (rep.lang) { score += 5; wins.push(`html lang="${rep.lang}"`); } else { issues.push('no html lang attribute'); }
if (rep.canonical) { score += 5; wins.push('canonical link set'); }
if (rep.h1_count === 1) { score += 10; wins.push('exactly one <h1>'); }
else if (rep.h1_count === 0) { issues.push('no <h1> tag'); }
else { score += 3; issues.push(`${rep.h1_count} <h1> tags (one is best)`); }
if (rep.img_count === 0) { /* no penalty — text-only is fine */ }
else if (rep.img_alt_pct >= 80) { score += 10; wins.push(`${rep.img_alt_pct}% of images have alt text`); }
else { issues.push(`only ${rep.img_alt_pct}% of ${rep.img_count} images have alt text`); }
if (rep.jsonld_types.length > 0) { score += 8; wins.push(`structured data (${rep.jsonld_types.join(', ')})`); }
else { issues.push('no JSON-LD structured data'); }
if (rep.is_https) { score += 5; wins.push('HTTPS'); } else { issues.push('not on HTTPS'); }
if (score > 100) score = 100;
const letter = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 45 ? 'C' : score >= 25 ? 'D' : 'F';
return { score, letter, wins, issues, reasons };
}
async function auditOne(row) {
const t0 = Date.now();
const fetched = await fetchHtml(row.url);
const rep = parse(fetched.html, fetched.finalUrl);
const grade = scoreReport(rep);
const audit = {
url: row.url,
final_url: fetched.finalUrl,
fetched_at: new Date().toISOString(),
http_status: fetched.status,
bytes: fetched.bytes,
fetch_ms: Date.now() - t0,
signals: rep,
grade,
top_3_issues: grade.issues.slice(0, 3),
top_3_wins: grade.wins.slice(0, 3),
};
// Audit history: keep the last 4 snapshots (slim — just score + issues count
// + fetched_at) so we can plot a sparkline + flag regressions.
const prevAudit = row.summary_json?.live_audit;
const prevHistory = Array.isArray(row.summary_json?.live_audit_history) ? row.summary_json.live_audit_history : [];
const newHistory = prevAudit ? [
{
score: prevAudit.grade?.score,
letter: prevAudit.grade?.letter,
fetched_at: prevAudit.fetched_at,
issues: prevAudit.top_3_issues?.length ?? 0,
bytes: prevAudit.bytes,
},
...prevHistory,
].slice(0, 4) : prevHistory;
// Flag regression if score dropped > 10 points or letter dropped (e.g. B → C).
const prevScore = prevAudit?.grade?.score;
const regression = (Number.isFinite(prevScore) && (prevScore - grade.score) > 10)
? { from: prevScore, to: grade.score, delta: grade.score - prevScore, detected_at: new Date().toISOString() }
: null;
// Use jsonb || merge instead of read-modify-write so concurrent runs on the
// same slug don't silently overwrite each other's live_audit / copy_pack.
await query("UPDATE website_analyses SET summary_json = COALESCE(summary_json, '{}'::jsonb) || $1::jsonb, updated_at=NOW() WHERE id=$2",
[JSON.stringify({
live_audit: audit,
live_audit_history: newHistory,
...(regression ? { live_audit_regression: regression } : {}),
}), row.id]);
return {
slug: row.slug, status: 'ok',
score: grade.score, letter: grade.letter, ms: audit.fetch_ms,
issues: grade.issues.length, wins: grade.wins.length,
delta: Number.isFinite(prevScore) ? grade.score - prevScore : null,
regression: !!regression,
};
}
async function main() {
let rows;
if (SLUG) {
rows = await many('SELECT id, slug, url, summary_json FROM website_analyses WHERE slug = $1', [SLUG]);
} else {
const baseWhere = `WHERE url ~ '^https?://' AND url NOT LIKE '%.local%' AND url NOT LIKE 'local://%' AND url NOT LIKE 'http://100.%' AND url NOT LIKE 'http://192.168.%'`;
const skipExisting = REBUILD ? '' : ` AND NOT (summary_json ? 'live_audit')`;
rows = await many(`SELECT id, slug, url, summary_json FROM website_analyses ${baseWhere}${skipExisting} ORDER BY id`);
}
const filtered = rows.filter(r => isPublicUrl(r.url));
console.log(`Auditing ${filtered.length} public URLs (skipped ${rows.length - filtered.length} private)`);
let ok = 0, err = 0;
const regressions = [];
for (const row of filtered) {
try {
const r = await auditOne(row);
ok += 1;
const deltaStr = r.delta != null ? (r.delta > 0 ? `+${r.delta}` : `${r.delta}`).padStart(4) : ' ·';
console.log(` [${ok}/${filtered.length}] ${r.letter} ${String(r.score).padStart(3)}${r.regression ? ' ⚠' : ' '} ${deltaStr} ${r.slug.padEnd(28)} ${r.ms}ms ✓${r.wins} ✗${r.issues}`);
if (r.regression) regressions.push({ slug: r.slug, score: r.score, delta: r.delta });
} catch (e) {
err += 1;
console.error(` ERR ${row.slug} ${e.message}`);
}
}
console.log(`\nDONE — ok:${ok} err:${err}${regressions.length ? ` ⚠ ${regressions.length} REGRESSIONS` : ''}`);
// Email digest via George if any site regressed > 10 points.
if (regressions.length) {
try {
const body = `Live audit run finished with ${regressions.length} regression${regressions.length === 1 ? '' : 's'}:\n\n` +
regressions.map(r => ` ${r.slug}: now ${r.score} (${r.delta > 0 ? '+' : ''}${r.delta})`).join('\n') +
`\n\nFull details: https://livesiteaudit.agentabrams.com/wall (login to /builds for admin view)`;
await fetch('http://localhost:9850/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: 'steveabramsdesigns@gmail.com',
subject: `[livesiteaudit] ${regressions.length} site${regressions.length === 1 ? '' : 's'} regressed`,
body,
}),
});
console.log(`Alerted Steve via George (${regressions.length} regressions)`);
} catch (e) { console.error('George alert failed:', e.message); }
}
process.exit(0);
}
main().catch(e => { console.error(e); process.exit(1); });