08b0a60bae
src/lib/audit/sources/ — raccolta dati, nessun LLM. Cinque moduli:
- fetch.ts home + fino a 3 pagine interne per profilo, piu' gli helper di
rete condivisi dalle altre fonti (ritentativi su 429/5xx e
timeout, tetto di concorrenza, navigazione JSON difensiva)
- pagespeed.ts 153 audit Lighthouse fatti sul DOM renderizzato, falliti
ordinati per gravita' con elementi concreti, per_id per la
checklist, fasi LCP, screenshot
- crux.ts dati di utenti reali, con scala di ripiego a quattro gradini
- history.ts Wayback CDX, istantanee a 1/3/5 anni, confronto con la home
- signals.ts RDAP, robots/sitemap, JSON-LD, hreflang, piattaforma, header
Regola comune: nessuna fonte puo' uccidere la pipeline. Chi fallisce restituisce
un risultato con `errore` valorizzato — e "non ha risposto" resta distinto da
"ha risposto che non ci sono dati", perche' il documento deve poterlo dire.
Provate sul campo su giojello.com prima di costruirci sopra, e il giro ha
trovato quattro cose che il typecheck non poteva vedere:
- fasi_lcp usciva vuoto: largest-contentful-paint-element non esiste piu'
nell'API pubblica, ora e' lcp-breakdown-insight con subpart/duration e senza
percentuali (si calcolano). Dice che il 91% dell'LCP e' ritardo nel *trovare*
la risorsa, non peso dell'immagine: comprimere le foto non toccherebbe nulla.
- ttfb_ms era un nome pericoloso. Lighthouse da' 7 ms, CrUX da' 3.553 ms di p75:
il server risponde in fretta al datacenter Google e lento a tutti gli altri.
Con lo stesso nome il sintetizzatore li tratterebbe come un numero solo, da
qui risposta_server_ms.
- le dimensioni dello screenshot erano sempre null: configSettings.screenEmulation
non esiste. Ora si leggono dai byte dell'immagine — 250x498, leggibile.
- Wayback andava in timeout a 30 s e la fonte usciva vuota.
Nessun renderer headless, da nessuna parte: il VPS non regge Chromium e non
serve, gli audit Lighthouse arrivano gia' fatti sul DOM renderizzato.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
8.3 KiB
TypeScript
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>`;
|
|
}
|