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>
This commit is contained in:
2026-08-18 17:48:27 +02:00
parent 31237da11c
commit 08b0a60bae
5 changed files with 1716 additions and 0 deletions
+226
View File
@@ -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<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,
};
}
+447
View File
@@ -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<string, string>;
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<T> = { 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<T>(
op: () => Promise<T>,
tentativi: number,
attesaMs: number
): Promise<T> {
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<string, string>;
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<string, string>; 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<string, string> }> {
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<Esito<string>> {
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<Esito<{ json: unknown; status: number }>> {
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<I, O>(
elementi: I[],
limite: number,
fn: (e: I, i: number) => Promise<O>
): Promise<O[]> {
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<string | number, unknown>)[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(/<!--[\s\S]*?-->/g, " ")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ");
}
export function decodifica(s: string): string {
const m: Record<string, string> = {
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(
`<meta[^>]+(?:name|property)=["']${nome}["'][^>]*content=["']([^"']*)["']`, "i");
const alt = new RegExp(
`<meta[^>]+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, /<title[^>]*>([\s\S]*?)<\/title>/gi, 1)[0] ?? "(assente)";
const desc = meta(html, "description");
const robots = meta(html, "robots");
const h1 = tag(c, /<h1[^>]*>([\s\S]*?)<\/h1>/gi, 6);
const h2 = tag(c, /<h2[^>]*>([\s\S]*?)<\/h2>/gi, 25);
const h3 = tag(c, /<h3[^>]*>([\s\S]*?)<\/h3>/gi, 30);
const bottoni = [
...tag(c, /<button[^>]*>([\s\S]*?)<\/button>/gi, 30),
...[...c.matchAll(/<input[^>]+type=["'](?:submit|button)["'][^>]*value=["']([^"']+)["']/gi)]
.map((m) => decodifica(m[1])),
];
const link = [...c.matchAll(/<a[^>]+href=["']([^"'#]+)["'][^>]*>([\s\S]*?)<\/a>/gi)]
.map((m) => decodifica(m[2].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim())
.filter(Boolean);
const imgs = [...c.matchAll(/<img[^>]*>/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 `<pagina ruolo="${p.ruolo}">\n${safe}\n</pagina>`;
}
// ---------------------------------------------------------------- selezione
function linkInterni(html: string, base: string): string[] {
const origin = new URL(base).origin;
return [...pulisci(html).matchAll(/<a[^>]+href=["']([^"'#]+)["']/gi)]
.map((m) => {
try { return new URL(m[1].replace(/&amp;/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<string | null> {
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<Raccolta> {
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 };
}
+236
View File
@@ -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, /<title[^>]*>([\s\S]*?)<\/title>/gi, 1)[0] ?? null,
h1: tag(c, /<h1[^>]*>([\s\S]*?)<\/h1>/gi, 4),
descrizione: meta(html, "description"),
estratto: testoVisibile(html, 1_200),
};
}
export async function rilevaStorico(url: string, homeHtml: string): Promise<StoricoDati> {
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<Istantanea> => {
// 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 `<archivio anni_fa="${i.anni_fa}" data="${i.data}">\n${safe}\n</archivio>`;
}
+427
View File
@@ -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<PagespeedDati> {
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<string, unknown> | 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 };
}
+380
View File
@@ -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<SegnaliDati["dominio"]> {
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(/<loc>\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 <sitemap>, una sitemap normale contiene <url>.
if (!/<sitemapindex/i.test(r.dati)) {
return { pagine: locs.length, parziale: false };
}
const figlie = locs.slice(0, MAX_SITEMAP_FIGLIE);
const conteggi = await conLimite(figlie, 3, async (u) => {
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<SegnaliDati["indicizzazione"]> {
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(
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
),
];
const tipi = new Set<string>();
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, string>): string[] {
const impronte: [string, RegExp][] = [
["WordPress", /wp-content|wp-includes|<meta[^>]+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<SegnaliDati["sicurezza"]["observatory"]> {
// 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<string, string>
): Promise<SegnaliDati["sicurezza"]> {
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<string, string>
): Promise<SegnaliDati> {
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(/<link[^>]+hreflang=["']([^"']+)["']/gi)].map((m) => m[1])
),
],
lang_dichiarato: homeHtml.match(/<html[^>]+lang=["']([^"']+)["']/i)?.[1] ?? null,
},
piattaforma: rilevaPiattaforma(homeHtml, headers),
sicurezza,
};
}