218 lines
8.3 KiB
JavaScript
218 lines
8.3 KiB
JavaScript
#!/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/<id>.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(`<details><summary>${r.ecartes.length} fichier(s) écarté(s) automatiquement</summary>`, '');
|
|||
|
|
for (const x of r.ecartes) l.push(`- ${x.titre} — ${x.raison}`);
|
|||
|
|
l.push('', '</details>', '');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
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);
|
|||
|
|
});
|