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>
381 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|