1

Ajoute un outil de gestion interactive des liens morts

This commit is contained in:
2026-03-25 23:06:23 +01:00
parent dc262bdd97
commit 3cb735333a
7 changed files with 1115 additions and 21 deletions

508
tools/manage_dead_links.js Normal file
View File

@@ -0,0 +1,508 @@
#!/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=<path> Racine à parcourir pour les remplacements
--report-path=<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<string|null>} 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<boolean>} 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<boolean>} 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<Array<{ url: string, status: number|null, locations: Array<{ file: string, line: number|null, page: string|null }> }>>}
*/
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;
});