1

Ajoute un script d'import d'images Wikimedia

This commit is contained in:
2026-03-20 01:41:27 +01:00
parent b518d573bc
commit e11e4ee591
8 changed files with 761 additions and 151 deletions

355
tools/lib/wikimedia.js Normal file
View File

@@ -0,0 +1,355 @@
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,
};