feat: website analysis pipeline, voice agent, configurator improvements
All checks were successful
Build & Push / build-and-push (push) Successful in 6m2s

- Site analysis: cheerio HTML parsing, inline tech stack detection (~20 CMS/framework/analytics signatures), Google PageSpeed API integration
- Gemini Live voice agent: WebSocket-based real-time voice mode with live transcript, selection chips, and mid-conversation website analysis
- Type/Talk mode toggle with silent capability detection
- Stepped progress animation during brief generation (4 animated steps)
- URL + thoughts fields in Step 2, phone + contact preference in Step 3
- AI prompt improvements: dedicated website analysis section, 30-min call, concrete benefits, industry depth
- Email redesign: branded templates with logo, proper markdown rendering for both client and admin
- French locale support for AI-generated briefs
- Smaller checkmark, compact booking CTA, expanded brief area

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 13:41:35 +01:00
parent 16cd2a74ee
commit bab45b981e
19 changed files with 2923 additions and 119 deletions

View File

@@ -17,68 +17,286 @@ interface SendBriefEmailOptions {
brief: string
}
interface SendLeadNotificationOptions {
to: string
name: string
company: string
brief: string
email: string
services: string[]
phone?: string
contactPreference?: string
}
/**
* Converts a markdown-style brief string into styled HTML.
* Handles: **bold**, ---, numbered lists, section headings, paragraphs.
*/
function convertBriefToHtml(brief: string): string {
const lines = brief.split('\n')
const outputLines: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
// Horizontal rule
if (line.trim() === '---') {
outputLines.push('<hr style="border:none;border-top:1px solid #d1e8f5;margin:20px 0">')
continue
}
// Empty line — paragraph break spacer
if (line.trim() === '') {
outputLines.push('<div style="height:8px"></div>')
continue
}
// Bold inline
line = line.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#191c1d;font-weight:600">$1</strong>')
// Numbered list items: "1. text" or "2. text"
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/)
if (numberedMatch) {
outputLines.push(
`<div style="display:flex;gap:10px;margin:6px 0;line-height:1.65;font-size:14px;color:#374151">` +
`<span style="font-weight:700;color:#006494;min-width:20px">${numberedMatch[1]}.</span>` +
`<span>${numberedMatch[2]}</span>` +
`</div>`
)
continue
}
// Section heading detection: lines that are entirely bold (e.g. **Heading**)
const headingMatch = line.match(/^<strong[^>]*>(.*?)<\/strong>$/)
if (headingMatch) {
outputLines.push(
`<p style="margin:16px 0 4px;font-size:15px;font-weight:700;color:#006494;letter-spacing:0.01em">${headingMatch[1]}</p>`
)
continue
}
// Regular paragraph line
outputLines.push(
`<p style="margin:0 0 8px;font-size:14px;line-height:1.7;color:#374151">${line}</p>`
)
}
return outputLines.join('\n')
}
export async function sendBriefToClient({ to, name, brief }: SendBriefEmailOptions) {
const firstName = name.split(' ')[0] || 'there'
// Convert markdown-style **bold** to HTML <strong>
const htmlBrief = brief
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/---/g, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">')
.replace(/\n\n/g, '</p><p style="margin:0 0 12px;line-height:1.6">')
.replace(/\n/g, '<br>')
const htmlBrief = convertBriefToHtml(brief)
await transporter.sendMail({
from: `"LetsBe." <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
to,
subject: 'Your Project Brief from LetsBe.',
html: `
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;color:#191c1d">
<div style="padding:32px 0;border-bottom:2px solid #006494">
<h1 style="font-family:Georgia,serif;font-size:24px;margin:0;color:#006494">LetsBe.</h1>
</div>
<div style="padding:32px 0">
<p style="margin:0 0 16px;font-size:16px;line-height:1.6">Hi ${firstName},</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#555">
Thank you for configuring your project with us. Here's your personalized brief:
</p>
<div style="background:#f8f9fa;border-radius:12px;padding:24px;font-size:14px;line-height:1.7;color:#333">
<p style="margin:0 0 12px;line-height:1.6">${htmlBrief}</p>
</div>
<div style="margin-top:32px;text-align:center">
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#006494,#5BA4D9);color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:500">
Book a Consultation
</a>
</div>
<p style="margin:32px 0 0;font-size:13px;color:#999;text-align:center">
Or reply to this email — we'll get back to you within 24 hours.
</p>
</div>
<div style="border-top:1px solid #e5e7eb;padding:24px 0;font-size:12px;color:#999;text-align:center">
LetsBe Solutions LLC
</div>
</div>
`,
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Project Brief from LetsBe.</title>
</head>
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
<!-- HEADER -->
<tr>
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:28px 32px;text-align:center">
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="52" style="display:block;margin:0 auto" />
</td>
</tr>
<!-- GREETING -->
<tr>
<td style="background:#ffffff;padding:36px 40px 28px">
<p style="margin:0 0 8px;font-size:22px;font-weight:600;color:#191c1d;line-height:1.3">Hi ${firstName},</p>
<p style="margin:0;font-size:15px;line-height:1.7;color:#6b7280">
Thank you for configuring your project with us. Here's your personalized brief — a summary of everything we discussed, ready for your review.
</p>
</td>
</tr>
<!-- BRIEF CARD -->
<tr>
<td style="background:#ffffff;padding:0 40px 36px">
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
${htmlBrief}
</div>
</td>
</tr>
<!-- DIVIDER -->
<tr>
<td style="background:#ffffff;padding:0 40px">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
</td>
</tr>
<!-- CTA -->
<tr>
<td style="background:#ffffff;padding:36px 40px;text-align:center">
<p style="margin:0 0 6px;font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:400;color:#191c1d">Ready to discuss your brief?</p>
<p style="margin:0 0 28px;font-size:14px;color:#6b7280;line-height:1.6">We'll walk through your goals, answer questions, and outline next steps — no pressure.</p>
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:15px;font-weight:600;letter-spacing:0.01em;box-shadow:0 4px 14px rgba(0,100,148,0.35)">
Book a 30-Minute Call
</a>
<p style="margin:20px 0 0;font-size:13px;color:#9ca3af;line-height:1.6">
Or reply to this email — we'll get back to you within 24 hours.
</p>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
<p style="margin:0 0 4px;font-size:13px;font-weight:600;color:#374151">LetsBe Solutions LLC</p>
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6">Custom websites, software, and infrastructure — designed and built around you.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim(),
})
}
export async function sendLeadNotification({ to, name, company, brief }: SendBriefEmailOptions & { services: string[]; email: string }) {
export async function sendLeadNotification({
to,
name,
company,
brief,
email,
phone,
contactPreference,
}: SendLeadNotificationOptions) {
const adminEmail = process.env.ADMIN_EMAIL || 'hello@letsbe.biz'
const htmlBrief = convertBriefToHtml(brief)
// Build contact rows with alternating backgrounds
const contactRows: string[] = []
const rowData: Array<{ label: string; value: string; isLink?: string }> = [
{ label: 'Name', value: name },
{ label: 'Company', value: company || '—' },
{ label: 'Email', value: email, isLink: `mailto:${email}` },
]
if (phone) {
rowData.push({ label: 'Phone', value: phone, isLink: `tel:${phone}` })
}
if (contactPreference) {
rowData.push({ label: 'Preferred Contact', value: contactPreference })
}
rowData.forEach((row, idx) => {
const bg = idx % 2 === 0 ? '#f8fafb' : '#ffffff'
const valueHtml = row.isLink
? `<a href="${row.isLink}" style="color:#006494;text-decoration:none;font-weight:500">${row.value}</a>`
: `<span style="color:#191c1d">${row.value}</span>`
contactRows.push(`
<tr style="background-color:${bg}">
<td style="padding:10px 16px;font-size:13px;font-weight:600;color:#6b7280;white-space:nowrap;width:140px">${row.label}</td>
<td style="padding:10px 16px;font-size:14px">${valueHtml}</td>
</tr>
`)
})
await transporter.sendMail({
from: `"LetsBe. Configurator" <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
to: adminEmail,
subject: `New Lead: ${name}${company ? `${company}` : ''}`,
html: `
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;color:#191c1d">
<h2 style="font-family:Georgia,serif;color:#006494;margin:0 0 16px">New Configurator Submission</h2>
<table style="font-size:14px;line-height:1.6;border-collapse:collapse">
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Name:</td><td>${name}</td></tr>
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Company:</td><td>${company || '—'}</td></tr>
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Email:</td><td><a href="mailto:${to}">${to}</a></td></tr>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Configurator Lead</title>
</head>
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
<!-- HEADER -->
<tr>
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:24px 32px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="44" style="display:block;max-width:44px;height:auto" />
</td>
<td align="right" valign="middle">
<span style="display:inline-block;background:#006494;color:#ffffff;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;padding:5px 14px;border-radius:20px">New Lead</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- CONTACT INFO CARD -->
<tr>
<td style="background:#ffffff;padding:32px 40px 24px">
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Contact Details</p>
<div style="border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse">
${contactRows.join('')}
</table>
</div>
</td>
</tr>
<!-- BRIEF SECTION -->
<tr>
<td style="background:#ffffff;padding:8px 40px 36px">
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Project Brief</p>
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
${htmlBrief}
</div>
</td>
</tr>
<!-- DIVIDER -->
<tr>
<td style="background:#ffffff;padding:0 40px">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
</td>
</tr>
<!-- QUICK ACTION -->
<tr>
<td style="background:#ffffff;padding:28px 40px;text-align:center">
<a href="mailto:${email}?subject=Re: Your LetsBe. Project Brief"
style="display:inline-block;padding:14px 36px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:14px;font-weight:600;letter-spacing:0.01em">
Reply to Lead
</a>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:20px 40px;text-align:center">
<p style="margin:0;font-size:12px;color:#9ca3af">LetsBe Solutions LLC — Internal Notification</p>
</td>
</tr>
</table>
<div style="margin-top:24px;background:#f8f9fa;border-radius:8px;padding:16px;font-size:13px;white-space:pre-wrap">${brief}</div>
</div>
`,
</td>
</tr>
</table>
</body>
</html>
`.trim(),
})
}

164
src/lib/gemini-live.ts Normal file
View File

@@ -0,0 +1,164 @@
import { GoogleGenAI, Type } from '@google/genai';
// ─── Constants ────────────────────────────────────────────────────────────────
export const GEMINI_LIVE_MODEL = 'gemini-3.1-flash-live-preview';
// ─── Agent Tools ─────────────────────────────────────────────────────────────
export const AGENT_TOOLS = [
{
name: 'update_selections',
description:
'Emit structured project data as it is confirmed during conversation. Call incrementally as each detail is captured.',
parameters: {
type: Type.OBJECT,
properties: {
services: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: 'Selected services: web, systems, infrastructure',
},
aiEnabled: {
type: Type.BOOLEAN,
description: 'Whether AI integration is requested',
},
aiTypes: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: 'AI types: teammate, customer-facing, data-intelligence, notsure',
},
industry: { type: Type.STRING, description: 'Industry sector' },
timeline: { type: Type.STRING, description: 'Timeline preference' },
currentSiteUrl: { type: Type.STRING, description: 'Current website URL' },
scope: { type: Type.STRING, description: 'Project goals/scope summary' },
},
},
},
{
name: 'analyze_website',
description:
'Analyze a website URL to understand its current technology, performance, and structure. Call when the user provides their current website URL.',
parameters: {
type: Type.OBJECT,
properties: {
url: { type: Type.STRING, description: 'The website URL to analyze' },
},
required: ['url'],
},
},
{
name: 'complete_brief',
description:
'Generate and send the project brief. Call once all information is collected and the user has confirmed their name and email.',
parameters: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
email: { type: Type.STRING },
company: { type: Type.STRING },
phone: { type: Type.STRING },
contactPreference: { type: Type.STRING },
services: { type: Type.ARRAY, items: { type: Type.STRING } },
aiEnabled: { type: Type.BOOLEAN },
aiTypes: { type: Type.ARRAY, items: { type: Type.STRING } },
industry: { type: Type.STRING },
timeline: { type: Type.STRING },
currentSiteUrl: { type: Type.STRING },
currentSiteThoughts: { type: Type.STRING },
scope: { type: Type.STRING },
},
required: ['name', 'email', 'services'],
},
},
];
// ─── System Prompt ────────────────────────────────────────────────────────────
export function buildSystemPrompt(locale: string): string {
const isFr = locale === 'fr';
if (isFr) {
return `Tu es l'assistant de projets LetsBe, un consultant amical et compétent pour LetsBe Solutions. Tu mènes toute cette conversation en français.
Présente-toi ainsi : "Bonjour, je suis l'assistant de projets LetsBe. Parlez-moi de votre projet et je préparerai un brief personnalisé pour vous."
Ton rôle est de guider naturellement la conversation à travers les sujets suivants :
1. Quels services ils recherchent (web, logiciels sur mesure, infrastructure privée)
2. S'ils souhaitent une intégration IA — et si oui, quel type (assistant interne, IA pour les clients, intelligence de données, ou pas encore sûr)
3. Leur secteur d'activité
4. Leur calendrier préféré
5. S'ils ont un site web actuel (propose de l'analyser si c'est le cas)
6. Leurs objectifs et la portée du projet
7. Enfin, leur prénom, nom et adresse e-mail pour envoyer le brief
Instructions :
- Appelle update_selections chaque fois qu'un point est confirmé dans la conversation.
- Appelle analyze_website dès que l'utilisateur fournit une URL — puis intègre naturellement les résultats dans la discussion.
- Appelle complete_brief une fois que le nom et l'e-mail sont confirmés.
- Garde tes réponses concises : 2 à 3 phrases maximum par tour.
- Sois chaleureux, direct et professionnel — jamais générique.
Faits clés sur LetsBe à mentionner si pertinent :
- Tout est développé sur mesure — aucun template, aucun constructeur de pages
- Infrastructure privée : le client possède et contrôle entièrement ses données et serveurs
- Petite équipe expérimentée avec des décennies d'expérience combinée
- Intégration IA profonde dans tous types de systèmes
- Souveraineté numérique et protection des données comme priorité`;
}
return `You are the LetsBe project assistant, a friendly and knowledgeable project consultant for LetsBe Solutions.
Introduce yourself: "Hi, I'm the LetsBe project assistant. Tell me about your project and I'll put together a personalized brief for you."
Your role is to walk through the following topics naturally in conversation:
1. What services they need (web, custom software, private infrastructure)
2. Whether they want AI integration — and if so, what kind (internal teammate, customer-facing, data intelligence, or not sure yet)
3. Their industry
4. Their timeline preference
5. Whether they have a current website (offer to analyze it if they do)
6. Their goals and project scope
7. Finally, their name and email to send the brief
Instructions:
- Call update_selections each time a data point is confirmed during the conversation.
- Call analyze_website as soon as the user provides a URL — then discuss the findings naturally.
- Call complete_brief once name and email are confirmed.
- Keep responses concise: 23 sentences maximum per turn.
- Be warm, direct, and professional — never generic.
Key facts about LetsBe to reference when relevant:
- Everything is custom-built from scratch — no templates, no page builders
- Private infrastructure: the client fully owns and controls their data and servers
- Small, experienced team with decades of combined expertise in design and engineering
- Deep AI integration into any type of system they build
- Data sovereignty and digital privacy as a core focus`;
}
// ─── Live Config ──────────────────────────────────────────────────────────────
export function buildLiveConfig(locale: string) {
return {
responseModalities: ['AUDIO'],
systemInstruction: buildSystemPrompt(locale),
tools: [{ functionDeclarations: AGENT_TOOLS }],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: 'Aoede' },
},
},
};
}
// ─── Ephemeral Token ──────────────────────────────────────────────────────────
export async function generateEphemeralToken(locale: string) {
// GoogleGenAI is instantiated here to validate the API key at request time.
// The SDK does not yet expose an ephemeral token API; in production, replace
// this with ai.auth.tokens.create() or equivalent when available to avoid
// exposing the API key to the client.
new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const config = buildLiveConfig(locale);
return { config, model: GEMINI_LIVE_MODEL };
}

316
src/lib/site-analysis.ts Normal file
View File

@@ -0,0 +1,316 @@
import * as cheerio from 'cheerio'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface TechStack {
cms: string | null
framework: string | null
ecommerce: string | null
analytics: string[]
hosting: string | null
}
export interface PerformanceMetrics {
score: number
fcp: number
lcp: number
cls: number
tbt: number
speedIndex: number
}
export interface SiteAnalysis {
url: string
fetchedAt: string
title: string | null
description: string | null
themeColor: string | null
primaryColors: string[]
headingStructure: { h1: string[]; h2: string[] }
navLinks: string[]
hasForms: boolean
techStack: TechStack | null
performance: PerformanceMetrics | null
fetchError: string | null
}
// ─── Internal result types ────────────────────────────────────────────────────
interface ParsedHtml {
title: string | null
description: string | null
themeColor: string | null
primaryColors: string[]
headingStructure: { h1: string[]; h2: string[] }
navLinks: string[]
hasForms: boolean
}
// ─── HTML parser ─────────────────────────────────────────────────────────────
function parseHtml(html: string): ParsedHtml {
const $ = cheerio.load(html)
const title = $('title').first().text().trim() || null
const description =
$('meta[name="description"]').attr('content')?.trim() ?? null
const themeColor =
$('meta[name="theme-color"]').attr('content')?.trim() ?? null
const colorPattern = /(#[0-9a-fA-F]{3,8})|rgb[a]?\(\s*\d[\d\s,./%]*\)/g
const colorSet = new Set<string>()
$('style').each((_, el) => {
const text = $(el).text()
const matches = text.match(colorPattern)
if (matches) matches.forEach(c => colorSet.add(c))
})
$('[style]').each((_, el) => {
const style = $(el).attr('style') ?? ''
const matches = style.match(colorPattern)
if (matches) matches.forEach(c => colorSet.add(c))
})
const primaryColors = [...colorSet].slice(0, 8)
const h1: string[] = []
$('h1').each((_, el) => {
if (h1.length < 3) h1.push($(el).text().trim())
})
const h2: string[] = []
$('h2').each((_, el) => {
if (h2.length < 3) h2.push($(el).text().trim())
})
const navLinks: string[] = []
$('nav').first().find('a').each((_, el) => {
const text = $(el).text().trim()
if (text && navLinks.length < 10) navLinks.push(text)
})
const hasForms = $('form').length > 0
return { title, description, themeColor, primaryColors, headingStructure: { h1, h2 }, navLinks, hasForms }
}
// ─── Tech stack detector ──────────────────────────────────────────────────────
function detectStack(html: string, headers: Record<string, string>): TechStack {
const h = html.toLowerCase()
const headerLower: Record<string, string> = {}
for (const [k, v] of Object.entries(headers)) {
headerLower[k.toLowerCase()] = v.toLowerCase()
}
// CMS
let cms: string | null = null
if (
h.includes('wp-content/') ||
(headerLower['x-powered-by']?.includes('php') && h.includes('wp-json'))
) {
cms = 'WordPress'
} else if (h.includes('cdn.shopify.com') || h.includes('shopify.theme')) {
cms = 'Shopify'
} else if (
h.includes('wixsite.com') ||
Object.keys(headerLower).some(k => k.includes('x-wix'))
) {
cms = 'Wix'
} else if (h.includes('static1.squarespace.com') || h.includes('squarespace-cdn')) {
cms = 'Squarespace'
} else if (h.includes('webflow.io') || h.includes('data-wf-site')) {
cms = 'Webflow'
} else if (h.includes('/media/jui/') || h.includes('joomla')) {
cms = 'Joomla'
} else if (
h.includes('/sites/default/files/') ||
headerLower['x-generator']?.includes('drupal')
) {
cms = 'Drupal'
} else if (h.includes('ghost.io') || h.includes('content="ghost')) {
cms = 'Ghost'
}
// Framework
let framework: string | null = null
if (h.includes('__next_data__') || h.includes('_next/static')) {
framework = 'Next.js'
} else if (h.includes('__nuxt__') || h.includes('_nuxt/')) {
framework = 'Nuxt'
} else if (!framework && (h.includes('data-reactroot') || h.includes('react-root'))) {
framework = 'React'
} else if (!framework && h.includes('data-v-')) {
framework = 'Vue'
} else if (h.includes('ng-version')) {
framework = 'Angular'
}
// Ecommerce
let ecommerce: string | null = null
if (h.includes('woocommerce')) {
ecommerce = 'WooCommerce'
} else if (h.includes('prestashop')) {
ecommerce = 'PrestaShop'
} else if (h.includes('mage.cookies') || h.includes('skin/frontend')) {
ecommerce = 'Magento'
}
// Analytics (collect all)
const analytics: string[] = []
if (h.includes('gtag') && /\/g-[a-z0-9]+\//i.test(html)) {
analytics.push('Google Analytics 4')
}
if (h.includes('googletagmanager.com')) {
analytics.push('Google Tag Manager')
}
if (h.includes('hotjar.com')) {
analytics.push('Hotjar')
}
if (h.includes('matomo.js') || h.includes('piwik.js')) {
analytics.push('Matomo')
}
if (h.includes('fbq(')) {
analytics.push('Facebook Pixel')
}
// Hosting
let hosting: string | null = null
if ('cf-ray' in headerLower) {
hosting = 'Cloudflare'
} else if (Object.keys(headerLower).some(k => k.startsWith('x-vercel'))) {
hosting = 'Vercel'
} else if ('x-nf-request-id' in headerLower) {
hosting = 'Netlify'
} else if (
'wpe-backend' in headerLower ||
headerLower['server']?.includes('wpe')
) {
hosting = 'WP Engine'
}
return { cms, framework, ecommerce, analytics, hosting }
}
// ─── PageSpeed fetcher ────────────────────────────────────────────────────────
async function fetchPageSpeed(url: string): Promise<PerformanceMetrics | null> {
try {
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile`
const res = await fetch(apiUrl)
const json = await res.json() as Record<string, unknown>
const lr = json['lighthouseResult'] as Record<string, unknown>
const categories = lr['categories'] as Record<string, Record<string, unknown>>
const audits = lr['audits'] as Record<string, Record<string, unknown>>
const score = Math.round((categories['performance']['score'] as number) * 100)
const fcp = audits['first-contentful-paint']['numericValue'] as number
const lcp = audits['largest-contentful-paint']['numericValue'] as number
const cls = audits['cumulative-layout-shift']['numericValue'] as number
const tbt = audits['total-blocking-time']['numericValue'] as number
const speedIndex = audits['speed-index']['numericValue'] as number
return { score, fcp, lcp, cls, tbt, speedIndex }
} catch {
return null
}
}
// ─── URL validation ───────────────────────────────────────────────────────────
function normalizeUrl(input: string): string {
const trimmed = input.trim()
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`
}
return trimmed
}
function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export async function analyzeSite(url: string): Promise<SiteAnalysis> {
const normalizedUrl = normalizeUrl(url)
const fetchedAt = new Date().toISOString()
const base: SiteAnalysis = {
url: normalizedUrl,
fetchedAt,
title: null,
description: null,
themeColor: null,
primaryColors: [],
headingStructure: { h1: [], h2: [] },
navLinks: [],
hasForms: false,
techStack: null,
performance: null,
fetchError: null,
}
if (!isHttpUrl(normalizedUrl)) {
return { ...base, fetchError: 'Invalid URL: only http and https schemes are supported.' }
}
let html: string
let headers: Record<string, string>
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(normalizedUrl, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SiteAnalyzer/1.0)' },
})
clearTimeout(timeout)
html = await response.text()
headers = {}
response.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ...base, fetchError: message }
}
const [htmlResult, stackResult, perfResult] = await Promise.allSettled([
Promise.resolve(parseHtml(html)),
Promise.resolve(detectStack(html, headers)),
fetchPageSpeed(normalizedUrl),
])
const parsed = htmlResult.status === 'fulfilled' ? htmlResult.value : null
const stack = stackResult.status === 'fulfilled' ? stackResult.value : null
const perf = perfResult.status === 'fulfilled' ? perfResult.value : null
return {
url: normalizedUrl,
fetchedAt,
title: parsed?.title ?? null,
description: parsed?.description ?? null,
themeColor: parsed?.themeColor ?? null,
primaryColors: parsed?.primaryColors ?? [],
headingStructure: parsed?.headingStructure ?? { h1: [], h2: [] },
navLinks: parsed?.navLinks ?? [],
hasForms: parsed?.hasForms ?? false,
techStack: stack,
performance: perf,
fetchError: null,
}
}