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>
317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
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,
|
|
}
|
|
}
|