/** * 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 { 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(/\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 , una sitemap normale contiene . if (!/ { 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 { 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( /]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi ), ]; const tipi = new Set(); 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[] { const impronte: [string, RegExp][] = [ ["WordPress", /wp-content|wp-includes|]+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 { // 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 ): Promise { 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 ): Promise { 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(/]+hreflang=["']([^"']+)["']/gi)].map((m) => m[1]) ), ], lang_dichiarato: homeHtml.match(/]+lang=["']([^"']+)["']/i)?.[1] ?? null, }, piattaforma: rilevaPiattaforma(homeHtml, headers), sicurezza, }; }