#!/usr/bin/env node /** * Contrôle des liens de /outils (B6-M4) — outil de revue, PAS un test : ne fait jamais échouer le build. * Lit public/data/outils.json (toutes les URL, y compris `urls[]` et `episodes[].url`) et * public/data/fmhy-curated.json, fait un GET (redirections suivies, délai 15 s, 4 requêtes en parallèle) * et liste les réponses hors 2xx/3xx. * * Un 403/429 d'un site qui bloque les robots n'est pas un lien mort : lire la liste avant de retirer quoi que ce soit. * Usage : node scripts/check-liens-outils.mjs */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8')) const DELAI_MS = 15000 const PARALLELE = 4 const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' /** url -> Set des entrées qui la portent. Toute chaîne https:// est prise, quel que soit son champ. */ const urls = new Map() function ajoute(url, qui) { if (!urls.has(url)) urls.set(url, new Set()) urls.get(url).add(qui) } /** Titre lisible de l'entrée courante : nom / titre / mot, sinon l'id. */ const titreDe = (o) => o.nom ?? o.titre ?? o.name ?? o.mot ?? o.id function parcourt(valeur, fichier, contexte) { if (typeof valeur === 'string') { if (/^https:\/\//i.test(valeur) || /^http:\/\//i.test(valeur)) ajoute(valeur, `${fichier} › ${contexte}`) } else if (Array.isArray(valeur)) { for (const v of valeur) parcourt(v, fichier, contexte) } else if (valeur && typeof valeur === 'object') { // `lien_texte`-like chaînes ne commencent pas par https:// : seules les vraies URL sont retenues. const ctx = titreDe(valeur) ? `${contexte ? contexte + ' › ' : ''}${titreDe(valeur)}` : contexte for (const [, v] of Object.entries(valeur)) parcourt(v, fichier, ctx) } } parcourt(lire('outils.json'), 'outils.json', '') parcourt(lire('fmhy-curated.json'), 'fmhy-curated.json', '') async function verifie(url) { const ctrl = new AbortController() const minuteur = setTimeout(() => ctrl.abort(), DELAI_MS) try { const r = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal, headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.7' }, }) r.body?.cancel().catch(() => {}) return r.status } catch (e) { return e?.name === 'AbortError' ? 'délai' : `erreur (${e?.cause?.code ?? e?.message ?? 'réseau'})` } finally { clearTimeout(minuteur) } } const liste = [...urls.keys()] const resultats = new Map() let curseur = 0 async function travailleur() { while (curseur < liste.length) { const url = liste[curseur++] resultats.set(url, await verifie(url)) } } await Promise.all(Array.from({ length: PARALLELE }, travailleur)) const ko = liste.filter((u) => { const c = resultats.get(u) return !(typeof c === 'number' && c >= 200 && c < 400) }) console.log(`Contrôle de ${liste.length} URL (GET, délai ${DELAI_MS / 1000} s, ${PARALLELE} en parallèle)\n`) if (ko.length) { console.log('code'.padEnd(18) + 'URL · entrée') for (const u of ko) { console.log(String(resultats.get(u)).padEnd(18) + `${u} · ${[...urls.get(u)].join(' | ')}`) } } console.log(`\n${ko.length} réponse(s) hors 2xx/3xx sur ${liste.length} URL.`) // Outil de revue : toujours code de sortie 0.