feat(session-0): cadre juridique, porte de validation et dataset vérifié

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>
This commit is contained in:
OKI
2026-07-26 23:47:27 -04:00
co-authored by Claude Opus 5
parent f0b3ce8484
commit 945a524b44
45 changed files with 8599 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
#!/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);
});