Files
clienthub/src/lib/audit/sources/signals.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

381 lines
13 KiB
TypeScript

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