Ajoute un script d'import d'images Wikimedia
This commit is contained in:
355
tools/lib/wikimedia.js
Normal file
355
tools/lib/wikimedia.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user