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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user