Session 0 du plan — aucun code applicatif. Cadre - 5 ADR : licences, Phaser 4 hors budget initial, frontière Svelte/Phaser, filières A/B des assets, kréyòl langue principale (norme gwadloupéyenne). - LICENSE (AGPL-3.0-or-later, texte canonique gnu.org), LICENSE-CONTENT (CC BY-SA 4.0). Le jeu est une œuvre collective : aucun compositing sur un média SA ou NC, contrainte vérifiée par script. - Audit des 7 sources avec endpoints réellement appelés. TRAMIL vérifié en direct : « all rights reserved » et page de mentions légales en 404. iNaturalist mesuré à 93 % de CC BY-NC sur un échantillon de 305 photos. Outillage (tools/data/, hors bundle, zéro appel réseau au runtime) - verify:taxa, ingest:commons, build:dataset, validate:data, test:porte, build:credits, check:audio. - validate.mjs est la porte exigée par le brief §2 : le build échoue si une épreuve s'appuie sur un média non validé, non photographique ou de filière B, sur une attribution sans auteur, sur un compositing SA/NC, ou sur une entrée publiée sans relecture kréyòl. test:porte prouve les 9 refus + 1 témoin. Données - 13 entrées (8 fruits + 5 plantes du module Pa touché) vérifiées contre Wikidata ET GBIF : 13/13 ACCEPTED, 0 divergence avec la graine. - 130 candidats Commons sous licence libre, 39 pré-inscrits, 0 validé — aucun agent ne peut valider un média. Écarts signalés, non corrigés en silence - Le champ danger de la graine a deux formes (note vs resume) : normalisé. - 3 identifiants divergent du nom kréyòl au-delà des accents : non tranchés. - comestible:false sur akée et pomme-cajou : sous-estimation prudente assumée. - Le mancenillier n'a aucune photographie moderne dans ses 3 premiers candidats — l'entrée la plus critique est la moins bien servie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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);
|
||
});
|