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

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

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

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

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

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

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

237 lines
8.3 KiB
TypeScript

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