Files
clienthub/src/lib/audit/sources/crux.ts
T
simone 08b0a60bae feat(audit): le fonti del motore di analisi
src/lib/audit/sources/ — raccolta dati, nessun LLM. Cinque moduli:

- fetch.ts      home + fino a 3 pagine interne per profilo, piu' gli helper di
                rete condivisi dalle altre fonti (ritentativi su 429/5xx e
                timeout, tetto di concorrenza, navigazione JSON difensiva)
- pagespeed.ts  153 audit Lighthouse fatti sul DOM renderizzato, falliti
                ordinati per gravita' con elementi concreti, per_id per la
                checklist, fasi LCP, screenshot
- crux.ts       dati di utenti reali, con scala di ripiego a quattro gradini
- history.ts    Wayback CDX, istantanee a 1/3/5 anni, confronto con la home
- signals.ts    RDAP, robots/sitemap, JSON-LD, hreflang, piattaforma, header

Regola comune: nessuna fonte puo' uccidere la pipeline. Chi fallisce restituisce
un risultato con `errore` valorizzato — e "non ha risposto" resta distinto da
"ha risposto che non ci sono dati", perche' il documento deve poterlo dire.

Provate sul campo su giojello.com prima di costruirci sopra, e il giro ha
trovato quattro cose che il typecheck non poteva vedere:

- fasi_lcp usciva vuoto: largest-contentful-paint-element non esiste piu'
  nell'API pubblica, ora e' lcp-breakdown-insight con subpart/duration e senza
  percentuali (si calcolano). Dice che il 91% dell'LCP e' ritardo nel *trovare*
  la risorsa, non peso dell'immagine: comprimere le foto non toccherebbe nulla.
- ttfb_ms era un nome pericoloso. Lighthouse da' 7 ms, CrUX da' 3.553 ms di p75:
  il server risponde in fretta al datacenter Google e lento a tutti gli altri.
  Con lo stesso nome il sintetizzatore li tratterebbe come un numero solo, da
  qui risposta_server_ms.
- le dimensioni dello screenshot erano sempre null: configSettings.screenEmulation
  non esiste. Ora si leggono dai byte dell'immagine — 250x498, leggibile.
- Wayback andava in timeout a 30 s e la fonte usciva vuota.

Nessun renderer headless, da nessuna parte: il VPS non regge Chromium e non
serve, gli audit Lighthouse arrivano gia' fatti sul DOM renderizzato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:27 +02:00

227 lines
8.1 KiB
TypeScript

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