const fs = require("node:fs/promises"); const path = require("node:path"); const { fetch } = require("undici"); const COMMONS_API_URL = "https://commons.wikimedia.org/w/api.php"; const COMMONS_HOST = "commons.wikimedia.org"; const UPLOAD_HOST = "upload.wikimedia.org"; /** * Extrait un titre de fichier MediaWiki depuis une URL Wikipédia ou Commons. * @param {string} rawUrl URL fournie par l'utilisateur. * @returns {string} Titre canonique de type `File:Nom.ext`. */ function extractFileTitleFromUrl(rawUrl) { const url = new URL(rawUrl); const hostname = url.hostname.toLowerCase(); if (url.hash) { const hash = decodeURIComponent(url.hash.slice(1)); if (hash.startsWith("/media/")) { const fileTitle = hash.slice("/media/".length); return normalizeFileTitle(fileTitle); } } if (pathnameLooksLikeFilePage(url.pathname)) { const title = decodeURIComponent(url.pathname.slice("/wiki/".length)); return normalizeFileTitle(title); } if (hostname === UPLOAD_HOST) { const fileName = decodeURIComponent(path.basename(url.pathname)); return normalizeFileTitle(`File:${fileName}`); } if (hostname === COMMONS_HOST || hostname.endsWith(".wikipedia.org")) { throw new Error(`L'URL ${rawUrl} ne pointe pas vers une page de fichier Wikimedia.`); } throw new Error(`L'URL ${rawUrl} n'appartient pas à Wikipédia ou Wikimedia Commons.`); } /** * Vérifie si un chemin d'URL correspond à une page de fichier MediaWiki. * @param {string} pathname Partie pathname de l'URL. * @returns {boolean} `true` si le chemin vise une page de fichier. */ function pathnameLooksLikeFilePage(pathname) { if (!pathname.startsWith("/wiki/")) { return false; } const decoded = decodeURIComponent(pathname.slice("/wiki/".length)); if (decoded.startsWith("File:")) { return true; } if (decoded.startsWith("Fichier:")) { return true; } return false; } /** * Normalise un titre de fichier Wikimedia vers l'espace de noms `File:`. * @param {string} rawTitle Titre brut extrait d'une URL. * @returns {string} Titre normalisé. */ function normalizeFileTitle(rawTitle) { const cleaned = rawTitle.trim(); if (!cleaned) { throw new Error("Le titre du fichier Wikimedia est vide."); } if (cleaned.startsWith("File:")) { return cleaned; } if (cleaned.startsWith("Fichier:")) { return `File:${cleaned.slice("Fichier:".length)}`; } throw new Error(`Le titre ${rawTitle} ne correspond pas à un fichier Wikimedia.`); } /** * Interroge l'API Commons pour récupérer l'image et ses métadonnées. * @param {string} fileTitle Titre du fichier ciblé. * @returns {Promise<{ fileTitle: string, fileName: string, imageUrl: string, descriptionUrl: string, descriptionShortUrl: string, description: string, attribution: string }>} */ async function fetchWikimediaAsset(fileTitle) { const url = new URL(COMMONS_API_URL); url.searchParams.set("action", "query"); url.searchParams.set("titles", fileTitle); url.searchParams.set("prop", "imageinfo"); url.searchParams.set("iiprop", "url|extmetadata"); url.searchParams.set("iiextmetadatalanguage", "en"); url.searchParams.set("iilimit", "1"); url.searchParams.set("format", "json"); const response = await fetch(url, { headers: { accept: "application/json", }, }); if (!response.ok) { throw new Error(`L'API Wikimedia Commons a répondu ${response.status} pour ${fileTitle}.`); } const data = await response.json(); return extractAssetFromApiResponse(data); } /** * Extrait les informations utiles depuis une réponse JSON de l'API Commons. * @param {Record} data Réponse JSON brute. * @returns {{ fileTitle: string, fileName: string, imageUrl: string, descriptionUrl: string, descriptionShortUrl: string, description: string, attribution: string }} */ function extractAssetFromApiResponse(data) { if (!data || typeof data !== "object") { throw new Error("La réponse de l'API Wikimedia Commons est invalide."); } const query = data.query; if (!query || typeof query !== "object") { throw new Error("La réponse de l'API Wikimedia Commons ne contient pas de section query."); } const pages = query.pages; if (!pages || typeof pages !== "object") { throw new Error("La réponse de l'API Wikimedia Commons ne contient pas de pages."); } const pageIds = Object.keys(pages); if (pageIds.length === 0) { throw new Error("La réponse de l'API Wikimedia Commons ne contient aucune page."); } const page = pages[pageIds[0]]; if (!page || typeof page !== "object") { throw new Error("La page Wikimedia Commons retournée est invalide."); } if (Object.prototype.hasOwnProperty.call(page, "missing")) { throw new Error(`Le fichier Wikimedia ${page.title} est introuvable.`); } const imageInfoList = page.imageinfo; if (!Array.isArray(imageInfoList) || imageInfoList.length === 0) { throw new Error(`Aucune information image n'a été retournée pour ${page.title}.`); } const imageInfo = imageInfoList[0]; const extmetadata = imageInfo.extmetadata; if (!extmetadata || typeof extmetadata !== "object") { throw new Error(`Les métadonnées étendues sont absentes pour ${page.title}.`); } const imageUrl = imageInfo.url; const descriptionUrl = imageInfo.descriptionurl; const descriptionShortUrl = imageInfo.descriptionshorturl; if (typeof imageUrl !== "string" || !imageUrl) { throw new Error(`L'URL de téléchargement est absente pour ${page.title}.`); } if (typeof descriptionUrl !== "string" || !descriptionUrl) { throw new Error(`L'URL de description est absente pour ${page.title}.`); } if (typeof descriptionShortUrl !== "string" || !descriptionShortUrl) { throw new Error(`L'URL courte de description est absente pour ${page.title}.`); } const imageDescription = readExtMetadataValue(extmetadata, "ImageDescription"); const artist = readExtMetadataValue(extmetadata, "Artist"); const credit = readExtMetadataValue(extmetadata, "Credit"); const licenseShortName = normalizeLicenseName(readExtMetadataValue(extmetadata, "LicenseShortName")); const attribution = buildAttribution(artist, credit, licenseShortName, descriptionShortUrl); const fileName = decodeURIComponent(path.basename(new URL(imageUrl).pathname)); if (!imageDescription) { throw new Error(`La description Wikimedia est absente pour ${page.title}.`); } if (!attribution) { throw new Error(`L'attribution Wikimedia est absente pour ${page.title}.`); } return { fileTitle: page.title, fileName, imageUrl, descriptionUrl, descriptionShortUrl, description: imageDescription, attribution, }; } /** * Lit un champ extmetadata et le convertit en texte brut. * @param {Record} extmetadata Métadonnées étendues. * @param {string} key Nom du champ recherché. * @returns {string} Valeur nettoyée, éventuellement vide. */ function readExtMetadataValue(extmetadata, key) { const entry = extmetadata[key]; if (!entry || typeof entry !== "object") { return ""; } if (typeof entry.value !== "string") { return ""; } return sanitizeMetadataText(entry.value); } /** * Nettoie une valeur HTML issue de Commons et la ramène à du texte. * @param {string} value Valeur brute. * @returns {string} Texte brut nettoyé. */ function sanitizeMetadataText(value) { let sanitized = decodeHtmlEntities(value); sanitized = sanitized.replace(//gi, " "); sanitized = sanitized.replace(/<[^>]+>/g, " "); sanitized = decodeHtmlEntities(sanitized); sanitized = sanitized.replace(/\s+/g, " ").trim(); return sanitized; } /** * Décode un sous-ensemble suffisant des entités HTML utilisées par Commons. * @param {string} value Valeur HTML encodée. * @returns {string} Valeur décodée. */ function decodeHtmlEntities(value) { const namedEntities = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: "\"", }; let decoded = value.replace(/&#x([0-9a-f]+);/gi, (match, digits) => { const codePoint = Number.parseInt(digits, 16); if (!Number.isInteger(codePoint)) { return match; } if (codePoint < 0 || codePoint > 0x10ffff) { return match; } return String.fromCodePoint(codePoint); }); decoded = decoded.replace(/&#([0-9]+);/g, (match, digits) => { const codePoint = Number.parseInt(digits, 10); if (!Number.isInteger(codePoint)) { return match; } if (codePoint < 0 || codePoint > 0x10ffff) { return match; } return String.fromCodePoint(codePoint); }); decoded = decoded.replace(/&([a-z]+);/gi, (match, name) => { const key = name.toLowerCase(); if (Object.prototype.hasOwnProperty.call(namedEntities, key)) { return namedEntities[key]; } return match; }); return decoded; } /** * Assemble l'attribution finale telle qu'elle sera écrite dans le YAML. * @param {string} artist Auteur nettoyé. * @param {string} credit Crédit nettoyé. * @param {string} licenseShortName Licence courte. * @param {string} descriptionShortUrl URL courte Commons. * @returns {string} Attribution concaténée. */ function buildAttribution(artist, credit, licenseShortName, descriptionShortUrl) { const parts = []; let creditLine = ""; if (artist) { creditLine = `By ${artist}`; } if (credit) { if (creditLine) { creditLine = `${creditLine} - ${credit}`; } else { creditLine = credit; } } if (creditLine) { parts.push(creditLine); } if (licenseShortName) { parts.push(licenseShortName); } if (descriptionShortUrl) { parts.push(descriptionShortUrl); } return parts.join(", "); } /** * Harmonise certains libellés de licence pour rester cohérente avec l'existant. * @param {string} licenseShortName Libellé brut fourni par Commons. * @returns {string} Libellé normalisé. */ function normalizeLicenseName(licenseShortName) { if (licenseShortName === "Public domain") { return "Public Domain"; } return licenseShortName; } /** * Télécharge un fichier binaire distant sur le disque. * @param {string} url URL source. * @param {string} targetPath Chemin cible. */ async function downloadFile(url, targetPath) { const response = await fetch(url); if (!response.ok) { throw new Error(`Le téléchargement de ${url} a échoué avec le code ${response.status}.`); } const buffer = Buffer.from(await response.arrayBuffer()); await fs.writeFile(targetPath, buffer); } module.exports = { extractFileTitleFromUrl, fetchWikimediaAsset, extractAssetFromApiResponse, sanitizeMetadataText, buildAttribution, downloadFile, };