356 lines
10 KiB
JavaScript
356 lines
10 KiB
JavaScript
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<string, any>} 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<string, any>} 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(/<br\s*\/?>/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,
|
|
};
|