Ajoute un outil de gestion interactive des liens morts
This commit is contained in:
508
tools/manage_dead_links.js
Normal file
508
tools/manage_dead_links.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user