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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(/&/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 };
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user