08b0a60bae
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>
227 lines
8.1 KiB
TypeScript
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,
|
|
};
|
|
}
|