feat: website analysis pipeline, voice agent, configurator improvements
All checks were successful
Build & Push / build-and-push (push) Successful in 6m2s
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:
316
src/lib/site-analysis.ts
Normal file
316
src/lib/site-analysis.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user