← back to Small Business Builder
src/lib/deep-dive.js
96 lines
// deep-dive — synthesizes a mini site-audit report for a website_analyses
// record. Pure local synthesis: no scraping, no LLM calls. Returns structured
// JSON that gets stored in summary_json.audit_report and rendered on /wa/:slug.
const VERTICAL_TIPS = {
salon: ['Add a "Book in 3 clicks" CTA above the fold.', 'Show real stylist photos — never stock.', 'Pin Yelp/Google review count to the hero.'],
restaurant: ['Hero needs the menu trigger AND the booking trigger — not one or the other.', 'List today\'s hours in plain text, not just a chart.', 'Embed the latest IG post above the footer.'],
lawyer: ['Hero must qualify the visitor in <2 seconds (e.g. "Hurt at work? Free case review").', 'Avoid lawyer-jargon — write at 7th-grade level.', 'Court-verifiable case results > vague testimonials.'],
vet: ['New-patient intake form deserves its own primary CTA.', 'Show clinic hours + emergency line in the hero.', 'Pet-passport, vaccine-due lookup widgets earn repeat visits.'],
advocacy: ['Move the donation CTA to a sticky header bar.', 'Story-wall above the demands list — humans before policy.', 'Live counter on signatures/calls drives social proof.'],
'saas-landing': ['Hero CTA must be 1-click (no "Sign up" → form route).', 'Add a 60-second product video above the fold.', 'Real-customer logos beat any stock badge.'],
ecom: ['Free-shipping line in the announcement bar.', 'Reviews + star count in the hero.', 'Sticky add-to-cart on PDP — non-negotiable.'],
generic: ['Lead with the "what we do" — visitors leave in 8 seconds.', 'One primary CTA, sized larger than anything else.', 'Add a real photograph — even a phone shot beats a CSS gradient.'],
};
const SEO_RULES = [
{ key: 'title_too_short', test: t => (t || '').length < 24, pts: -8, msg: 'Title under 24 chars — search engines truncate before delivering value.' },
{ key: 'title_too_long', test: t => (t || '').length > 70, pts: -4, msg: 'Title >70 chars — Google truncates. Front-load the keyword.' },
{ key: 'title_ok', test: t => (t || '').length >= 24 && (t || '').length <= 70, pts: 12, msg: 'Title length is in the SEO sweet spot.' },
];
const CONTENT_RULES = [
{ key: 'tagline_too_short', test: s => (s || '').length < 40, pts: -10, msg: 'Tagline under 40 chars — visitor leaves before getting context.' },
{ key: 'tagline_too_long', test: s => (s || '').length > 240, pts: -3, msg: 'Tagline >240 chars — break it into a hero line + body paragraph.' },
{ key: 'tagline_ok', test: s => (s || '').length >= 40 && (s || '').length <= 240, pts: 10, msg: 'Tagline is well-sized for above-the-fold scanning.' },
{ key: 'no_caps', test: s => /[A-Z]/.test(s || ''), pts: 4, msg: 'Sentence-case maintained — not all-lowercase or all-caps.' },
];
function checkRules(rules, val) {
const fired = [];
let delta = 0;
for (const r of rules) {
try {
if (r.test(val)) { fired.push({ key: r.key, pts: r.pts, msg: r.msg }); delta += r.pts; }
} catch {}
}
return { fired, delta };
}
export function runDeepDive(analysis) {
const meta = analysis?.meta_json || {};
const summary = analysis?.summary_json || {};
const title = summary.title || meta.title || '';
const tagline = summary.tagline || meta.tagline || '';
const vertical = analysis?.vertical || 'generic';
const palette = Array.isArray(meta.palette) ? meta.palette : [];
const heroImage = !!meta.hero_image;
const igHandle = !!analysis?.ig_handle;
const iframeBlocked = !!meta.iframe_blocked;
const seo = checkRules(SEO_RULES, title);
const content = checkRules(CONTENT_RULES, tagline);
// Composition / a11y heuristics — synthesize from what we have
const composition = [];
if (!heroImage) composition.push({ key: 'no_hero', pts: -5, msg: 'No hero image scraped — add an authentic photograph or commissioned illustration.' });
if (palette.length === 0) composition.push({ key: 'no_palette', pts: -3, msg: 'No palette inferred — site CSS may rely on framework defaults; add 2-3 brand colors.' });
else if (palette.length >= 5) composition.push({ key: 'palette_ok', pts: 5, msg: 'Palette has ≥5 colors — designers have headroom for hierarchy.' });
if (!igHandle) composition.push({ key: 'no_ig', pts: -3, msg: 'No Instagram linked — social proof + content reuse opportunity missed.' });
if (iframeBlocked) composition.push({ key: 'xfo_set', pts: 3, msg: 'X-Frame-Options set — small security win, but blocks our preview embed.' });
if (vertical !== 'generic') composition.push({ key: 'vertical_detected', pts: 5, msg: `Vertical detected as "${vertical}" — site copy aligns with category keywords.` });
// Top-3 actionable suggestions — vertical-specific
const tips = (VERTICAL_TIPS[vertical] || VERTICAL_TIPS.generic).slice(0, 3);
// Suggested replacement headlines (3, drawn from tagline if possible)
const sentences = tagline.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length >= 12);
const headlines = sentences.length >= 3
? sentences.slice(0, 3).map(s => s.charAt(0).toUpperCase() + s.slice(1))
: [
`${title || 'Your business'} — what we actually do.`,
`Why ${title || 'we'} matters now.`,
`Get started in 30 seconds.`,
];
// Aggregate score
const sectionScore = (50 + seo.delta + content.delta + composition.reduce((acc, c) => acc + c.pts, 0));
const score = Math.max(0, Math.min(100, sectionScore));
return {
generated_at: new Date().toISOString(),
score,
sections: {
seo: { score_delta: seo.delta, findings: seo.fired },
content: { score_delta: content.delta, findings: content.fired },
composition:{ score_delta: composition.reduce((a, c) => a + c.pts, 0), findings: composition },
},
suggested_headlines: headlines,
vertical_tips: tips,
quick_wins: [
...seo.fired.filter(r => r.pts < 0).map(r => r.msg),
...content.fired.filter(r => r.pts < 0).map(r => r.msg),
...composition.filter(r => r.pts < 0).map(r => r.msg),
].slice(0, 5),
};
}