diff --git a/src/lib/audit/sources/crux.ts b/src/lib/audit/sources/crux.ts new file mode 100644 index 0000000..124041a --- /dev/null +++ b/src/lib/audit/sources/crux.ts @@ -0,0 +1,226 @@ +/** + * Chrome UX Report — i dati di UTENTI REALI, non di laboratorio. + * + * È la fonte più difendibile dell'audit: PageSpeed misura una singola + * esecuzione su una macchina Google con rete emulata, CrUX misura il p75 di 28 + * giorni di visite vere. Quando le due divergono, la divergenza *è* il + * risultato — e su giojello.com lo è stata: TTFB p75 2.876 ms con il 2% degli + * utenti nel verde, di cui 2.360 ms di sola attesa del server. + * + * CrUX degrada, e succede subito. Verificato il 2026-08-18: giojello.com ha + * dati a livello di origin ma risponde 404 su `formFactor: PHONE` — traffico + * mobile insufficiente. Il caso "nessun dato di campo" non è teorico, capita al + * primo sito vero: da qui la scala di ripiego qui sotto e la `nota` pronta da + * mettere nel documento, perché quel vuoto va DETTO, non lasciato in bianco. + */ +import { lista, numero, ramo, scaricaJson } from "./fetch"; + +const ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"; + +export type MetricaCampo = { + p75: number | null; + /** Percentuali di visite nelle tre fasce Core Web Vitals. Interi 0-100. */ + buono: number | null; + da_migliorare: number | null; + scarso: number | null; +}; + +export type CruxDati = { + disponibile: boolean; + /** `url` = questa pagina; `origin` = tutto il dominio. Non è la stessa cosa e va detto. */ + livello: "url" | "origin" | null; + /** `PHONE` = solo mobile; `tutti` = mobile+desktop+tablet aggregati. */ + form_factor: "PHONE" | "tutti" | null; + periodo: { da: string; a: string } | null; + metriche: { + lcp: MetricaCampo | null; + inp: MetricaCampo | null; + cls: MetricaCampo | null; + ttfb: MetricaCampo | null; + fcp: MetricaCampo | null; + }; + /** + * Frase pronta per il blocco 3, in italiano, sia quando i dati ci sono + * parzialmente sia quando mancano del tutto. Serve a impedire il buco: il + * documento deve saper dire "non ci sono abbastanza visitatori perché Google + * raccolga dati di campo", che è di per sé un'informazione sul sito. + */ + nota: string; + errore?: string; +}; + +const CHIAVI = { + lcp: "largest_contentful_paint", + inp: "interaction_to_next_paint", + cls: "cumulative_layout_shift", + ttfb: "experimental_time_to_first_byte", + fcp: "first_contentful_paint", +} as const; + +function estraiMetrica(metriche: unknown, chiave: string): MetricaCampo | null { + const m = ramo(metriche, chiave); + if (!m) return null; + + const p75 = numero(ramo(m, "percentiles", "p75")); + // L'istogramma ha sempre tre fasce nell'ordine buono / da migliorare / + // scarso, e le densità sono frazioni (0.02 = 2% delle visite). + const bins = lista(ramo(m, "histogram")); + const pct = (i: number) => { + const d = numero(ramo(bins[i], "density")); + return d == null ? null : Math.round(d * 100); + }; + + if (p75 == null && bins.length === 0) return null; + return { p75, buono: pct(0), da_migliorare: pct(1), scarso: pct(2) }; +} + +/** Un solo tentativo della scala. Il 404 non è un guasto: è "non ci sono dati". */ +async function interroga( + corpo: Record, + chiave: string +): Promise<{ record: unknown } | "assente" | { errore: string }> { + const r = await scaricaJson(`${ENDPOINT}?key=${encodeURIComponent(chiave)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo), + timeoutMs: 20_000, + tentativi: 2, + accetta: [404], + }); + if (!r.ok) return { errore: r.errore }; + if (r.dati.status === 404) return "assente"; + + const record = ramo(r.dati.json, "record"); + return record ? { record } : "assente"; +} + +function componiNota( + livello: "url" | "origin" | null, + ff: "PHONE" | "tutti" | null, + metriche: CruxDati["metriche"] +): string { + if (!livello) { + return "Google non raccoglie dati di campo per questo sito: i visitatori non sono abbastanza numerosi perché il campione sia statisticamente valido. Le rilevazioni qui sotto vengono quindi da una misurazione di laboratorio, non dall'esperienza reale degli utenti."; + } + + const parti: string[] = []; + parti.push( + livello === "url" + ? "I dati di campo si riferiscono a questa singola pagina." + : "I dati di campo si riferiscono all'intero dominio, non alla singola pagina: le visite su una sola pagina non bastano a formare un campione." + ); + if (ff === "tutti") { + parti.push( + "Non sono disponibili dati separati per il traffico da telefono — il campione mobile è troppo piccolo — quindi i valori aggregano telefono, tablet e desktop." + ); + } + const mancanti = (Object.keys(CHIAVI) as (keyof typeof CHIAVI)[]).filter( + (k) => !metriche[k] + ); + if (mancanti.length) { + parti.push(`Metriche senza dati sufficienti: ${mancanti.join(", ").toUpperCase()}.`); + } + return parti.join(" "); +} + +/** + * Scala di ripiego, dal dato più specifico al più generico. Fermarsi al primo + * livello che risponde è deliberato: un p75 di pagina vale più di un p75 di + * dominio, e un p75 mobile vale più di uno aggregato, ma un dato generico vale + * infinitamente più di nessun dato. + */ +export async function rilevaCrux(url: string): Promise { + const chiave = process.env.PAGESPEED_API_KEY; + const vuoto: CruxDati = { + disponibile: false, + livello: null, + form_factor: null, + periodo: null, + metriche: { lcp: null, inp: null, cls: null, ttfb: null, fcp: null }, + nota: componiNota(null, null, { lcp: null, inp: null, cls: null, ttfb: null, fcp: null }), + }; + + if (!chiave) { + return { ...vuoto, errore: "PAGESPEED_API_KEY non configurata (stessa chiave di PageSpeed)" }; + } + + let origin: string; + try { + origin = new URL(url).origin; + } catch { + return { ...vuoto, errore: `URL non valido: ${url}` }; + } + + const scala: { corpo: Record; livello: "url" | "origin"; ff: "PHONE" | "tutti" }[] = [ + { corpo: { url, formFactor: "PHONE" }, livello: "url", ff: "PHONE" }, + { corpo: { url }, livello: "url", ff: "tutti" }, + { corpo: { origin, formFactor: "PHONE" }, livello: "origin", ff: "PHONE" }, + { corpo: { origin }, livello: "origin", ff: "tutti" }, + ]; + + let ultimoErrore: string | undefined; + + for (const gradino of scala) { + const esito = await interroga(gradino.corpo, chiave); + if (esito === "assente") continue; + if ("errore" in esito) { + // Un guasto di rete su un gradino non deve impedire di provare il + // successivo: si tiene da parte e si prosegue. + ultimoErrore = esito.errore; + continue; + } + + const m = ramo(esito.record, "metrics"); + const metriche = { + lcp: estraiMetrica(m, CHIAVI.lcp), + inp: estraiMetrica(m, CHIAVI.inp), + cls: estraiMetrica(m, CHIAVI.cls), + ttfb: estraiMetrica(m, CHIAVI.ttfb), + fcp: estraiMetrica(m, CHIAVI.fcp), + }; + // Un record senza nemmeno una metrica leggibile equivale a non averlo. + if (!Object.values(metriche).some(Boolean)) continue; + + const da = ramo(esito.record, "collectionPeriod", "firstDate"); + const a = ramo(esito.record, "collectionPeriod", "lastDate"); + const data = (d: unknown) => { + const y = numero(ramo(d, "year")); + const mo = numero(ramo(d, "month")); + const g = numero(ramo(d, "day")); + return y && mo && g + ? `${y}-${String(mo).padStart(2, "0")}-${String(g).padStart(2, "0")}` + : null; + }; + const daS = data(da); + const aS = data(a); + + return { + disponibile: true, + livello: gradino.livello, + form_factor: gradino.ff, + periodo: daS && aS ? { da: daS, a: aS } : null, + metriche, + nota: componiNota(gradino.livello, gradino.ff, metriche), + }; + } + + return ultimoErrore ? { ...vuoto, errore: ultimoErrore } : vuoto; +} + +/** + * Le tre metriche che finiscono nelle colonne `*_field` di `audits`. + * Restano null quando il campo non c'è — e quel null è esso stesso un dato, + * non un buco da riempire con il valore di laboratorio. + */ +export function campiPersistibili(c: CruxDati): { + lcp_field: number | null; + inp_field: number | null; + cls_field: number | null; +} { + return { + // In `audits.lcp_field` il LCP sta in SECONDI (numeric 6,2), CrUX lo dà in ms. + lcp_field: c.metriche.lcp?.p75 != null ? c.metriche.lcp.p75 / 1000 : null, + inp_field: c.metriche.inp?.p75 ?? null, + cls_field: c.metriche.cls?.p75 ?? null, + }; +} diff --git a/src/lib/audit/sources/fetch.ts b/src/lib/audit/sources/fetch.ts new file mode 100644 index 0000000..2a4be20 --- /dev/null +++ b/src/lib/audit/sources/fetch.ts @@ -0,0 +1,447 @@ +/** + * Raccolta pagine + estrazione testo, e le utilità di rete condivise da tutte + * le altre fonti. + * + * Portato quasi invariato da `scripts/spike-audit.ts`: quel codice ha girato su + * un sito vero e ha prodotto un audit di buona qualità, quindi le euristiche di + * estrazione (struttura prima del testo, risoluzione delle schede WooCommerce + * dagli id `add-to-cart`) sono già state validate sul campo. + * + * Regola valida per TUTTE le fonti di `sources/`: nessuna deve poter uccidere + * la pipeline. Chi fallisce restituisce un risultato con `errore` valorizzato e + * il resto a null — mai un throw che risale fino all'orchestratore. + */ + +/** Presentarsi per quello che si è: è un audit commissionato, non uno scrape furtivo. */ +export const UA = "iamcavalli-audit/1.0 (+https://iamcavalli.net)"; + +const MAX_TESTO_PAGINA = 14_000; + +export type Pagina = { + url: string; + ruolo: string; + bytes: number; + nodi: number; + estratto: string; +}; + +export type Raccolta = { + /** URL canonico dopo i redirect: molti siti rimbalzano www↔non-www. */ + home: string; + homeHtml: string; + /** Header della risposta della home — li rilegge `signals.ts` senza riscaricare. */ + homeHeaders: Record; + pagine: Pagina[]; + /** Pagine interne che non si sono potute scaricare. Non è un errore fatale. */ + errori: string[]; +}; + +// ---------------------------------------------------------------- rete + +/** + * Fallimento non fatale: chi chiama riceve `null` e prosegue. Il messaggio + * dell'errore lo si tiene comunque, perché "la fonte non ha risposto" e "la + * fonte ha risposto che non ci sono dati" sono due cose diverse e il documento + * finale deve poterle distinguere. + */ +export type Esito = { ok: true; dati: T } | { ok: false; errore: string }; + +function messaggio(e: unknown): string { + if (e instanceof Error) { + return e.name === "TimeoutError" ? "timeout" : e.message; + } + return String(e); +} + +class RiprovabileError extends Error {} + +/** + * Ritenta solo su errori di rete e su 429/5xx: un 404 è una risposta, non un + * guasto, e ritentarlo sarebbe solo tempo perso su una pipeline che ha già + * 30-60 s di latenza per ogni chiamata PageSpeed. + */ +async function conRitentativi( + op: () => Promise, + tentativi: number, + attesaMs: number +): Promise { + let ultimo: unknown; + for (let i = 0; i < tentativi; i++) { + try { + return await op(); + } catch (e) { + ultimo = e; + // Un timeout va ritentato quanto un 5xx: Wayback e Observatory sono + // lenti a intermittenza, e rinunciare al primo scatto costa una fonte. + const riprovabile = + e instanceof RiprovabileError || (e instanceof Error && e.name === "TimeoutError"); + if (riprovabile && i < tentativi - 1) { + await new Promise((r) => setTimeout(r, attesaMs * 2 ** i)); + continue; + } + throw e; + } + } + throw ultimo; +} + +type OpzioniRete = { + timeoutMs?: number; + tentativi?: number; + headers?: Record; + method?: string; + body?: string; + /** Codici da NON trattare come errore: es. il 404 di CrUX, che è un dato. */ + accetta?: number[]; +}; + +async function richiesta( + url: string, + opts: OpzioniRete = {} +): Promise<{ status: number; testo: string; headers: Record; finale: string }> { + const { + timeoutMs = 20_000, + tentativi = 2, + headers = {}, + method = "GET", + body, + accetta = [], + } = opts; + + return conRitentativi( + async () => { + const res = await fetch(url, { + method, + body, + headers: { "User-Agent": UA, "Accept-Language": "it-IT,it;q=0.9", ...headers }, + redirect: "follow", + signal: AbortSignal.timeout(timeoutMs), + // Le fonti esterne non vanno nella cache di Next: un audit è una + // misurazione datata, e una risposta riusata falserebbe `measured_at`. + cache: "no-store", + }); + const testo = await res.text(); + if (!res.ok && !accetta.includes(res.status)) { + const err = + res.status === 429 || res.status >= 500 + ? new RiprovabileError(`HTTP ${res.status}`) + : new Error(`HTTP ${res.status}`); + throw err; + } + return { + status: res.status, + testo, + headers: Object.fromEntries(res.headers.entries()), + finale: res.url || url, + }; + }, + tentativi, + 1_000 + ); +} + +/** GET testuale. Rilancia: usato dove il fallimento è già gestito dal chiamante. */ +export async function scarica( + url: string, + opts: OpzioniRete = {} +): Promise<{ html: string; finale: string; headers: Record }> { + const r = await richiesta(url, opts); + return { html: r.testo, finale: r.finale, headers: r.headers }; +} + +/** GET testuale che non rilancia mai. */ +export async function scaricaSicuro(url: string, opts: OpzioniRete = {}): Promise> { + try { + const r = await richiesta(url, opts); + return { ok: true, dati: r.testo }; + } catch (e) { + return { ok: false, errore: messaggio(e) }; + } +} + +/** GET/POST JSON che non rilancia mai. `status` serve a chi tratta il 404 come dato. */ +export async function scaricaJson( + url: string, + opts: OpzioniRete = {} +): Promise> { + try { + const r = await richiesta(url, { + ...opts, + headers: { Accept: "application/json", ...opts.headers }, + }); + let json: unknown = null; + try { + json = JSON.parse(r.testo); + } catch { + // Alcuni endpoint (RDAP dietro proxy, Wayback in errore) rispondono HTML + // con status 200. Non è JSON valido: vale come fonte non disponibile. + return { ok: false, errore: "risposta non JSON" }; + } + return { ok: true, dati: { json, status: r.status } }; + } catch (e) { + return { ok: false, errore: messaggio(e) }; + } +} + +/** + * Fan-out con tetto. Le fonti aprono decine di richieste (figli di una sitemap, + * snapshot Wayback) e senza limite un audit su un sito grosso aprirebbe + * centinaia di socket su un VPS che ha 2 vCPU e 1,5 GB liberi. + */ +export async function conLimite( + elementi: I[], + limite: number, + fn: (e: I, i: number) => Promise +): Promise { + const out: O[] = new Array(elementi.length); + let cursore = 0; + const operai = Array.from({ length: Math.min(limite, elementi.length) }, async () => { + while (cursore < elementi.length) { + const i = cursore++; + out[i] = await fn(elementi[i], i); + } + }); + await Promise.all(operai); + return out; +} + +// ------------------------------------------------- navigazione JSON difensiva + +/** Cammina un JSON di forma ignota senza mai lanciare. */ +export function ramo(o: unknown, ...chiavi: (string | number)[]): unknown { + let cur: unknown = o; + for (const k of chiavi) { + if (cur == null || typeof cur !== "object") return null; + cur = (cur as Record)[k]; + } + return cur ?? null; +} + +export function numero(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = Number(v.replace(",", ".")); + return Number.isFinite(n) ? n : null; + } + return null; +} + +export function stringa(v: unknown): string | null { + return typeof v === "string" && v.trim() ? v.trim() : null; +} + +export function lista(v: unknown): unknown[] { + return Array.isArray(v) ? v : []; +} + +// ---------------------------------------------------------------- estrazione + +export function pulisci(html: string): string { + return html + .replace(//g, " ") + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/]*>[\s\S]*?<\/noscript>/gi, " "); +} + +export function decodifica(s: string): string { + const m: Record = { + amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", + egrave: "è", eacute: "é", agrave: "à", ograve: "ò", ugrave: "ù", igrave: "ì", + euro: "€", hellip: "…", ndash: "–", mdash: "—", laquo: "«", raquo: "»", + }; + return s + .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(+d)) + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16))) + .replace(/&([a-z]+);/gi, (t, n) => m[n.toLowerCase()] ?? t); +} + +export function tag(html: string, re: RegExp, max: number): string[] { + const out: string[] = []; + for (const m of html.matchAll(re)) { + const t = decodifica(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); + if (t && !out.includes(t)) out.push(t); + if (out.length >= max) break; + } + return out; +} + +export function meta(html: string, nome: string): string | null { + const re = new RegExp( + `]+(?:name|property)=["']${nome}["'][^>]*content=["']([^"']*)["']`, "i"); + const alt = new RegExp( + `]+content=["']([^"']*)["'][^>]*(?:name|property)=["']${nome}["']`, "i"); + const m = html.match(re) ?? html.match(alt); + return m ? decodifica(m[1]).trim() : null; +} + +/** Testo visibile, per i confronti storici dove la struttura non serve. */ +export function testoVisibile(html: string, max = 1_200): string { + return decodifica(pulisci(html).replace(/<[^>]+>/g, " ")) + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +/** + * Riduce una pagina a una rappresentazione compatta ma fedele: struttura + * (titoli, link, bottoni, form, immagini) + testo visibile. La struttura viene + * PRIMA del testo perché è ciò su cui verte la maggior parte della checklist. + */ +export function estraiPagina(html: string, url: string, ruolo: string): Pagina { + const bytes = Buffer.byteLength(html, "utf8"); + const nodi = (html.match(/<[a-zA-Z][^>]*>/g) ?? []).length; + const c = pulisci(html); + + const titolo = tag(c, /]*>([\s\S]*?)<\/title>/gi, 1)[0] ?? "(assente)"; + const desc = meta(html, "description"); + const robots = meta(html, "robots"); + const h1 = tag(c, /]*>([\s\S]*?)<\/h1>/gi, 6); + const h2 = tag(c, /]*>([\s\S]*?)<\/h2>/gi, 25); + const h3 = tag(c, /]*>([\s\S]*?)<\/h3>/gi, 30); + + const bottoni = [ + ...tag(c, /]*>([\s\S]*?)<\/button>/gi, 30), + ...[...c.matchAll(/]+type=["'](?:submit|button)["'][^>]*value=["']([^"']+)["']/gi)] + .map((m) => decodifica(m[1])), + ]; + + const link = [...c.matchAll(/]+href=["']([^"'#]+)["'][^>]*>([\s\S]*?)<\/a>/gi)] + .map((m) => decodifica(m[2].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim()) + .filter(Boolean); + + const imgs = [...c.matchAll(/]*>/gi)].map((m) => m[0]); + const senzaAlt = imgs.filter((i) => !/\balt=["'][^"']+["']/i.test(i)).length; + const lazy = imgs.filter((i) => /loading=["']lazy["']/i.test(i)).length; + + const campi = [...c.matchAll(/<(input|select|textarea)\b[^>]*>/gi)] + .map((m) => { + const t = m[0].match(/type=["']([^"']+)["']/i)?.[1] ?? m[1].toLowerCase(); + const n = m[0].match(/name=["']([^"']+)["']/i)?.[1] ?? ""; + return `${t}${n ? `[${n}]` : ""}`; + }) + .filter((x) => !/hidden/.test(x)); + + const estratto = [ + `URL: ${url}`, + `TITLE: ${titolo}`, + `META DESCRIPTION: ${desc ?? "(ASSENTE)"}`, + `META ROBOTS: ${robots ?? "(assente)"}`, + `PESO HTML: ${(bytes / 1024).toFixed(0)} KB · NODI (approx): ${nodi}`, + `IMMAGINI: ${imgs.length} totali, ${senzaAlt} senza alt, ${lazy} con lazy-load`, + `CAMPI FORM: ${campi.length ? campi.slice(0, 30).join(", ") : "(nessuno)"}`, + ``, + `H1: ${h1.join(" | ") || "(NESSUN H1)"}`, + `H2: ${h2.join(" | ") || "—"}`, + `H3: ${h3.join(" | ") || "—"}`, + ``, + `BOTTONI/CTA: ${bottoni.length ? [...new Set(bottoni)].slice(0, 25).join(" | ") : "(nessuno rilevato)"}`, + `TESTI DEI LINK: ${[...new Set(link)].slice(0, 60).join(" | ")}`, + ``, + `TESTO VISIBILE:`, + decodifica(c.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim().slice(0, MAX_TESTO_PAGINA), + ].join("\n"); + + return { url, ruolo, bytes, nodi, estratto }; +} + +/** + * Il contenuto scaricato da un sito terzo è DATI, mai istruzioni. `fence()` + * neutralizza i tag di chiusura così il materiale non può uscire dal recinto + * che il prompt di sistema dichiara essere dati. + */ +export function fence(p: Pagina): string { + const safe = p.estratto.replace(/<\/?pagina\b[^>]*>/gi, "[tag rimosso]"); + return `\n${safe}\n`; +} + +// ---------------------------------------------------------------- selezione + +function linkInterni(html: string, base: string): string[] { + const origin = new URL(base).origin; + return [...pulisci(html).matchAll(/]+href=["']([^"'#]+)["']/gi)] + .map((m) => { + try { return new URL(m[1].replace(/&/g, "&"), base).toString(); } catch { return null; } + }) + .filter((u): u is string => !!u && u.startsWith(origin)); +} + +/** + * WooCommerce: nelle griglie il permalink del prodotto spesso non compare come + * ancora — c'è solo `?add-to-cart=ID`. Da quell'id WordPress risolve il + * permalink via `/?p=ID`, che è il modo più affidabile per arrivare a una + * scheda prodotto reale senza indovinare la forma degli URL. + */ +async function schedaDaAddToCart(html: string, base: string): Promise { + const id = html.match(/[?&]add-to-cart=(\d+)/i)?.[1]; + if (!id) return null; + try { + const { finale } = await scarica(new URL(`/?p=${id}`, base).toString(), { tentativi: 1 }); + return /\/\?p=\d+$/.test(finale) ? null : finale; + } catch { + return null; + } +} + +/** Sceglie fino a 3 pagine interne rappresentative oltre alla home. */ +async function scegliPagine( + html: string, + base: string, + profilo: string +): Promise<{ url: string; ruolo: string }[]> { + const hrefs = linkInterni(html, base); + const scelte: { url: string; ruolo: string }[] = []; + + const prendi = (ruolo: string, re: RegExp) => { + const u = hrefs.find((h) => re.test(h) && !scelte.some((s) => s.url === h)); + if (u) scelte.push({ url: u, ruolo }); + return u; + }; + + if (profilo === "ecommerce") { + const cat = prendi("pagina categoria", + /\/(categoria|category|categoria-prodotto|product-category|shop|negozio)\//i); + prendi("carrello", /\/(carrello|cart)\/?$/i); + + // La scheda si cerca prima nei link diretti, poi — se il tema non li espone — + // partendo dagli id add-to-cart della home o della categoria. + if (!prendi("scheda prodotto", /\/(prodotto|product)\//i)) { + let da = html; + if (cat) { try { da = (await scarica(cat, { tentativi: 1 })).html; } catch { /* resta la home */ } } + const u = (await schedaDaAddToCart(da, base)) ?? (await schedaDaAddToCart(html, base)); + if (u) scelte.push({ url: u, ruolo: "scheda prodotto" }); + } + } else { + prendi("pagina servizi", /\/(servizi|services|cosa-facciamo|offerta|soluzioni)\//i); + prendi("chi siamo", /\/(chi-siamo|about|about-us|studio)\/?/i); + prendi("contatti", /\/(contatti|contact|prenota|book|call)\/?/i); + } + return scelte; +} + +/** + * Punto di ingresso della fonte: home + fino a 3 pagine interne per profilo. + * + * A differenza delle altre fonti questa PUÒ fallire in modo fatale: se la home + * non risponde non c'è audit da fare, e proseguire produrrebbe un documento + * scritto sul nulla. Le pagine interne invece si saltano e basta. + */ +export async function raccogliPagine(url: string, profilo: string): Promise { + const { html: homeHtml, finale: home, headers: homeHeaders } = await scarica(url, { + timeoutMs: 30_000, + tentativi: 3, + }); + + const pagine: Pagina[] = [estraiPagina(homeHtml, home, "home")]; + const errori: string[] = []; + + for (const p of await scegliPagine(homeHtml, home, profilo)) { + try { + pagine.push(estraiPagina((await scarica(p.url, { tentativi: 1 })).html, p.url, p.ruolo)); + } catch (e) { + errori.push(`${p.ruolo} (${p.url}): ${messaggio(e)}`); + } + } + + return { home, homeHtml, homeHeaders, pagine, errori }; +} diff --git a/src/lib/audit/sources/history.ts b/src/lib/audit/sources/history.ts new file mode 100644 index 0000000..4013daf --- /dev/null +++ b/src/lib/audit/sources/history.ts @@ -0,0 +1,236 @@ +/** + * Wayback Machine — da quanto tempo il sito è fermo. + * + * È la fonte che regge la tesi commerciale dell'intero servizio: *l'azienda è + * cresciuta, il sito no*. Un imprenditore può discutere un punteggio Lighthouse; + * non può discutere il fatto che l'headline della sua home sia la stessa del + * 2021 mentre nel frattempo ha triplicato il catalogo. + * + * Wayback è lento e ballerino: ogni pezzo qui dentro fallisce in modo non + * fatale e restituisce quello che è riuscito a raccogliere. + */ +import { + conLimite, + decodifica, + lista, + meta, + pulisci, + scaricaSicuro, + scaricaJson, + tag, + testoVisibile, +} from "./fetch"; + +/** Quanto indietro si guarda. Tre punti bastano a mostrare una linea piatta. */ +const TRAGUARDI_ANNI = [1, 3, 5]; + +export type Istantanea = { + anni_fa: number; + /** Data effettiva dello snapshot trovato, che può discostarsi dal traguardo. */ + data: string; + url_archivio: string; + titolo: string | null; + h1: string[]; + descrizione: string | null; + estratto: string; + errore?: string; +}; + +export type StoricoDati = { + disponibile: boolean; + primo_snapshot: string | null; + ultimo_snapshot: string | null; + /** Snapshot MENSILI distinti, non il totale: l'indice è collassato per mese. + * Chiamarlo "totale" sarebbe un numero non misurato. */ + snapshot_mensili: number; + istantanee: Istantanea[]; + /** Il confronto con la home di oggi — il cuore della fonte. */ + confronto: { + titolo_invariato: boolean | null; + h1_invariato: boolean | null; + /** Da quando il titolo/H1 risultano identici, fra gli snapshot esaminati. */ + invariato_da: string | null; + }; + nota: string; + errore?: string; +}; + +function daTimestamp(ts: string): Date | null { + const m = ts.match(/^(\d{4})(\d{2})(\d{2})/); + if (!m) return null; + const d = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`); + return Number.isNaN(d.getTime()) ? null : d; +} + +function iso(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Normalizza per il confronto: le differenze di spaziatura e maiuscole non contano. */ +function normalizza(s: string | null | undefined): string { + return (s ?? "").toLowerCase().replace(/\s+/g, " ").replace(/[^\p{L}\p{N} ]/gu, "").trim(); +} + +function estraiDaSnapshot(html: string) { + const c = pulisci(html); + return { + titolo: tag(c, /]*>([\s\S]*?)<\/title>/gi, 1)[0] ?? null, + h1: tag(c, /]*>([\s\S]*?)<\/h1>/gi, 4), + descrizione: meta(html, "description"), + estratto: testoVisibile(html, 1_200), + }; +} + +export async function rilevaStorico(url: string, homeHtml: string): Promise { + const vuoto: StoricoDati = { + disponibile: false, + primo_snapshot: null, + ultimo_snapshot: null, + snapshot_mensili: 0, + istantanee: [], + confronto: { titolo_invariato: null, h1_invariato: null, invariato_da: null }, + nota: "Non è stato possibile ricostruire lo storico del sito dagli archivi pubblici.", + }; + + let host: string; + try { + host = new URL(url).host; + } catch { + return { ...vuoto, errore: `URL non valido: ${url}` }; + } + + // Un indice collassato per mese: abbastanza fitto da trovare uno snapshot + // vicino a ogni traguardo, abbastanza corto da non scaricare un elenco di + // decine di migliaia di righe per un sito vecchio. + const cdx = new URL("https://web.archive.org/cdx/search/cdx"); + cdx.searchParams.set("url", `${host}/`); + cdx.searchParams.set("output", "json"); + cdx.searchParams.set("fl", "timestamp,original,statuscode"); + cdx.searchParams.set("filter", "statuscode:200"); + cdx.searchParams.set("collapse", "timestamp:6"); + cdx.searchParams.set("limit", "600"); + + // 60 s: l'indice CDX su un dominio con anni di storia è lento per costruzione, + // e a 30 s giojello.com andava regolarmente in timeout (misurato 2026-08-18). + const r = await scaricaJson(cdx.toString(), { timeoutMs: 60_000, tentativi: 2 }); + if (!r.ok) return { ...vuoto, errore: `Wayback CDX: ${r.errore}` }; + + // Prima riga = intestazione. Un indice con la sola intestazione significa + // sito mai archiviato: è un'informazione, non un errore. + const righe = lista(r.dati.json) + .slice(1) + .map((x) => (Array.isArray(x) ? String(x[0] ?? "") : "")) + .filter(Boolean); + + if (!righe.length) { + return { + ...vuoto, + nota: "Il sito non risulta archiviato dalla Wayback Machine: non è possibile confrontarlo con le sue versioni precedenti.", + }; + } + + const date = righe + .map((ts) => ({ ts, d: daTimestamp(ts) })) + .filter((x): x is { ts: string; d: Date } => x.d != null) + .sort((a, b) => a.d.getTime() - b.d.getTime()); + + if (!date.length) return { ...vuoto, errore: "timestamp Wayback illeggibili" }; + + const primo = date[0]; + const ultimo = date[date.length - 1]; + const ora = Date.now(); + + // Per ogni traguardo, lo snapshot più vicino nel tempo — e solo se il sito + // esisteva già: chiedere "com'era 5 anni fa" a un dominio di 2 anni non ha + // senso e produrrebbe tre volte lo stesso snapshot. + const traguardi = TRAGUARDI_ANNI.map((anni) => { + const bersaglio = ora - anni * 365.25 * 24 * 3600 * 1000; + if (primo.d.getTime() > bersaglio) return null; + const scelto = date.reduce((a, b) => + Math.abs(b.d.getTime() - bersaglio) < Math.abs(a.d.getTime() - bersaglio) ? b : a + ); + return { anni, ...scelto }; + }).filter((x): x is { anni: number; ts: string; d: Date } => x != null); + + // Deduplica: due traguardi possono cadere sullo stesso snapshot su un sito + // archiviato di rado. + const unici = traguardi.filter( + (t, i) => traguardi.findIndex((x) => x.ts === t.ts) === i + ); + + const istantanee = await conLimite(unici, 2, async (t): Promise => { + // Il suffisso `id_` restituisce il documento originale, senza la barra di + // navigazione che l'archivio inietta e che sporcherebbe l'estrazione. + const archivio = `https://web.archive.org/web/${t.ts}id_/${url}`; + const res = await scaricaSicuro(archivio, { timeoutMs: 30_000, tentativi: 2 }); + if (!res.ok) { + return { + anni_fa: t.anni, + data: iso(t.d), + url_archivio: archivio, + titolo: null, + h1: [], + descrizione: null, + estratto: "", + errore: res.errore, + }; + } + return { anni_fa: t.anni, data: iso(t.d), url_archivio: archivio, ...estraiDaSnapshot(res.dati) }; + }); + + // Confronto con la home di oggi, dalla più vecchia leggibile: è la data che + // rende la frase forte ("l'headline è la stessa dal 2021"). + const oggi = estraiDaSnapshot(homeHtml); + const leggibili = istantanee + .filter((i) => !i.errore && (i.titolo || i.h1.length)) + .sort((a, b) => b.anni_fa - a.anni_fa); + + let titolo_invariato: boolean | null = null; + let h1_invariato: boolean | null = null; + let invariato_da: string | null = null; + + for (const i of leggibili) { + const t = i.titolo ? normalizza(i.titolo) === normalizza(oggi.titolo) : null; + const h = i.h1.length ? normalizza(i.h1[0]) === normalizza(oggi.h1[0]) : null; + if (titolo_invariato === null) titolo_invariato = t; + if (h1_invariato === null) h1_invariato = h; + if ((t || h) && invariato_da === null) invariato_da = i.data; + if (t === false && h === false) break; + } + + const anni = Math.floor((ora - primo.d.getTime()) / (365.25 * 24 * 3600 * 1000)); + const parti = [ + `Il sito è archiviato dal ${iso(primo.d)}${anni >= 1 ? ` (${anni} anni)` : ""}, con ${date.length} rilevazioni mensili distinte fino al ${iso(ultimo.d)}.`, + ]; + if (invariato_da) { + parti.push( + `Il testo principale della home risulta invariato almeno dal ${invariato_da}.` + ); + } else if (leggibili.length) { + parti.push("Il testo principale della home è cambiato rispetto alle versioni archiviate."); + } + + return { + disponibile: true, + primo_snapshot: iso(primo.d), + ultimo_snapshot: iso(ultimo.d), + snapshot_mensili: date.length, + istantanee, + confronto: { titolo_invariato, h1_invariato, invariato_da }, + nota: parti.join(" "), + }; +} + +/** Riutilizzata dal sub-agent storico per fenceare gli estratti d'archivio. */ +export function fenceIstantanea(i: Istantanea): string { + const safe = [ + `TITLE: ${i.titolo ?? "(assente)"}`, + `H1: ${i.h1.join(" | ") || "(nessuno)"}`, + `META DESCRIPTION: ${i.descrizione ?? "(assente)"}`, + ``, + decodifica(i.estratto), + ] + .join("\n") + .replace(/<\/?archivio\b[^>]*>/gi, "[tag rimosso]"); + return `\n${safe}\n`; +} diff --git a/src/lib/audit/sources/pagespeed.ts b/src/lib/audit/sources/pagespeed.ts new file mode 100644 index 0000000..bcb1a12 --- /dev/null +++ b/src/lib/audit/sources/pagespeed.ts @@ -0,0 +1,427 @@ +/** + * PageSpeed Insights v5 — la fonte più ricca della pipeline. + * + * Lo spike chiamava questa stessa API e ne estraeva DIECI numeri. Il resto + * finiva nel cestino: ~150 audit Lighthouse eseguiti sul DOM **renderizzato**, + * cioè esattamente ciò che l'HTML statico non può vedere. È da lì che si + * recupera una fetta del 52% di voci di checklist "non verificabili" misurato + * sullo spike — `color-contrast`, `target-size`, `unsized-images`, + * `heading-order`, `errors-in-console` sono osservazioni visive e a runtime. + * + * Serve `PAGESPEED_API_KEY`: senza chiave l'API usa una quota anonima condivisa + * che risponde 429 quasi sempre (verificato il 2026-08-16 — lo spike girò con + * `psi: {}`, cioè zero rilevazioni). + */ +import { conLimite, lista, numero, ramo, scaricaJson, stringa } from "./fetch"; + +export type Strategia = "mobile" | "desktop"; + +export type AuditLighthouse = { + id: string; + titolo: string; + /** Perché è un problema — la spiegazione di Lighthouse, già in italiano se locale=it. */ + descrizione: string; + punteggio: number | null; + valore: string | null; + /** Fino a 5 elementi concreti (selettore/URL), per dare al modello un appiglio verificabile. */ + elementi: string[]; +}; + +export type Screenshot = { + /** `image/jpeg` o `image/webp` — Lighthouse cambia formato fra versioni. */ + mime: string; + base64: string; + larghezza: number | null; + altezza: number | null; +}; + +export type PagespeedDati = { + strategia: Strategia; + url_analizzato: string | null; + punteggi: { + performance: number | null; + accessibilita: number | null; + seo: number | null; + best_practices: number | null; + }; + /** Millisecondi, tranne CLS (adimensionale) e peso (KB). Numeri, non stringhe: + * le stringhe tipo "2,4 s" il modello le ricopia, i numeri li può incrociare. */ + metriche: { + lcp_ms: number | null; + fcp_ms: number | null; + cls: number | null; + tbt_ms: number | null; + speed_index_ms: number | null; + /** + * Tempo di risposta del server misurato DAL DATACENTER DI GOOGLE. Non è il + * TTFB degli utenti e non va confuso con `crux.metriche.ttfb`: su + * giojello.com questo dà 7-17 ms mentre il campo dà 3.553 ms di p75 con + * l'1% di visite nel verde. La divergenza non è un errore di misura — è il + * risultato: il server risponde in fretta a chi è vicino e lento a tutti + * gli altri. Il nome è esplicito apposta, perché il sintetizzatore non + * tratti i due numeri come lo stesso numero. + */ + risposta_server_ms: number | null; + peso_kb: number | null; + richieste: number | null; + }; + /** + * Le quattro sottoparti dell'LCP: attesa del server, ritardo nel trovare la + * risorsa, tempo di scaricamento, ritardo di rendering. È il dato che + * cambia la diagnosi: su giojello.com mobile 1.253 ms su 2.361 sono ritardo + * nel *trovare* l'immagine e 1.005 ms sono rendering — comprimere le foto, + * l'intervento istintivo, non toccherebbe nessuno dei due. + */ + fasi_lcp: { fase: string; ms: number | null; percento: number | null }[]; + falliti: AuditLighthouse[]; + /** + * Voci sempre presenti, passate o no: la checklist le interroga per id, ed è + * qui che si recupera una fetta del 52% di voci "non verificabili" misurato + * sullo spike — sapere che `image-alt` vale 1 chiude una voce come conforme + * invece di lasciarla in sospeso. + * + * `modo` è indispensabile: un `punteggio: null` con modo `notApplicable` + * significa "non si applica a questa pagina", con modo `manual` significa + * "Lighthouse non lo verifica da solo". Senza il modo entrambi si + * leggerebbero come "dato mancante", che è una terza cosa ancora. + */ + per_id: Record< + string, + { punteggio: number | null; valore: string | null; modo: string | null } + >; + audit_totali: number; + /** + * La viewport renderizzata, NON la pagina intera. + * + * Misurato il 2026-08-18 su giojello.com: `fullPageScreenshot` esiste ma è + * 412×7906 px. Claude ridimensiona il lato lungo a ~1568 px, quindi + * arriverebbe largo ~82 px — illeggibile. `final-screenshot` è la sola + * viewport, cioè di fatto l'hero: che è comunque il blocco da cui venivano le + * osservazioni migliori dell'audit manuale. Affettare la pagina intera + * richiede `sharp`, che oggi non è fra le dipendenze. + */ + screenshot: Screenshot | null; + /** Dimensioni del full-page, tenute solo per sapere quando `sharp` varrà la pena. */ + fullpage_px: { larghezza: number | null; altezza: number | null } | null; + errore?: string; +}; + +/** + * Voci che la checklist interroga per id anche quando passano: sapere che + * `image-alt` è a 1 chiude la voce come conforme invece di lasciarla + * "non verificabile", ed è metà del guadagno di questa fonte. + */ +const RILEVANTI = [ + "color-contrast", "target-size", "tap-targets", "font-size", "image-alt", + "link-text", "crawlable-anchors", "structured-data", "unsized-images", + "errors-in-console", "viewport", "canonical", "hreflang", "document-title", + "meta-description", "http-status-code", "is-crawlable", "robots-txt", + "heading-order", "html-has-lang", "label", "button-name", "link-name", + "uses-responsive-images", "modern-image-formats", "uses-text-compression", + "server-response-time", "render-blocking-resources", "total-byte-weight", + "third-party-summary", "legacy-javascript", "redirects", +] as const; + +/** Fuori dal conteggio dei "falliti": non sono verdetti. */ +const NON_VERDETTI = new Set(["notApplicable", "manual", "informative", "error"]); + +/** + * Audit puramente descrittivi che Lighthouse pubblica come `metricSavings`, e + * che quindi passerebbero il filtro dei verdetti pur non essendo difetti. + * `lcp-breakdown-insight` in particolare è la scomposizione dell'LCP, che + * estraiamo a parte: lasciarlo anche fra i problemi lo farebbe contare due + * volte e occuperebbe uno dei dieci posti del documento con una tautologia. + */ +const SOLO_DIAGNOSTICI = new Set([ + "lcp-breakdown-insight", + "largest-contentful-paint-element", + "network-requests", + "third-party-summary", + "resource-summary", + "diagnostics", + "screenshot-thumbnails", + "final-screenshot", + "full-page-screenshot", + "valid-source-maps", +]); + +/** + * Dimensioni reali dell'immagine, lette dai suoi stessi byte. + * + * `configSettings.screenEmulation` non è presente nelle risposte dell'API + * pubblica (verificato il 2026-08-18: la risposta contiene solo formFactor e + * locale), quindi le dimensioni non si possono dedurre dalla configurazione. + * E servono davvero: la sola cosa che rende utilizzabile uno screenshot è la + * sua proporzione, ed è per una proporzione sbagliata — 412×7906 — che il + * full-page è stato scartato. + */ +function dimensioniImmagine(buf: Buffer): { larghezza: number | null; altezza: number | null } { + // JPEG: si scorrono i marker fino a un SOF, dove precisione/altezza/larghezza + // stanno nei 5 byte dopo la lunghezza del segmento. + if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8) { + let i = 2; + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) { i++; continue; } + const marker = buf[i + 1]; + const len = buf.readUInt16BE(i + 2); + const sof = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + if (sof) { + return { altezza: buf.readUInt16BE(i + 5), larghezza: buf.readUInt16BE(i + 7) }; + } + if (len < 2) break; + i += 2 + len; + } + return { larghezza: null, altezza: null }; + } + + // WebP: Lighthouse ha già cambiato formato una volta, quindi vale coprirlo. + if (buf.length > 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") { + const tipo = buf.toString("ascii", 12, 16); + if (tipo === "VP8X") { + return { + larghezza: buf.readUIntLE(24, 3) + 1, + altezza: buf.readUIntLE(27, 3) + 1, + }; + } + if (tipo === "VP8 ") { + return { + larghezza: buf.readUInt16LE(26) & 0x3fff, + altezza: buf.readUInt16LE(28) & 0x3fff, + }; + } + if (tipo === "VP8L") { + const b = buf.readUInt32LE(21); + return { larghezza: (b & 0x3fff) + 1, altezza: ((b >> 14) & 0x3fff) + 1 }; + } + } + + return { larghezza: null, altezza: null }; +} + +function estraiElementi(dettagli: unknown): string[] { + const items = lista(ramo(dettagli, "items")); + const out: string[] = []; + for (const it of items.slice(0, 5)) { + const s = + stringa(ramo(it, "node", "selector")) ?? + stringa(ramo(it, "node", "snippet")) ?? + stringa(ramo(it, "url")) ?? + stringa(ramo(it, "source", "url")) ?? + stringa(ramo(it, "entity")); + if (s) out.push(s.slice(0, 200)); + } + return out; +} + +function estraiFasiLcp(audits: unknown): PagespeedDati["fasi_lcp"] { + // La forma è cambiata fra versioni di Lighthouse: la vecchia + // `largest-contentful-paint-element` esponeva `phase`/`timing`/`percent`, la + // nuova `lcp-breakdown-insight` espone `subpart`/`label`/`duration` e NON dà + // le percentuali. Verificato il 2026-08-18: l'API pubblica serve solo la + // nuova, e il codice che cercava `phase` restituiva un array vuoto. Si + // accettano entrambe e la percentuale, quando manca, si calcola. + for (const chiave of ["lcp-breakdown-insight", "largest-contentful-paint-element"]) { + const gruppi = lista(ramo(audits, chiave, "details", "items")); + for (const gruppo of gruppi) { + const grezze = lista(ramo(gruppo, "items")) + .map((x) => ({ + fase: + stringa(ramo(x, "label")) ?? + stringa(ramo(x, "subpart")) ?? + stringa(ramo(x, "phase")), + ms: numero(ramo(x, "duration")) ?? numero(ramo(x, "timing")), + // `percent`, quando c'è, arriva come "13%" — stringa, non numero. + percentoGrezzo: ramo(x, "percent"), + })) + .filter((x) => x.fase != null && x.ms != null); + + if (!grezze.length) continue; + + const totale = grezze.reduce((s, x) => s + (x.ms ?? 0), 0); + return grezze.map((x) => { + const p = x.percentoGrezzo; + const dichiarata = numero(typeof p === "string" ? p.replace("%", "") : p); + return { + fase: x.fase as string, + ms: x.ms, + percento: + dichiarata ?? (totale > 0 ? Math.round(((x.ms ?? 0) / totale) * 100) : null), + }; + }); + } + } + return []; +} + +function estraiScreenshot(lh: unknown): { + screenshot: Screenshot | null; + fullpage: PagespeedDati["fullpage_px"]; +} { + const full = ramo(lh, "fullPageScreenshot", "screenshot"); + const fullpage = full + ? { larghezza: numero(ramo(full, "width")), altezza: numero(ramo(full, "height")) } + : null; + + const data = stringa(ramo(lh, "audits", "final-screenshot", "details", "data")); + if (!data) return { screenshot: null, fullpage }; + + const m = data.match(/^data:([^;]+);base64,(.+)$/); + if (!m) return { screenshot: null, fullpage }; + + return { + screenshot: { + mime: m[1], + base64: m[2], + ...dimensioniImmagine(Buffer.from(m[2], "base64")), + }, + fullpage, + }; +} + +function vuoto(strategia: Strategia, errore: string): PagespeedDati { + return { + strategia, + url_analizzato: null, + punteggi: { performance: null, accessibilita: null, seo: null, best_practices: null }, + metriche: { + lcp_ms: null, fcp_ms: null, cls: null, tbt_ms: null, + speed_index_ms: null, risposta_server_ms: null, peso_kb: null, richieste: null, + }, + fasi_lcp: [], + falliti: [], + per_id: {}, + audit_totali: 0, + screenshot: null, + fullpage_px: null, + errore, + }; +} + +export async function rilevaPagespeed( + url: string, + strategia: Strategia +): Promise { + const chiave = process.env.PAGESPEED_API_KEY; + if (!chiave) { + return vuoto(strategia, "PAGESPEED_API_KEY non configurata"); + } + + const api = new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed"); + api.searchParams.set("url", url); + api.searchParams.set("strategy", strategia); + api.searchParams.set("key", chiave); + // Le descrizioni degli audit arrivano localizzate: finiscono nel prompt dei + // sub-agent, e un prompt tutto in italiano riduce le derive di lingua. + api.searchParams.set("locale", "it"); + for (const c of ["performance", "accessibility", "seo", "best-practices"]) { + api.searchParams.append("category", c); + } + + // Fino a 60 s per strategia: è latenza normale per questa API, non un guasto. + const r = await scaricaJson(api.toString(), { timeoutMs: 120_000, tentativi: 2 }); + if (!r.ok) { + return vuoto( + strategia, + r.errore === "HTTP 429" + ? "quota PageSpeed esaurita — la chiave è valida ma ha superato il limite" + : r.errore + ); + } + + const lh = ramo(r.dati.json, "lighthouseResult"); + if (!lh) { + const msg = stringa(ramo(r.dati.json, "error", "message")); + return vuoto(strategia, msg ?? "risposta senza lighthouseResult"); + } + + const audits = ramo(lh, "audits") as Record | null; + const cat = ramo(lh, "categories"); + const pct = (k: string) => { + const s = numero(ramo(cat, k, "score")); + return s == null ? null : Math.round(s * 100); + }; + const val = (k: string) => numero(ramo(audits, k, "numericValue")); + + const falliti: AuditLighthouse[] = []; + const per_id: PagespeedDati["per_id"] = {}; + let audit_totali = 0; + + for (const [id, a] of Object.entries(audits ?? {})) { + audit_totali++; + const punteggio = numero(ramo(a, "score")); + const valore = stringa(ramo(a, "displayValue")); + const modo = stringa(ramo(a, "scoreDisplayMode")) ?? ""; + + if ((RILEVANTI as readonly string[]).includes(id)) { + per_id[id] = { punteggio, valore, modo: modo || null }; + } + // "Fallito" = c'è un verdetto ed è sotto la soglia. Gli audit informativi + // non ne hanno uno: farli passare per problemi gonfierebbe l'elenco con + // roba che non è un difetto. + if (!NON_VERDETTI.has(modo) && !SOLO_DIAGNOSTICI.has(id) && punteggio != null && punteggio < 0.9) { + falliti.push({ + id, + titolo: stringa(ramo(a, "title")) ?? id, + descrizione: (stringa(ramo(a, "description")) ?? "").slice(0, 400), + punteggio, + valore, + elementi: estraiElementi(ramo(a, "details")), + }); + } + } + + // Il più grave per primo: il sintetizzatore legge dall'alto e ha un tetto di + // 10 finding, quindi l'ordine è già una selezione. + falliti.sort((a, b) => (a.punteggio ?? 1) - (b.punteggio ?? 1)); + + const { screenshot, fullpage } = estraiScreenshot(lh); + + return { + strategia, + url_analizzato: stringa(ramo(lh, "finalUrl")) ?? stringa(ramo(lh, "requestedUrl")), + punteggi: { + performance: pct("performance"), + accessibilita: pct("accessibility"), + seo: pct("seo"), + best_practices: pct("best-practices"), + }, + metriche: { + lcp_ms: val("largest-contentful-paint"), + fcp_ms: val("first-contentful-paint"), + cls: val("cumulative-layout-shift"), + tbt_ms: val("total-blocking-time"), + speed_index_ms: val("speed-index"), + risposta_server_ms: val("server-response-time"), + peso_kb: (() => { + const b = val("total-byte-weight"); + return b == null ? null : Math.round(b / 1024); + })(), + richieste: numero(ramo(audits, "network-requests", "details", "items", "length")), + }, + fasi_lcp: estraiFasiLcp(audits), + falliti, + per_id, + audit_totali, + screenshot, + fullpage_px: fullpage, + }; +} + +/** + * Entrambe le strategie. In parallelo: sono due chiamate da 30-60 s ciascuna e + * in sequenza raddoppierebbero da sole il tempo dell'intera pipeline. + */ +export async function rilevaPagespeedCompleto( + url: string +): Promise<{ mobile: PagespeedDati; desktop: PagespeedDati }> { + const [mobile, desktop] = await conLimite( + ["mobile", "desktop"] as const, + 2, + (s) => rilevaPagespeed(url, s) + ); + return { mobile, desktop }; +} diff --git a/src/lib/audit/sources/signals.ts b/src/lib/audit/sources/signals.ts new file mode 100644 index 0000000..4b365d4 --- /dev/null +++ b/src/lib/audit/sources/signals.ts @@ -0,0 +1,380 @@ +/** + * Segnali di contesto e di fiducia: RDAP, robots/sitemap, dati strutturati, + * hreflang, impronta della piattaforma, header di sicurezza. + * + * Nessuno di questi da solo fa un finding. Servono al sintetizzatore per + * INCROCIARE: "nessun dato strutturato Product/Review" da solo è una nota + * tecnica, ma unito a "nessun segnale di fiducia sulla scheda prodotto" dalla + * checklist e a "le recensioni sono sotto tre schermate" dal visivo diventa un + * unico finding con tre evidenze indipendenti. + * + * Tutto qui dentro fallisce in modo non fatale: sono fonti pubbliche gratuite, + * quindi lente e ballerine per definizione. + */ +import { + conLimite, + lista, + ramo, + scarica, + scaricaJson, + scaricaSicuro, + stringa, +} from "./fetch"; + +/** Le sitemap figlie da seguire: oltre questo si paga tempo per un numero che + * non cambia la diagnosi. `pagine_indicizzate` è un ordine di grandezza. */ +const MAX_SITEMAP_FIGLIE = 8; + +export type SegnaliDati = { + dominio: { + host: string; + registrato_il: string | null; + eta_anni: number | null; + ultimo_aggiornamento: string | null; + registrar: string | null; + errore?: string; + }; + indicizzazione: { + robots_presente: boolean; + /** Direttive che bloccano l'indicizzazione dell'intero sito: è un incidente, + * non una scelta, e va segnalato subito. */ + blocca_tutto: boolean; + sitemap_dichiarate: string[]; + sitemap_usata: string | null; + /** Conteggio degli URL nelle sitemap raggiunte → `audits.pagine_indicizzate`. */ + pagine: number | null; + /** True se il conteggio si è fermato al tetto delle figlie: il numero è un minimo. */ + parziale: boolean; + errore?: string; + }; + dati_strutturati: { + presente: boolean; + tipi: string[]; + blocchi_non_validi: number; + }; + internazionalizzazione: { + hreflang: string[]; + lang_dichiarato: string | null; + }; + piattaforma: string[]; + sicurezza: { + https: boolean; + /** Se `http://` non rimanda a `https://`, il lucchetto non protegge chi digita l'indirizzo. */ + http_redirige_a_https: boolean | null; + header_presenti: string[]; + header_mancanti: string[]; + /** Header che rivelano tecnologia e versione — informazione regalata a chi cerca bersagli. */ + espone: string[]; + /** Voto Mozilla Observatory, quando risponde. Opzionale per definizione. */ + observatory: { voto: string; punteggio: number | null } | null; + }; +}; + +// ---------------------------------------------------------------- RDAP + +/** + * RDAP vuole il dominio registrabile, non l'host. Non esiste un modo esatto di + * ricavarlo senza la Public Suffix List (che non è fra le dipendenze), quindi + * si prova dal più specifico al più generico: `shop.esempio.co.uk` → + * `esempio.co.uk` → `co.uk`. Il primo che risponde è quello giusto. + */ +function candidatiDominio(host: string): string[] { + const parti = host.replace(/^www\./i, "").split("."); + const out: string[] = []; + for (let n = 2; n <= Math.min(parti.length, 4); n++) { + out.push(parti.slice(-n).join(".")); + } + return out; +} + +function dataEvento(json: unknown, azione: string): string | null { + for (const e of lista(ramo(json, "events"))) { + if (stringa(ramo(e, "eventAction")) === azione) { + const d = stringa(ramo(e, "eventDate")); + if (d) return d.slice(0, 10); + } + } + return null; +} + +function nomeRegistrar(json: unknown): string | null { + for (const ent of lista(ramo(json, "entities"))) { + const ruoli = lista(ramo(ent, "roles")).map(String); + if (!ruoli.includes("registrar")) continue; + // vcardArray: ["vcard", [["version",…], ["fn",{},"text","Nome Registrar"], …]] + for (const campo of lista(ramo(ent, "vcardArray", 1))) { + if (Array.isArray(campo) && campo[0] === "fn") return stringa(campo[3]); + } + const h = stringa(ramo(ent, "handle")); + if (h) return h; + } + return null; +} + +async function rilevaDominio(host: string): Promise { + let ultimo = "nessun candidato ha risposto"; + for (const candidato of candidatiDominio(host)) { + const r = await scaricaJson(`https://rdap.org/domain/${candidato}`, { + timeoutMs: 15_000, + tentativi: 1, + }); + if (!r.ok) { + ultimo = r.errore; + continue; + } + const registrato = dataEvento(r.dati.json, "registration"); + if (!registrato && !nomeRegistrar(r.dati.json)) { + ultimo = "risposta RDAP senza data di registrazione"; + continue; + } + const eta = registrato + ? Math.floor((Date.now() - new Date(registrato).getTime()) / (365.25 * 24 * 3600 * 1000)) + : null; + return { + host: candidato, + registrato_il: registrato, + eta_anni: Number.isFinite(eta as number) ? eta : null, + ultimo_aggiornamento: dataEvento(r.dati.json, "last changed"), + registrar: nomeRegistrar(r.dati.json), + }; + } + return { + host, + registrato_il: null, + eta_anni: null, + ultimo_aggiornamento: null, + registrar: null, + errore: ultimo, + }; +} + +// --------------------------------------------------- robots.txt + sitemap + +function estraiLoc(xml: string): string[] { + return [...xml.matchAll(/\s*([^<]+?)\s*<\/loc>/gi)].map((m) => m[1]); +} + +async function contaSitemap( + radice: string +): Promise<{ pagine: number | null; parziale: boolean; errore?: string }> { + const r = await scaricaSicuro(radice, { timeoutMs: 20_000, tentativi: 1 }); + if (!r.ok) return { pagine: null, parziale: false, errore: r.errore }; + + const locs = estraiLoc(r.dati); + if (!locs.length) return { pagine: 0, parziale: false }; + + // Un indice di sitemap contiene , una sitemap normale contiene . + if (!/ { + const f = await scaricaSicuro(u, { timeoutMs: 20_000, tentativi: 1 }); + return f.ok ? estraiLoc(f.dati).length : 0; + }); + return { + pagine: conteggi.reduce((a, b) => a + b, 0), + parziale: locs.length > MAX_SITEMAP_FIGLIE, + }; +} + +async function rilevaIndicizzazione(origin: string): Promise { + const robots = await scaricaSicuro(new URL("/robots.txt", origin).toString(), { + timeoutMs: 15_000, + tentativi: 1, + }); + + const testo = robots.ok ? robots.dati : ""; + // `Disallow: /` sotto uno `User-agent: *` blocca l'intero sito. Si guarda + // solo il gruppo `*`: un blocco su un crawler specifico è normale. + const gruppoStar = testo + .split(/^user-agent:/im) + .find((g) => /^\s*\*/.test(g)) ?? ""; + const blocca_tutto = /^\s*disallow:\s*\/\s*$/im.test(gruppoStar); + + const dichiarate = [...testo.matchAll(/^\s*sitemap:\s*(\S+)/gim)].map((m) => m[1]); + const candidate = dichiarate.length + ? dichiarate + : ["/sitemap.xml", "/sitemap_index.xml", "/wp-sitemap.xml"].map((p) => + new URL(p, origin).toString() + ); + + for (const c of candidate) { + const conteggio = await contaSitemap(c); + if (conteggio.pagine != null && conteggio.pagine > 0) { + return { + robots_presente: robots.ok && testo.trim().length > 0, + blocca_tutto, + sitemap_dichiarate: dichiarate, + sitemap_usata: c, + pagine: conteggio.pagine, + parziale: conteggio.parziale, + }; + } + } + + return { + robots_presente: robots.ok && testo.trim().length > 0, + blocca_tutto, + sitemap_dichiarate: dichiarate, + sitemap_usata: null, + pagine: null, + parziale: false, + errore: "nessuna sitemap raggiungibile", + }; +} + +// ----------------------------------------------------------- HTML statico + +function rilevaDatiStrutturati(html: string): SegnaliDati["dati_strutturati"] { + const blocchi = [ + ...html.matchAll( + /]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi + ), + ]; + const tipi = new Set(); + let nonValidi = 0; + + const raccogli = (n: unknown) => { + const t = ramo(n, "@type"); + if (typeof t === "string") tipi.add(t); + for (const x of lista(t)) if (typeof x === "string") tipi.add(x); + // @graph è la forma che usano quasi tutti i plugin WordPress. + for (const g of lista(ramo(n, "@graph"))) raccogli(g); + }; + + for (const b of blocchi) { + try { + const json = JSON.parse(b[1]); + for (const n of Array.isArray(json) ? json : [json]) raccogli(n); + } catch { + nonValidi++; + } + } + + return { presente: tipi.size > 0, tipi: [...tipi].sort(), blocchi_non_validi: nonValidi }; +} + +function rilevaPiattaforma(html: string, headers: Record): string[] { + const impronte: [string, RegExp][] = [ + ["WordPress", /wp-content|wp-includes|]+generator[^>]+WordPress/i], + ["WooCommerce", /woocommerce|wc-ajax|add-to-cart=/i], + ["Shopify", /cdn\.shopify\.com|shopify\.theme|myshopify/i], + ["PrestaShop", /prestashop/i], + ["Magento", /\/static\/version|Magento_/i], + ["Wix", /static\.wixstatic\.com|wix-code/i], + ["Squarespace", /squarespace\.com|static1\.squarespace/i], + ["Webflow", /webflow\.(js|com)|data-wf-page/i], + ["Shopware", /shopware/i], + ["Next.js", /__NEXT_DATA__|\/_next\//i], + ["Elementor", /elementor-(?:frontend|page|widget)/i], + ]; + const testo = html + "\n" + Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n"); + return impronte.filter(([, re]) => re.test(testo)).map(([n]) => n); +} + +// ---------------------------------------------------------------- sicurezza + +const HEADER_ATTESI = [ + "strict-transport-security", + "content-security-policy", + "x-content-type-options", + "referrer-policy", + "permissions-policy", +] as const; + +async function rilevaObservatory( + host: string +): Promise { + // Timeout corto e un solo tentativo: è un di più. Una scansione lenta non + // deve allungare un audit che ha già due chiamate PageSpeed da 60 s. + const r = await scaricaJson( + `https://observatory-api.mdn.mozilla.net/api/v2/scan?host=${encodeURIComponent(host)}`, + { method: "POST", timeoutMs: 15_000, tentativi: 1 } + ); + if (!r.ok) return null; + const voto = stringa(ramo(r.dati.json, "grade")) ?? stringa(ramo(r.dati.json, "scan", "grade")); + if (!voto) return null; + const p = ramo(r.dati.json, "score") ?? ramo(r.dati.json, "scan", "score"); + return { voto, punteggio: typeof p === "number" ? p : null }; +} + +async function rilevaSicurezza( + origin: string, + headers: Record +): Promise { + const https = origin.startsWith("https://"); + const chiavi = Object.keys(headers).map((k) => k.toLowerCase()); + const csp = headers["content-security-policy"] ?? ""; + + const presenti = HEADER_ATTESI.filter((h) => chiavi.includes(h)); + // `frame-ancestors` nella CSP sostituisce X-Frame-Options: contarlo come + // mancante quando c'è la direttiva moderna sarebbe un falso allarme. + const clickjacking = chiavi.includes("x-frame-options") || /frame-ancestors/i.test(csp); + + let http_redirige_a_https: boolean | null = null; + try { + const { finale } = await scarica(origin.replace(/^https:/, "http:"), { + timeoutMs: 12_000, + tentativi: 1, + }); + http_redirige_a_https = finale.startsWith("https://"); + } catch { + // Un `http://` che non risponde affatto è comunque una configurazione + // ragionevole: nessuna conclusione, si lascia null. + } + + const host = new URL(origin).host; + return { + https, + http_redirige_a_https, + header_presenti: [...presenti, ...(clickjacking ? ["protezione-clickjacking"] : [])], + header_mancanti: [ + ...HEADER_ATTESI.filter((h) => !presenti.includes(h)), + ...(clickjacking ? [] : ["protezione-clickjacking"]), + ], + espone: ["server", "x-powered-by", "x-generator", "x-aspnet-version"] + .filter((h) => headers[h]) + .map((h) => `${h}: ${headers[h]}`), + observatory: await rilevaObservatory(host), + }; +} + +// ---------------------------------------------------------------- ingresso + +export async function rilevaSegnali( + url: string, + homeHtml: string, + homeHeaders: Record +): Promise { + const u = new URL(url); + // Gli header arrivano da `fetch.ts` con la capitalizzazione di `undici`, che + // li normalizza già in minuscolo; si rinormalizza comunque perché la lettura + // per chiave qui sotto è l'unica cosa che li rende utilizzabili. + const headers = Object.fromEntries( + Object.entries(homeHeaders).map(([k, v]) => [k.toLowerCase(), v]) + ); + + const [dominio, indicizzazione, sicurezza] = await Promise.all([ + rilevaDominio(u.host), + rilevaIndicizzazione(u.origin), + rilevaSicurezza(u.origin, headers), + ]); + + return { + dominio, + indicizzazione, + dati_strutturati: rilevaDatiStrutturati(homeHtml), + internazionalizzazione: { + hreflang: [ + ...new Set( + [...homeHtml.matchAll(/]+hreflang=["']([^"']+)["']/gi)].map((m) => m[1]) + ), + ], + lang_dichiarato: homeHtml.match(/]+lang=["']([^"']+)["']/i)?.[1] ?? null, + }, + piattaforma: rilevaPiattaforma(homeHtml, headers), + sicurezza, + }; +}