/** * 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, /]*>([\s\S]*?)<\/title>/gi, 1)[0] ?? null, h1: tag(c, /]*>([\s\S]*?)<\/h1>/gi, 4), descrizione: meta(html, "description"), estratto: testoVisibile(html, 1_200), }; } export async function rilevaStorico(url: string, homeHtml: string): Promise { 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 => { // 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 `\n${safe}\n`; }