#!/usr/bin/env node const path = require("node:path"); const { spawnSync } = require("node:child_process"); const readline = require("node:readline/promises"); const { stdin, stdout } = require("node:process"); const { DateTime } = require("luxon"); const { listArchiveCaptures } = require("./lib/archive"); const { resolveExternalLinksReportPath, loadExternalLinksReport, getLinksByStatus, } = require("./lib/external_links_report"); const { findUrlOccurrences, replaceUrlInFiles } = require("./lib/url_replacements"); const PROJECT_ROOT = path.resolve(__dirname, ".."); const DEFAULT_CONTENT_DIR = path.join(PROJECT_ROOT, "content"); const DEFAULT_STATUS_CODE = 404; /** * Convertit une reponse utilisateur en boolen simple. * @param {string} answer Reponse brute. * @returns {boolean} true si la reponse signifie oui. */ function isYes(answer) { if (typeof answer !== "string") { return false; } const normalized = answer.trim().toLowerCase(); return normalized === "o" || normalized === "oui" || normalized === "y" || normalized === "yes"; } /** * Resout un chemin CLI par rapport a la racine du projet. * @param {string} value Valeur fournie en argument. * @returns {string} Chemin absolu. */ function resolveCliPath(value) { if (typeof value !== "string" || !value.trim()) { throw new Error("Le chemin fourni est vide."); } const trimmed = value.trim(); if (path.isAbsolute(trimmed)) { return trimmed; } return path.resolve(PROJECT_ROOT, trimmed); } /** * Analyse les arguments de la ligne de commande. * @param {string[]} argv Arguments bruts. * @returns {{ help: boolean, refresh: boolean, contentDir: string, reportPath: string|null }} */ function parseArgs(argv) { const options = { help: false, refresh: true, contentDir: DEFAULT_CONTENT_DIR, reportPath: null, }; for (const arg of argv.slice(2)) { if (arg === "--help" || arg === "-h") { options.help = true; continue; } if (arg === "--no-refresh") { options.refresh = false; continue; } if (arg.startsWith("--content-dir=")) { options.contentDir = resolveCliPath(arg.slice("--content-dir=".length)); continue; } if (arg.startsWith("--report-path=")) { options.reportPath = resolveCliPath(arg.slice("--report-path=".length)); continue; } throw new Error(`Argument non pris en charge : ${arg}`); } return options; } /** * Affiche l'aide du script. */ function showUsage() { console.log(`Utilisation : node tools/manage_dead_links.js [options] Options --no-refresh Réutilise le rapport existant au lieu de relancer la vérification --content-dir= Racine à parcourir pour les remplacements --report-path= Rapport YAML à lire --help, -h Affiche cette aide Notes - Par défaut, le script relance tools/check_external_links.js avant le traitement. - Les remplacements portent sur les fichiers .md, .markdown, .json, .yaml et .yml. - L'action de suppression est réservée pour plus tard et n'est pas encore implémentée.`); } /** * Verifie que l'on ne combine pas des options incompatibles. * @param {{ refresh: boolean, contentDir: string, reportPath: string|null }} options Options retenues. */ function validateOptions(options) { const usesCustomContentDir = path.resolve(options.contentDir) !== path.resolve(DEFAULT_CONTENT_DIR); const usesCustomReportPath = options.reportPath !== null; if (!options.refresh) { return; } if (usesCustomContentDir || usesCustomReportPath) { throw new Error( "Les options --content-dir et --report-path nécessitent --no-refresh, car le contrôleur de liens actuel travaille sur le projet courant." ); } } /** * Relance la generation du rapport des liens externes. */ function refreshExternalLinksReport() { const scriptPath = path.join(PROJECT_ROOT, "tools", "check_external_links.js"); const result = spawnSync(process.execPath, [scriptPath], { cwd: PROJECT_ROOT, stdio: "inherit", }); if (result.error) { throw result.error; } if (result.status !== 0) { throw new Error("La mise à jour du rapport des liens externes a échoué."); } } /** * Formate un horodatage Archive.org. * @param {string} timestamp Horodatage brut. * @returns {string} Representation lisible. */ function formatArchiveTimestamp(timestamp) { if (typeof timestamp !== "string") { return "horodatage inconnu"; } const date = DateTime.fromFormat(timestamp, "yyyyLLddHHmmss", { zone: "utc" }); if (!date.isValid) { return timestamp; } return date.setLocale("fr").toFormat("dd/LL/yyyy HH:mm:ss 'UTC'"); } /** * Rend un chemin plus lisible pour la console. * @param {string} filePath Chemin absolu ou relatif. * @returns {string} Chemin affiche. */ function formatFilePath(filePath) { const resolvedPath = path.resolve(filePath); const relativePath = path.relative(PROJECT_ROOT, resolvedPath); if (relativePath && !relativePath.startsWith("..")) { return relativePath; } return resolvedPath; } /** * Affiche les emplacements connus pour un lien mort. * @param {{ locations?: Array<{ file: string, line: number|null }> }} link Lien courant. */ function showLocations(link) { let locations = []; if (Array.isArray(link.locations)) { locations = link.locations; } if (locations.length === 0) { console.log("Emplacements connus : aucun"); return; } console.log("Emplacements connus :"); for (const location of locations) { let label = ` - ${location.file}`; if (typeof location.line === "number" && Number.isFinite(location.line)) { label += `:${location.line}`; } console.log(label); } } /** * Affiche les actions disponibles pour le lien courant. * @param {boolean} allowArchive Indique si Archive.org reste propose. */ function showActions(allowArchive) { console.log("Actions :"); if (allowArchive) { console.log(" 1. Remplacer par une capture Archive.org"); } console.log(" 2. Remplacer par une autre URL"); console.log(" 3. Supprimer le lien (non implémenté)"); console.log(" s. Passer au lien suivant"); console.log(" q. Quitter"); } /** * Demande l'action a executer pour un lien. * @param {readline.Interface} rl Interface readline. * @param {boolean} allowArchive Indique si l'action Archive.org est disponible. * @returns {Promise<"archive"|"custom"|"remove"|"skip"|"quit">} */ async function promptAction(rl, allowArchive) { while (true) { showActions(allowArchive); const answer = (await rl.question("> ")).trim().toLowerCase(); if (answer === "1" && allowArchive) { return "archive"; } if (answer === "2") { return "custom"; } if (answer === "3") { return "remove"; } if (answer === "s") { return "skip"; } if (answer === "q") { return "quit"; } console.log("Choix invalide."); } } /** * Propose une selection de captures Wayback a l'utilisateur. * @param {readline.Interface} rl Interface readline. * @param {string} deadUrl URL d'origine. * @returns {Promise<{ type: "selected", replacementUrl: string }|{ type: "unavailable" }|{ type: "cancel" }>} */ async function promptArchiveReplacement(rl, deadUrl) { const captures = await listArchiveCaptures(deadUrl, { limit: 10 }); if (captures.length === 0) { console.log("Aucune capture Archive.org exploitable n'a été trouvée pour cette URL."); return { type: "unavailable" }; } if (captures.length === 1) { const capture = captures[0]; console.log("Une capture Archive.org a été trouvée :"); console.log(` - ${formatArchiveTimestamp(capture.timestamp)}`); console.log(` ${capture.url}`); const confirm = await rl.question("Utiliser cette capture ? (o/N) "); if (isYes(confirm)) { return { type: "selected", replacementUrl: capture.url }; } return { type: "cancel" }; } console.log("Captures Archive.org disponibles (10 plus récentes) :"); for (const [index, capture] of captures.entries()) { console.log(` ${index + 1}. ${formatArchiveTimestamp(capture.timestamp)}`); console.log(` ${capture.url}`); } while (true) { const answer = ( await rl.question(`Choisissez une capture (1-${captures.length}) ou Entrée pour revenir au menu : `) ).trim(); if (!answer) { return { type: "cancel" }; } const selectedIndex = Number.parseInt(answer, 10); if (Number.isNaN(selectedIndex)) { console.log("Sélection invalide."); continue; } if (selectedIndex < 1 || selectedIndex > captures.length) { console.log("Sélection hors plage."); continue; } const capture = captures[selectedIndex - 1]; return { type: "selected", replacementUrl: capture.url }; } } /** * Demande une URL de remplacement manuelle. * @param {readline.Interface} rl Interface readline. * @param {string} deadUrl URL remplacee. * @returns {Promise} URL choisie ou null si abandon. */ async function promptCustomReplacement(rl, deadUrl) { while (true) { const answer = (await rl.question("Nouvelle URL (laisser vide pour revenir au menu) : ")).trim(); if (!answer) { return null; } if (!URL.canParse(answer)) { console.log("Cette URL n'est pas valide."); continue; } const parsed = new URL(answer); const protocol = parsed.protocol.toLowerCase(); if (protocol !== "http:" && protocol !== "https:") { console.log("Seules les URL http et https sont acceptées."); continue; } if (answer === deadUrl) { console.log("La nouvelle URL est identique à l'ancienne."); continue; } return answer; } } /** * Affiche le plan de remplacement avant confirmation. * @param {{ url: string }} link Lien traite. * @param {string} replacementUrl URL de destination. * @param {Array<{ filePath: string, occurrences: number }>} matches Fichiers concernes. * @param {readline.Interface} rl Interface readline. * @returns {Promise} true si l'utilisateur confirme. */ async function confirmReplacement(link, replacementUrl, matches, rl) { let totalOccurrences = 0; for (const match of matches) { totalOccurrences += match.occurrences; } console.log(`Remplacement prévu : ${link.url}`); console.log(` vers ${replacementUrl}`); console.log(`Occurrences : ${totalOccurrences} dans ${matches.length} fichier(s)`); for (const match of matches.slice(0, 10)) { console.log(` - ${formatFilePath(match.filePath)} (${match.occurrences})`); } if (matches.length > 10) { console.log(` - ... ${matches.length - 10} fichier(s) supplémentaire(s)`); } const answer = await rl.question("Confirmer le remplacement ? (o/N) "); return isYes(answer); } /** * Applique un remplacement deja choisi. * @param {{ url: string }} link Lien courant. * @param {string} replacementUrl URL de remplacement. * @param {string} contentDir Racine de recherche. * @param {readline.Interface} rl Interface readline. * @returns {Promise} true si des fichiers ont ete modifies. */ async function applyReplacement(link, replacementUrl, contentDir, rl) { const matches = await findUrlOccurrences(contentDir, link.url); if (matches.length === 0) { console.log("Aucune occurrence exacte n'a été trouvée dans le contenu cible."); return false; } const confirmed = await confirmReplacement(link, replacementUrl, matches, rl); if (!confirmed) { console.log("Remplacement annulé."); return false; } const result = await replaceUrlInFiles(contentDir, link.url, replacementUrl, { matches }); console.log( `${result.totalOccurrences} occurrence(s) remplacee(s) dans ${result.changedFiles.length} fichier(s).` ); return result.changedFiles.length > 0; } /** * Traite un lien 404 dans une boucle interactive. * @param {readline.Interface} rl Interface readline. * @param {{ url: string, locations?: Array<{ file: string, line: number|null }> }} link Lien a gerer. * @param {number} index Index humain. * @param {number} total Nombre total de liens. * @param {string} contentDir Racine du contenu. * @returns {Promise<"changed"|"skipped"|"quit">} */ async function processLink(rl, link, index, total, contentDir) { let allowArchive = true; while (true) { console.log(""); console.log(`[${index}/${total}] Lien 404`); console.log(link.url); showLocations(link); const action = await promptAction(rl, allowArchive); if (action === "quit") { return "quit"; } if (action === "skip") { return "skipped"; } if (action === "remove") { console.log("La suppression n'est pas encore implémentée. Retour au menu."); continue; } if (action === "custom") { const replacementUrl = await promptCustomReplacement(rl, link.url); if (!replacementUrl) { continue; } const changed = await applyReplacement(link, replacementUrl, contentDir, rl); if (changed) { return "changed"; } continue; } if (action === "archive") { const archiveSelection = await promptArchiveReplacement(rl, link.url); if (archiveSelection.type === "unavailable") { allowArchive = false; continue; } if (archiveSelection.type === "cancel") { continue; } const changed = await applyReplacement(link, archiveSelection.replacementUrl, contentDir, rl); if (changed) { return "changed"; } continue; } } } /** * Charge la liste des liens 404 a traiter. * @param {{ reportPath: string|null }} options Options du script. * @returns {Promise }>>} */ async function load404Links(options) { let reportPath = options.reportPath; if (!reportPath) { reportPath = await resolveExternalLinksReportPath(PROJECT_ROOT); } const report = loadExternalLinksReport(reportPath); return getLinksByStatus(report, DEFAULT_STATUS_CODE); } async function main() { const options = parseArgs(process.argv); if (options.help) { showUsage(); return; } validateOptions(options); if (options.refresh) { console.log("Actualisation du rapport des liens externes..."); refreshExternalLinksReport(); } const links = await load404Links(options); if (links.length === 0) { console.log("Aucun lien 404 à traiter."); return; } const rl = readline.createInterface({ input: stdin, output: stdout }); let changedCount = 0; let skippedCount = 0; const interactiveRun = async () => { for (const [index, link] of links.entries()) { const outcome = await processLink(rl, link, index + 1, links.length, options.contentDir); if (outcome === "quit") { break; } if (outcome === "changed") { changedCount += 1; continue; } skippedCount += 1; } }; await interactiveRun().finally(() => rl.close()); console.log(""); console.log(`Traitement terminé : ${changedCount} lien(s) traité(s), ${skippedCount} lien(s) ignoré(s).`); if (changedCount > 0 && options.refresh) { console.log("Régénération du rapport après modifications..."); refreshExternalLinksReport(); } if (changedCount > 0 && !options.refresh) { console.log("Le rapport n'a pas été régénéré car --no-refresh est actif."); } } main().catch((error) => { console.error("Erreur lors de la gestion des liens morts :", error.message); process.exitCode = 1; });