/** * 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, }; }