#!/usr/bin/env node /** * ingest-commons.mjs — recherche de photographies candidates sur Wikimedia Commons. * * FILIÈRE A UNIQUEMENT (ADR 0004) : ce script ne produit que des photographies réelles, * destinées aux épreuves d'identification. Aucun asset généré ne passe par ici. * * Ce que ce script fait : il interroge Commons, récupère pour chaque fichier l'auteur, * la licence, l'URL du fichier et l'URL de sa page de description, et écarte tout ce * dont la licence n'est pas explicitement libre. * * Ce que ce script NE fait PAS, et ne fera jamais : * — il ne télécharge aucun fichier ; * — il ne décide pas qu'une photo montre bien l'espèce annoncée. L'ordre des candidats * est celui de la pertinence du moteur de recherche de Commons, ce qui n'est pas un * jugement botanique. Tout candidat sort avec `valide: false` et le reste jusqu'à ce * qu'un humain compétent tranche (brief §2, ADR 0004). * * Sorties : * tools/data/out/commons-candidats.json — données brutes * docs/planche-relecture-medias.md — planche de relecture pour le porteur */ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)'; const API = 'https://commons.wikimedia.org/w/api.php'; /** Nombre de candidats remontés par taxon. Le porteur en retiendra au moins deux. */ const CANDIDATS_PAR_TAXON = 10; /** * Licences acceptées, par préfixe de `LicenseShortName` normalisé. * Commons n'héberge que du libre, mais on ne présume pas : on vérifie. * Le champ `sa` marque le partage à l'identique, qui contamine les dérivés — d'où * l'interdiction de compositing de l'ADR 0001. */ const LICENCES_OK = [ { motif: /^cc0/i, id: 'CC0-1.0', attribution: false, sa: false }, { motif: /^public domain|^pd-/i, id: 'PD', attribution: false, sa: false }, { motif: /^cc by-sa 4/i, id: 'CC-BY-SA-4.0', attribution: true, sa: true }, { motif: /^cc by-sa 3/i, id: 'CC-BY-SA-3.0', attribution: true, sa: true }, { motif: /^cc by-sa 2\.5/i, id: 'CC-BY-SA-2.5', attribution: true, sa: true }, { motif: /^cc by-sa 2/i, id: 'CC-BY-SA-2.0', attribution: true, sa: true }, { motif: /^cc by 4/i, id: 'CC-BY-4.0', attribution: true, sa: false }, { motif: /^cc by 3/i, id: 'CC-BY-3.0', attribution: true, sa: false }, { motif: /^cc by 2/i, id: 'CC-BY-2.0', attribution: true, sa: false } ]; /** Retire le balisage HTML que Commons renvoie dans les champs d'attribution. */ const texte = (html) => html ? String(html) .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() : null; function classerLicence(nomCourt) { if (!nomCourt) return null; return LICENCES_OK.find((l) => l.motif.test(nomCourt.trim())) ?? null; } async function getJson(params) { const u = new URL(API); for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); const res = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' } }); if (!res.ok) throw new Error(`Commons ${res.status} ${res.statusText}`); return res.json(); } async function chercher(nomScientifique) { const json = await getJson({ action: 'query', format: 'json', formatversion: '2', generator: 'search', gsrsearch: `"${nomScientifique}" filetype:bitmap`, gsrnamespace: '6', gsrlimit: String(CANDIDATS_PAR_TAXON * 2), prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiextmetadatafilter: 'LicenseShortName|LicenseUrl|Artist|Credit|ImageDescription|DateTimeOriginal' }); const pages = json.query?.pages ?? []; const retenus = []; const ecartes = []; for (const p of pages) { const info = p.imageinfo?.[0]; if (!info) continue; const meta = info.extmetadata ?? {}; const nomCourt = texte(meta.LicenseShortName?.value); const licence = classerLicence(nomCourt); const commun = { titre: p.title, auteur: texte(meta.Artist?.value) ?? texte(meta.Credit?.value), licence_libelle: nomCourt, url_fichier: info.url, url_description: info.descriptionurl, largeur: info.width, hauteur: info.height, mime: info.mime, description: texte(meta.ImageDescription?.value)?.slice(0, 220) ?? null, date_prise_de_vue: texte(meta.DateTimeOriginal?.value) }; if (!licence) { ecartes.push({ ...commun, raison: `licence non reconnue comme libre : ${nomCourt ?? 'absente'}` }); continue; } if (licence.attribution && !commun.auteur) { ecartes.push({ ...commun, raison: 'licence à attribution mais auteur introuvable dans les métadonnées' }); continue; } retenus.push({ ...commun, licence: licence.id, licence_url: texte(meta.LicenseUrl?.value), attribution_requise: licence.attribution, partage_identique: licence.sa }); } return { retenus: retenus.slice(0, CANDIDATS_PAR_TAXON), ecartes }; } async function main() { const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8')); const entrees = [ ...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })), ...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' })) ]; const resultats = []; for (const e of entrees) { process.stdout.write(`→ Commons : ${e.taxon.scientifique}… `); const { retenus, ecartes } = await chercher(e.taxon.scientifique); console.log(`${retenus.length} candidats, ${ecartes.length} écartés`); resultats.push({ id: e.id, categorie: e.categorie, taxon: e.taxon.scientifique, candidats: retenus, ecartes }); } await mkdir(join(ROOT, 'tools', 'data', 'out'), { recursive: true }); await writeFile( join(ROOT, 'tools', 'data', 'out', 'commons-candidats.json'), JSON.stringify({ genere_le: new Date().toISOString(), resultats }, null, 2) + '\n' ); const insuffisants = resultats.filter((r) => r.candidats.length < 2); const l = [ '# Planche de relecture des médias — filière A', '', `> Généré par \`npm run ingest:commons\` le ${new Date().toISOString().slice(0, 10)}.`, "> Sortie de script : ne pas éditer à la main, rejouer `npm run ingest:commons`.", '', '## Ce qui est demandé au porteur', '', "Pour chaque taxon, ouvrir les pages de description et retenir au moins deux photographies", "qui montrent réellement l'espèce. Reporter le choix dans `src/lib/data/fruits/.json`,", 'assigner à chaque média sa vue (`fruit_entier`, `coupe`, `feuille`, `ecorce`, `port`),', 'puis passer `valide` à `true` **et seulement alors**.', '', "Aucune vérification botanique n'a été faite par la machine : l'ordre ci-dessous est celui du", 'moteur de recherche de Commons, rien de plus.', '' ]; for (const r of resultats) { l.push(`## \`${r.id}\` — *${r.taxon}* (${r.categorie})`, ''); if (!r.candidats.length) { l.push('**Aucun candidat sous licence libre exploitable.** À photographier sur le terrain.', ''); continue; } l.push('| # | fichier | auteur | licence | dimensions | page de description |'); l.push('|---|---|---|---|---|---|'); r.candidats.forEach((c, i) => { const titre = c.titre.replace(/^File:/, '').replace(/\|/g, '\\|'); const auteur = (c.auteur ?? '—').replace(/\|/g, '\\|').slice(0, 60); l.push( `| ${i + 1} | ${titre.slice(0, 70)} | ${auteur} | ${c.licence} | ${c.largeur}×${c.hauteur} | [voir](${c.url_description}) |` ); }); l.push(''); if (r.ecartes.length) { l.push(`
${r.ecartes.length} fichier(s) écarté(s) automatiquement`, ''); for (const x of r.ecartes) l.push(`- ${x.titre} — ${x.raison}`); l.push('', '
', ''); } } if (insuffisants.length) { l.push('## Taxons sous le seuil de deux candidats', ''); for (const r of insuffisants) l.push(`- \`${r.id}\` (*${r.taxon}*) — ${r.candidats.length} candidat(s)`); l.push(''); } await writeFile(join(ROOT, 'docs', 'planche-relecture-medias.md'), l.join('\n')); const total = resultats.reduce((n, r) => n + r.candidats.length, 0); console.log(`\n✔ ${total} candidats sur ${resultats.length} taxons — 0 validé (état attendu).`); if (insuffisants.length) console.log(`⚠ ${insuffisants.length} taxon(s) sous deux candidats.`); console.log(' tools/data/out/commons-candidats.json'); console.log(' docs/planche-relecture-medias.md'); } main().catch((err) => { console.error('✘', err.message); process.exit(1); });