← 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),
  };
}