Files
ki-fwi/tools/data/ingest-commons.mjs
OKIandClaude Opus 5 945a524b44 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>
2026-07-26 23:47:27 -04:00

218 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});