chore(outils): script de contrôle des liens, outil de revue (B6-M4)

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
This commit is contained in:
Jules Neny
2026-10-08 17:07:37 +02:00
co-authored by Claude Sonnet
parent 43ae065e26
commit f4ea1639f2
+91
View File
@@ -0,0 +1,91 @@
#!/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.