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,131 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-credits.mjs — génère CREDITS.md depuis les données.
|
||||
*
|
||||
* Le brief §3 exige un CREDITS.md « généré depuis les données ». Un fichier de crédits
|
||||
* tenu à la main se désynchronise le jour où l'on remplace une photo, et une attribution
|
||||
* fausse est une violation de licence. Ce fichier n'est donc jamais édité : il se rejoue.
|
||||
*
|
||||
* Les médias sous clause non commerciale sont isolés dans leur propre section, comme
|
||||
* l'ADR 0001 l'impose.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const DATA = join(ROOT, 'src', 'lib', 'data', 'fruits');
|
||||
|
||||
const LIBELLE = {
|
||||
'CC0-1.0': 'CC0 1.0 (domaine public volontaire)',
|
||||
PD: 'domaine public',
|
||||
'CC-BY-2.0': 'CC BY 2.0',
|
||||
'CC-BY-3.0': 'CC BY 3.0',
|
||||
'CC-BY-4.0': 'CC BY 4.0',
|
||||
'CC-BY-SA-2.0': 'CC BY-SA 2.0',
|
||||
'CC-BY-SA-2.5': 'CC BY-SA 2.5',
|
||||
'CC-BY-SA-3.0': 'CC BY-SA 3.0',
|
||||
'CC-BY-SA-4.0': 'CC BY-SA 4.0',
|
||||
'CC-BY-NC-4.0': 'CC BY-NC 4.0',
|
||||
'CC-BY-NC-SA-4.0': 'CC BY-NC-SA 4.0',
|
||||
'OKI-CC-BY-SA-4.0': 'ORGANISATION KA INTERNATIONALE, CC BY-SA 4.0'
|
||||
};
|
||||
|
||||
const echapper = (s) => String(s ?? '—').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
|
||||
async function main() {
|
||||
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
|
||||
const entrees = [];
|
||||
for (const f of fichiers) entrees.push(JSON.parse(await readFile(join(DATA, f), 'utf8')));
|
||||
|
||||
const tous = entrees.flatMap((e) => e.medias.map((m) => ({ ...m, entree: e })));
|
||||
const nc = tous.filter((m) => m.usage_non_commercial);
|
||||
const sa = tous.filter((m) => m.partage_identique);
|
||||
|
||||
const l = [
|
||||
'# Crédits — KI FWI SA YÉ ?',
|
||||
'',
|
||||
'> **Fichier généré.** Ne pas éditer à la main : `npm run build:credits`.',
|
||||
`> Dernière génération : ${new Date().toISOString().slice(0, 10)}.`,
|
||||
'',
|
||||
'## Ce que couvre ce fichier',
|
||||
'',
|
||||
'Chaque média embarqué dans le jeu ou dans l’encyclopédie est listé ci-dessous avec son',
|
||||
'auteur, sa licence et le lien vers sa page de description d’origine. Rien n’est',
|
||||
'« libre de droit » : chaque ligne est une obligation contractuelle.',
|
||||
'',
|
||||
'Le code du projet est sous **AGPL-3.0-or-later** (`LICENSE`). Les textes rédigés par',
|
||||
'ORGANISATION KA INTERNATIONALE sont sous **CC BY-SA 4.0** (`LICENSE-CONTENT`). Les médias',
|
||||
'tiers restent sous leur licence propre : le jeu est une **œuvre collective**, pas une',
|
||||
'œuvre dérivée — voir `docs/adr/0001-licences.md`.',
|
||||
'',
|
||||
`## Chiffres`,
|
||||
'',
|
||||
`- ${entrees.length} entrées · ${tous.length} médias`,
|
||||
`- ${tous.filter((m) => m.valide).length} médias validés par un humain`,
|
||||
`- ${sa.length} médias en partage à l’identique (CC BY-SA) — **aucun compositing autorisé**`,
|
||||
`- ${nc.length} médias sous clause non commerciale (CC BY-NC)`,
|
||||
''
|
||||
];
|
||||
|
||||
if (nc.length) {
|
||||
l.push(
|
||||
'## ⚠ Médias sous clause non commerciale',
|
||||
'',
|
||||
'La présence de ces médias engage le projet à ne **jamais** être distribué à titre',
|
||||
'commercial — ni vente, ni version payante, ni licence à un éditeur. Décision prise et',
|
||||
'documentée dans `docs/adr/0001-licences.md`. Retirer ces médias est la seule façon de',
|
||||
'revenir en arrière.',
|
||||
'',
|
||||
'| entrée | média | auteur | licence | source |',
|
||||
'|---|---|---|---|---|'
|
||||
);
|
||||
for (const m of nc)
|
||||
l.push(
|
||||
`| \`${m.entree.id}\` | ${echapper(m.id)} | ${echapper(m.auteur)} | ${LIBELLE[m.licence] ?? m.licence} | [page](${m.url_description}) |`
|
||||
);
|
||||
l.push('');
|
||||
}
|
||||
|
||||
l.push('## Médias par entrée', '');
|
||||
for (const e of entrees) {
|
||||
l.push(`### ${e.noms.gcf} — *${e.taxon.scientifique}* (\`${e.id}\`)`, '');
|
||||
l.push(
|
||||
`Taxonomie vérifiée le ${e.taxon.verifie_le} contre [Wikidata ${e.taxon.wikidata}](https://www.wikidata.org/wiki/${e.taxon.wikidata})`,
|
||||
`et [GBIF ${e.taxon.gbif}](https://www.gbif.org/species/${e.taxon.gbif}).`,
|
||||
''
|
||||
);
|
||||
if (!e.medias.length) {
|
||||
l.push('_Aucun média._', '');
|
||||
continue;
|
||||
}
|
||||
l.push('| média | vue | auteur | licence | validé | page d’origine |');
|
||||
l.push('|---|---|---|---|---|---|');
|
||||
for (const m of e.medias)
|
||||
l.push(
|
||||
`| ${echapper(m.id)} | ${m.vue ?? '—'} | ${echapper(m.auteur)} | ${LIBELLE[m.licence] ?? m.licence} | ${m.valide ? `oui (${echapper(m.valide_par)})` : 'non'} | [voir](${m.url_description}) |`
|
||||
);
|
||||
l.push('');
|
||||
}
|
||||
|
||||
l.push(
|
||||
'## Sources factuelles',
|
||||
'',
|
||||
'- **Wikidata** — identifiants et taxonomie (CC0). https://www.wikidata.org',
|
||||
'- **GBIF** — noms scientifiques acceptés et clés d’usage. https://www.gbif.org',
|
||||
'- **Wikimedia Commons** — photographies. https://commons.wikimedia.org',
|
||||
'',
|
||||
'Voir `SOURCES.md` pour le détail des régimes de licence et `docs/audit-sources.md`',
|
||||
'pour l’audit complet, TRAMIL et CIRAD compris.',
|
||||
''
|
||||
);
|
||||
|
||||
await writeFile(join(ROOT, 'CREDITS.md'), l.join('\n'));
|
||||
console.log(`✔ CREDITS.md — ${entrees.length} entrées, ${tous.length} médias, ${nc.length} sous clause NC.`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('✘', e.stack ?? e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-dataset.mjs — compose le dataset à partir de la graine et des sorties vérifiées.
|
||||
*
|
||||
* fruits.seed.json (contenu éditorial d'amorçage)
|
||||
* + tools/data/out/taxa-verified.json (Wikidata + GBIF, réponses d'API)
|
||||
* + tools/data/out/commons-candidats.json (médias sous licence libre)
|
||||
* → src/lib/data/fruits/<id>.json
|
||||
*
|
||||
* AMORÇAGE SEUL. Une fois les fichiers écrits, ils sont édités à la main par le porteur
|
||||
* (choix des photos, assignation des vues, passage de `valide` à true, corrections kréyòl).
|
||||
* Le script refuse donc d'écraser un fichier existant sans `--force`, pour ne jamais
|
||||
* détruire une validation humaine.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const DEST = join(ROOT, 'src', 'lib', 'data', 'fruits');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
|
||||
/** Nombre de médias candidats pré-inscrits par entrée. Le brief §8 en exige au moins deux. */
|
||||
const MEDIAS_PAR_ENTREE = 3;
|
||||
|
||||
/** Licences à attribution — recopié de la classification d'ingest-commons.mjs. */
|
||||
const SANS_ATTRIBUTION = new Set(['CC0-1.0', 'PD']);
|
||||
const AVEC_SA = new Set(['CC-BY-SA-2.0', 'CC-BY-SA-2.5', 'CC-BY-SA-3.0', 'CC-BY-SA-4.0', 'CC-BY-NC-SA-4.0']);
|
||||
const AVEC_NC = new Set(['CC-BY-NC-4.0', 'CC-BY-NC-SA-4.0']);
|
||||
|
||||
const slug = (s) =>
|
||||
s
|
||||
.replace(/^File:/, '')
|
||||
.replace(/\.[a-z0-9]+$/i, '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60)
|
||||
.replace(/-+$/, '');
|
||||
|
||||
/**
|
||||
* Une planche gravée du XVIIIe siècle est exacte mais stylisée : elle ne peut pas servir
|
||||
* d'épreuve de reconnaissance du réel. On la détecte grossièrement — c'est un pré-tri,
|
||||
* pas un verdict : le porteur corrige le champ à la relecture.
|
||||
*/
|
||||
const INDICES_PLANCHE = /blanco|merian|k[oö]hler|flora|sylva|botanical|plate|lithograph|engrav|descourtilz|1[6-9]\d\d/i;
|
||||
|
||||
function typeMedia(c) {
|
||||
// La date de prise de vue fait partie du texte examiné : sans elle, la planche de
|
||||
// Jacquin (1780) ressortait en « photo » — son titre et son auteur ne disent rien
|
||||
// de son siècle. Constaté en session 0, corrigé ici.
|
||||
const hay = `${c.titre} ${c.description ?? ''} ${c.auteur ?? ''} ${c.date_prise_de_vue ?? ''}`;
|
||||
if (INDICES_PLANCHE.test(hay)) return 'planche_botanique';
|
||||
|
||||
// La photographie de terrain n'existe pas avant le XXe siècle dans ces fonds.
|
||||
const annee = Number((c.date_prise_de_vue ?? '').match(/\b(1[5-9]\d\d)\b/)?.[1]);
|
||||
if (annee && annee < 1900) return 'planche_botanique';
|
||||
|
||||
return 'photo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Écart constaté dans la graine : les dangers de niveau « vigilance » (kénèt, pòm-kannèl)
|
||||
* portent leur texte dans `note`, les niveaux « élevé » et « critique » dans `resume`.
|
||||
* Le schéma impose un champ unique — on normalise vers `resume` sans rien réécrire.
|
||||
* Écart signalé dans docs/sessions/00-cadre-et-donnees.md, conformément au brief §9.
|
||||
*/
|
||||
function normaliserDanger(d) {
|
||||
if (!d) return null;
|
||||
const { note, resume, ...reste } = d;
|
||||
return { ...reste, resume: resume ?? note };
|
||||
}
|
||||
|
||||
/** Deux planches Blanco tronquées au même slug : on désambiguïse plutôt que de perdre un média. */
|
||||
function idsUniques(medias) {
|
||||
const vus = new Map();
|
||||
return medias.map((m) => {
|
||||
const n = (vus.get(m.id) ?? 0) + 1;
|
||||
vus.set(m.id, n);
|
||||
return n === 1 ? m : { ...m, id: `${m.id}-${n}` };
|
||||
});
|
||||
}
|
||||
|
||||
async function existe(p) {
|
||||
try {
|
||||
await access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8'));
|
||||
const taxa = JSON.parse(await readFile(join(ROOT, 'tools/data/out/taxa-verified.json'), 'utf8'));
|
||||
const medias = JSON.parse(await readFile(join(ROOT, 'tools/data/out/commons-candidats.json'), 'utf8'));
|
||||
|
||||
const parId = (arr) => new Map(arr.map((r) => [r.id, r]));
|
||||
const T = parId(taxa.resultats);
|
||||
const M = parId(medias.resultats);
|
||||
const dateVerif = taxa.genere_le.slice(0, 10);
|
||||
|
||||
const entrees = [
|
||||
...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })),
|
||||
...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' }))
|
||||
];
|
||||
|
||||
await mkdir(DEST, { recursive: true });
|
||||
let ecrits = 0;
|
||||
let ignores = 0;
|
||||
|
||||
for (const e of entrees) {
|
||||
const chemin = join(DEST, `${e.id}.json`);
|
||||
if (!FORCE && (await existe(chemin))) {
|
||||
console.log(`· ${e.id} — existe déjà, conservé (--force pour écraser)`);
|
||||
ignores++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const t = T.get(e.id);
|
||||
if (!t?.wikidata?.qid || !t?.gbif?.usageKey) {
|
||||
console.error(`✘ ${e.id} — taxonomie non vérifiée, entrée non écrite`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidats = idsUniques(
|
||||
(M.get(e.id)?.candidats ?? []).slice(0, MEDIAS_PAR_ENTREE).map((c) => ({
|
||||
id: slug(c.titre),
|
||||
filiere: 'A',
|
||||
type_media: typeMedia(c),
|
||||
vue: null,
|
||||
usages: [],
|
||||
source: 'wikimedia_commons',
|
||||
auteur: c.auteur,
|
||||
licence: c.licence,
|
||||
licence_url: c.licence_url ?? null,
|
||||
url_fichier: c.url_fichier,
|
||||
url_description: c.url_description,
|
||||
largeur: c.largeur ?? null,
|
||||
hauteur: c.hauteur ?? null,
|
||||
mime: c.mime ?? null,
|
||||
description: c.description ?? null,
|
||||
date_prise_de_vue: c.date_prise_de_vue ?? null,
|
||||
attribution_requise: !SANS_ATTRIBUTION.has(c.licence),
|
||||
partage_identique: AVEC_SA.has(c.licence),
|
||||
usage_non_commercial: AVEC_NC.has(c.licence),
|
||||
composite: false,
|
||||
valide: false,
|
||||
valide_par: null,
|
||||
valide_le: null,
|
||||
note_validation: null
|
||||
}))
|
||||
);
|
||||
|
||||
const entree = {
|
||||
$schema: '../../../../fruits.schema.json',
|
||||
id: e.id,
|
||||
categorie: e.categorie,
|
||||
noms: e.noms,
|
||||
orthographe: { norme: 'gwadloupeyen', statut: 'a_relire', relu_par: null },
|
||||
taxon: {
|
||||
scientifique: t.gbif.canonicalName ?? e.taxon.scientifique,
|
||||
famille: t.gbif.famille ?? e.taxon.famille,
|
||||
wikidata: t.wikidata.qid,
|
||||
gbif: t.gbif.usageKey,
|
||||
verifie_le: dateVerif,
|
||||
verifie_contre: ['wikidata', 'gbif']
|
||||
},
|
||||
iles: e.iles,
|
||||
...(e.categorie === 'fruit' ? { saison_mois: e.saison_mois } : {}),
|
||||
// Les cinq plantes du module « Pa touché » sortent à comestible: false.
|
||||
// C'est le défaut prudent, pas la vérité complète : l'akée mûre et la pomme-cajou
|
||||
// se mangent sous conditions strictes. La nuance vit dans danger.resume et doit
|
||||
// être arbitrée par un humain avant publication (voir docs/a-arbitrer.md).
|
||||
comestible: e.categorie === 'fruit' ? (e.comestible ?? true) : false,
|
||||
danger: normaliserDanger(e.danger),
|
||||
indices: e.indices ?? {},
|
||||
medias: candidats,
|
||||
audio: { gcf: null, locuteur: null },
|
||||
sources_factuelles: [],
|
||||
valide: false
|
||||
};
|
||||
|
||||
if (e.$comment) entree.$comment = e.$comment;
|
||||
|
||||
await writeFile(chemin, JSON.stringify(entree, null, '\t') + '\n');
|
||||
console.log(`✔ ${e.id} — ${candidats.length} médias, ${entree.taxon.wikidata} / ${entree.taxon.gbif}`);
|
||||
ecrits++;
|
||||
}
|
||||
|
||||
console.log(`\n${ecrits} entrée(s) écrite(s), ${ignores} conservée(s).`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('✘', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-audio.mjs — liste les enregistrements kréyòl manquants.
|
||||
*
|
||||
* Le brief §6 le demande explicitement : « prévois le contrat (audio.gcf, format .opus,
|
||||
* repli silencieux si le fichier manque) et un script de vérification listant les
|
||||
* enregistrements manquants. »
|
||||
*
|
||||
* Pas de synthèse vocale : ce script n'en fabrique aucune. Il produit une liste de courses
|
||||
* pour la séance d'enregistrement — une après-midi de terrain vaut dix heures de code.
|
||||
*
|
||||
* Ce script n'échoue jamais : un enregistrement manquant est un travail à faire,
|
||||
* pas un défaut de build. Le jeu doit se replier en silence.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, writeFile, access } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const DATA = join(ROOT, 'src', 'lib', 'data', 'fruits');
|
||||
const STATIC = join(ROOT, 'static');
|
||||
|
||||
async function existe(p) {
|
||||
try {
|
||||
await access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
|
||||
const manquants = [];
|
||||
const absents = [];
|
||||
const presents = [];
|
||||
|
||||
for (const f of fichiers) {
|
||||
const e = JSON.parse(await readFile(join(DATA, f), 'utf8'));
|
||||
if (!e.audio.gcf) {
|
||||
manquants.push(e);
|
||||
continue;
|
||||
}
|
||||
// Le chemin est absolu depuis la racine du site : /audio/<id>.opus → static/audio/<id>.opus
|
||||
if (await existe(join(STATIC, e.audio.gcf))) presents.push(e);
|
||||
else absents.push(e);
|
||||
}
|
||||
|
||||
const l = [
|
||||
'# Enregistrements kréyòl à réaliser',
|
||||
'',
|
||||
'> Généré par `npm run check:audio`. Aucune synthèse vocale : ces noms doivent être',
|
||||
'> prononcés par une voix humaine locale (brief §6).',
|
||||
'',
|
||||
"Format attendu : `.opus` — un fichier par entrée, déposé dans `static/audio/<id>.opus`.",
|
||||
'',
|
||||
`## À enregistrer (${manquants.length})`,
|
||||
''
|
||||
];
|
||||
for (const e of manquants) l.push(`- [ ] **${e.noms.gcf}** — \`static/audio/${e.id}.opus\` _(${e.noms.fr ?? ''})_`);
|
||||
l.push('');
|
||||
if (absents.length) {
|
||||
l.push(`## Déclarés mais fichier introuvable (${absents.length})`, '');
|
||||
for (const e of absents) l.push(`- ⚠ \`${e.id}\` → \`${e.audio.gcf}\` absent de \`static/\``);
|
||||
l.push('');
|
||||
}
|
||||
if (presents.length) {
|
||||
l.push(`## En place (${presents.length})`, '');
|
||||
for (const e of presents) l.push(`- ✔ \`${e.id}\` — ${e.audio.locuteur ?? 'locuteur non renseigné'}`);
|
||||
l.push('');
|
||||
}
|
||||
|
||||
await writeFile(join(ROOT, 'docs', 'audio-a-enregistrer.md'), l.join('\n'));
|
||||
|
||||
console.log(`À enregistrer : ${manquants.length}`);
|
||||
console.log(`Introuvables : ${absents.length}`);
|
||||
console.log(`En place : ${presents.length}`);
|
||||
console.log('\n docs/audio-a-enregistrer.md');
|
||||
if (absents.length) console.log("\n⚠ Des chemins audio sont déclarés sans fichier : le jeu se repliera en silence.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('✘', e.stack ?? e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"genere_le": "2026-07-27T03:46:04.075Z",
|
||||
"resultats": [
|
||||
{
|
||||
"id": "kowosol",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Annona muricata",
|
||||
"famille": "Annonaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q1709343",
|
||||
"famille": "Annonaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 5407273,
|
||||
"scientificName": "Annona muricata L.",
|
||||
"canonicalName": "Annona muricata",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 97,
|
||||
"famille": "Annonaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "kenet",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Melicoccus bijugatus",
|
||||
"famille": "Sapindaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q279728",
|
||||
"famille": "Sapindaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3189990,
|
||||
"scientificName": "Melicoccus bijugatus Jacq.",
|
||||
"canonicalName": "Melicoccus bijugatus",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Sapindaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "gouyav",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Psidium guajava",
|
||||
"famille": "Myrtaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q166843",
|
||||
"famille": "Myrtaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 5420380,
|
||||
"scientificName": "Psidium guajava L.",
|
||||
"canonicalName": "Psidium guajava",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Myrtaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "pom-site",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Spondias dulcis",
|
||||
"famille": "Anacardiaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q500383",
|
||||
"famille": "Anacardiaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3190595,
|
||||
"scientificName": "Spondias dulcis Parkinson",
|
||||
"canonicalName": "Spondias dulcis",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 97,
|
||||
"famille": "Anacardiaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "zikak",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Chrysobalanus icaco",
|
||||
"famille": "Chrysobalanaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q577669",
|
||||
"famille": "Chrysobalanaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 2985082,
|
||||
"scientificName": "Chrysobalanus icaco L.",
|
||||
"canonicalName": "Chrysobalanus icaco",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Chrysobalanaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "pom-kannel",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Annona squamosa",
|
||||
"famille": "Annonaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q275620",
|
||||
"famille": "Annonaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 5407099,
|
||||
"scientificName": "Annona squamosa L.",
|
||||
"canonicalName": "Annona squamosa",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 97,
|
||||
"famille": "Annonaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "fwiyapen",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Artocarpus altilis",
|
||||
"famille": "Moraceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q14677",
|
||||
"famille": "Moraceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 2984573,
|
||||
"scientificName": "Artocarpus altilis (Parkinson) Fosberg",
|
||||
"canonicalName": "Artocarpus altilis",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 97,
|
||||
"famille": "Moraceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "pom-dlo",
|
||||
"categorie": "fruit",
|
||||
"graine": {
|
||||
"scientifique": "Syzygium samarangense",
|
||||
"famille": "Myrtaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q11643111",
|
||||
"famille": "Myrtaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3183180,
|
||||
"scientificName": "Syzygium samarangense (Blume) Merr. & L.M.Perry",
|
||||
"canonicalName": "Syzygium samarangense",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Myrtaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "mansiniye",
|
||||
"categorie": "plante_dangereuse",
|
||||
"graine": {
|
||||
"scientifique": "Hippomane mancinella",
|
||||
"famille": "Euphorbiaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q636480",
|
||||
"famille": "Euphorbiaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3063705,
|
||||
"scientificName": "Hippomane mancinella L.",
|
||||
"canonicalName": "Hippomane mancinella",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Euphorbiaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "ake",
|
||||
"categorie": "plante_dangereuse",
|
||||
"graine": {
|
||||
"scientifique": "Blighia sapida",
|
||||
"famille": "Sapindaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q417145",
|
||||
"famille": "Sapindaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3189955,
|
||||
"scientificName": "Blighia sapida K.D.Koenig",
|
||||
"canonicalName": "Blighia sapida",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Sapindaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "pom-kajou",
|
||||
"categorie": "plante_dangereuse",
|
||||
"graine": {
|
||||
"scientifique": "Anacardium occidentale",
|
||||
"famille": "Anacardiaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q34007",
|
||||
"famille": "Anacardiaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 5421368,
|
||||
"scientificName": "Anacardium occidentale L.",
|
||||
"canonicalName": "Anacardium occidentale",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 98,
|
||||
"famille": "Anacardiaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "medisinye",
|
||||
"categorie": "plante_dangereuse",
|
||||
"graine": {
|
||||
"scientifique": "Jatropha curcas",
|
||||
"famille": "Euphorbiaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q74644",
|
||||
"famille": "Euphorbiaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3072953,
|
||||
"scientificName": "Jatropha curcas L.",
|
||||
"canonicalName": "Jatropha curcas",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 98,
|
||||
"famille": "Euphorbiaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
},
|
||||
{
|
||||
"id": "lorye-jonn",
|
||||
"categorie": "plante_dangereuse",
|
||||
"graine": {
|
||||
"scientifique": "Cascabela thevetia",
|
||||
"famille": "Apocynaceae"
|
||||
},
|
||||
"wikidata": {
|
||||
"qid": "Q10694475",
|
||||
"famille": "Apocynaceae",
|
||||
"rang": "espèce"
|
||||
},
|
||||
"wikidata_pistes": null,
|
||||
"gbif": {
|
||||
"usageKey": 3169652,
|
||||
"scientificName": "Cascabela thevetia (L.) Lippold",
|
||||
"canonicalName": "Cascabela thevetia",
|
||||
"rank": "SPECIES",
|
||||
"status": "ACCEPTED",
|
||||
"matchType": "EXACT",
|
||||
"confidence": 99,
|
||||
"famille": "Apocynaceae",
|
||||
"accepte": null
|
||||
},
|
||||
"ecarts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-porte.mjs — prouve que la porte se ferme.
|
||||
*
|
||||
* Une règle de sécurité qu'on n'a jamais vue refuser quoi que ce soit n'est pas une règle,
|
||||
* c'est une intention. Ce test fabrique les cas interdits par le brief et vérifie que
|
||||
* `validate.mjs` les rejette bien avec un code de sortie non nul.
|
||||
*
|
||||
* Chaque cas correspond à une phrase du brief, citée en regard.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const BAC = join('tools', 'data', 'out', 'test-porte');
|
||||
|
||||
/** Applique une mutation à une copie profonde de l'entrée de référence. */
|
||||
const muter = (base, fn) => {
|
||||
const c = structuredClone(base);
|
||||
fn(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
/** Un média de référence, valide et utilisable comme épreuve. */
|
||||
const mediaValide = (over = {}) => ({
|
||||
id: 'photo-temoin',
|
||||
filiere: 'A',
|
||||
type_media: 'photo',
|
||||
vue: 'fruit_entier',
|
||||
usages: ['epreuve'],
|
||||
source: 'wikimedia_commons',
|
||||
auteur: 'Témoin',
|
||||
licence: 'CC-BY-SA-4.0',
|
||||
licence_url: null,
|
||||
url_fichier: 'https://upload.wikimedia.org/temoin.jpg',
|
||||
url_description: 'https://commons.wikimedia.org/wiki/File:temoin.jpg',
|
||||
largeur: 800,
|
||||
hauteur: 600,
|
||||
mime: 'image/jpeg',
|
||||
description: null,
|
||||
date_prise_de_vue: null,
|
||||
attribution_requise: true,
|
||||
partage_identique: true,
|
||||
usage_non_commercial: false,
|
||||
composite: false,
|
||||
valide: true,
|
||||
valide_par: 'porteur',
|
||||
valide_le: '2026-07-27',
|
||||
note_validation: null
|
||||
});
|
||||
|
||||
const CAS = [
|
||||
{
|
||||
nom: 'épreuve sur un média non validé',
|
||||
regle: 'brief §2 — « Le build échoue si un média non validé est référencé par une épreuve. »',
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide({}), mediaValide()];
|
||||
e.medias[0].id = 'photo-non-validee';
|
||||
e.medias[0].valide = false;
|
||||
e.medias[0].valide_par = null;
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'épreuve sur un asset généré (filière B)',
|
||||
regle: "brief §10 — « Aucune épreuve d'identification ne s'appuie sur un asset IA. »",
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.medias[0].filiere = 'B';
|
||||
e.medias[0].type_media = 'image_generee';
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'épreuve sur une planche botanique',
|
||||
regle: "brief §2 — filière A = photographie réelle ; une gravure documente mais n'entraîne pas la reconnaissance du réel.",
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.medias[0].type_media = 'planche_botanique';
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'épreuve portée par une plante dangereuse',
|
||||
regle: 'brief §6 — « Pa touché : jamais chronométré, jamais scoré, jamais transformé en épreuve. »',
|
||||
muter: (e) => {
|
||||
e.categorie = 'plante_dangereuse';
|
||||
e.danger = { niveau: 'critique', resume: 'Ne jamais toucher ni manger ce fruit.' };
|
||||
delete e.saison_mois;
|
||||
e.comestible = false;
|
||||
e.medias = [mediaValide()];
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'média à attribution sans auteur',
|
||||
regle: 'brief §3 — « Récupérer auteur, licence, URL du fichier, URL de la page de description. »',
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.medias[0].auteur = null;
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'compositing sur un média en partage à l’identique',
|
||||
regle: "ADR 0001 — le SA contamine les dérivés, le NC est incompatible avec le SA : aucun compositing.",
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.medias[0].composite = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'entrée publiée avec une seule photo validée',
|
||||
regle: 'brief §8 — « ≥ 2 photos Commons par fruit avec auteur, licence et URL. »',
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.orthographe = { norme: 'gwadloupeyen', statut: 'relu', relu_par: 'relecteur' };
|
||||
e.valide = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'entrée publiée sans relecture kréyòl',
|
||||
regle: "brief §5 — « L'orthographe kréyòl n'est pas de ton ressort. »",
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide(), muter(mediaValide(), (m) => (m.id = 'photo-temoin-2'))];
|
||||
e.valide = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
nom: 'validation anonyme',
|
||||
regle: 'porte du projet — une validation sans nom n’engage personne.',
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide()];
|
||||
e.medias[0].valide_par = null;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/** Cas témoin : doit passer. Un test qui échoue toujours ne prouve rien non plus. */
|
||||
const TEMOIN = {
|
||||
nom: 'TÉMOIN — entrée correcte, doit passer',
|
||||
attendu: 0,
|
||||
muter: (e) => {
|
||||
e.medias = [mediaValide(), muter(mediaValide(), (m) => (m.id = 'photo-temoin-2'))];
|
||||
e.orthographe = { norme: 'gwadloupeyen', statut: 'relu', relu_par: 'relecteur' };
|
||||
e.valide = true;
|
||||
}
|
||||
};
|
||||
|
||||
async function jouer(cas, base, i) {
|
||||
const dir = join(BAC, `cas-${i}`);
|
||||
await mkdir(join(ROOT, dir), { recursive: true });
|
||||
await writeFile(join(ROOT, dir, `${base.id}.json`), JSON.stringify(muter(base, cas.muter), null, '\t'));
|
||||
|
||||
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'data', 'validate.mjs')], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, KI_FWI_DATA: dir },
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
const attendu = cas.attendu ?? 1;
|
||||
const obtenu = r.status;
|
||||
const ok = obtenu === attendu;
|
||||
console.log(`${ok ? '✔' : '✘'} ${cas.nom}`);
|
||||
if (cas.regle) console.log(` ${cas.regle}`);
|
||||
if (!ok) {
|
||||
console.log(` attendu code ${attendu}, obtenu ${obtenu}`);
|
||||
console.log((r.stderr || r.stdout).split('\n').slice(-8).join('\n'));
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const base = JSON.parse(await readFile(join(ROOT, 'src/lib/data/fruits/kowosol.json'), 'utf8'));
|
||||
await rm(join(ROOT, BAC), { recursive: true, force: true });
|
||||
|
||||
console.log('\nTest de la porte de validation\n');
|
||||
const resultats = [];
|
||||
resultats.push(await jouer(TEMOIN, base, 0));
|
||||
for (const [i, cas] of CAS.entries()) resultats.push(await jouer(cas, base, i + 1));
|
||||
|
||||
await rm(join(ROOT, BAC), { recursive: true, force: true });
|
||||
|
||||
const echecs = resultats.filter((r) => !r).length;
|
||||
console.log(`\n${resultats.length - echecs}/${resultats.length} cas conformes.`);
|
||||
if (echecs) {
|
||||
console.error(`✘ ${echecs} cas non couvert(s) : la porte laisse passer ce qu'elle devrait refuser.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✔ La porte refuse tout ce que le brief interdit.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('✘', e.stack ?? e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* validate.mjs — LA PORTE. Ce script est la règle n°1 du projet écrite en code.
|
||||
*
|
||||
* Le brief §2 l'exige mot pour mot : « Le build échoue si un média non validé est
|
||||
* référencé par une épreuve. Écris ce contrôle comme un script de build, pas comme
|
||||
* une consigne. » Ce fichier est ce contrôle.
|
||||
*
|
||||
* Il vérifie deux couches :
|
||||
* 1. la forme, contre fruits.schema.json (Ajv) ;
|
||||
* 2. les règles que le schéma ne peut pas exprimer — filières, licences, sécurité.
|
||||
*
|
||||
* Sortie non nulle = build refusé. Aucune de ces règles ne se contourne par un flag.
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
/** Surchargeable pour les tests de la porte (tools/data/test-porte.mjs). */
|
||||
const DATA = process.env.KI_FWI_DATA ? join(ROOT, process.env.KI_FWI_DATA) : join(ROOT, 'src', 'lib', 'data', 'fruits');
|
||||
|
||||
/** Usages qui servent à identifier une espèce. Ce sont eux qui déclenchent la porte. */
|
||||
const USAGES_IDENTIFIANTS = new Set(['epreuve', 'indice']);
|
||||
|
||||
const erreurs = [];
|
||||
const alertes = [];
|
||||
const err = (fichier, msg) => erreurs.push(`${fichier} — ${msg}`);
|
||||
const warn = (fichier, msg) => alertes.push(`${fichier} — ${msg}`);
|
||||
|
||||
async function main() {
|
||||
const schema = JSON.parse(await readFile(join(ROOT, 'fruits.schema.json'), 'utf8'));
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
const valider = ajv.compile(schema);
|
||||
|
||||
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
|
||||
if (!fichiers.length) {
|
||||
console.error('✘ Aucune entrée dans src/lib/data/fruits/.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const vus = new Map();
|
||||
const entrees = [];
|
||||
let nbMedias = 0;
|
||||
let nbMediasValides = 0;
|
||||
let nbNC = 0;
|
||||
|
||||
for (const f of fichiers) {
|
||||
const brut = await readFile(join(DATA, f), 'utf8');
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(brut);
|
||||
} catch (ex) {
|
||||
err(f, `JSON illisible : ${ex.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Couche 1 : la forme
|
||||
if (!valider(e)) {
|
||||
for (const d of valider.errors ?? []) err(f, `schéma : ${d.instancePath || '/'} ${d.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Couche 2 : les règles métier
|
||||
if (e.id !== basename(f, '.json')) err(f, `id « ${e.id} » ≠ nom de fichier`);
|
||||
if (vus.has(e.id)) err(f, `id « ${e.id} » déjà utilisé par ${vus.get(e.id)}`);
|
||||
vus.set(e.id, f);
|
||||
|
||||
const idsMedias = new Set();
|
||||
for (const m of e.medias) {
|
||||
nbMedias++;
|
||||
if (m.valide) nbMediasValides++;
|
||||
if (m.usage_non_commercial) nbNC++;
|
||||
|
||||
if (idsMedias.has(m.id)) err(f, `média « ${m.id} » en double`);
|
||||
idsMedias.add(m.id);
|
||||
|
||||
const identifiant = m.usages.some((u) => USAGES_IDENTIFIANTS.has(u));
|
||||
|
||||
// LA PORTE — brief §2 et §10.
|
||||
if (identifiant) {
|
||||
if (m.filiere !== 'A')
|
||||
err(f, `média « ${m.id} » sert à identifier mais relève de la filière ${m.filiere} : une image générée n'enseigne pas le réel`);
|
||||
if (m.type_media !== 'photo')
|
||||
err(f, `média « ${m.id} » sert à identifier mais n'est pas une photo (${m.type_media})`);
|
||||
if (!m.valide)
|
||||
err(f, `média « ${m.id} » sert à identifier mais n'est pas validé — aucun humain n'a confirmé qu'il montre bien ${e.taxon.scientifique}`);
|
||||
if (!m.vue) err(f, `média « ${m.id} » sert à identifier mais sa vue n'est pas renseignée`);
|
||||
}
|
||||
|
||||
// Cohérence des filières
|
||||
if (m.filiere === 'B' && m.type_media !== 'image_generee')
|
||||
err(f, `média « ${m.id} » : filière B impose type_media = image_generee`);
|
||||
if (m.filiere === 'B' && m.usages.some((u) => u !== 'decor'))
|
||||
err(f, `média « ${m.id} » : un média de filière B ne peut servir qu'au décor`);
|
||||
if (m.filiere === 'A' && m.type_media === 'image_generee')
|
||||
err(f, `média « ${m.id} » : filière A n'accepte aucune image générée`);
|
||||
|
||||
// Licences — brief §3
|
||||
if (m.attribution_requise && !m.auteur)
|
||||
err(f, `média « ${m.id} » : licence ${m.licence} à attribution, auteur manquant`);
|
||||
if (m.composite && (m.partage_identique || m.usage_non_commercial))
|
||||
err(f, `média « ${m.id} » : compositing interdit sur un média SA ou NC (conflit de licence, ADR 0001)`);
|
||||
if (m.valide && !m.valide_par)
|
||||
err(f, `média « ${m.id} » : validé sans nom de validateur — une validation anonyme n'en est pas une`);
|
||||
|
||||
// Signalement, pas blocage : l'ADR 0001 accepte le NC contre un engagement non commercial.
|
||||
if (m.usage_non_commercial && m.source === 'wikimedia_commons')
|
||||
warn(f, `média « ${m.id} » : NC annoncé sur une source Commons — vérifier, Commons n'héberge pas de NC`);
|
||||
}
|
||||
|
||||
// Sécurité enfants — brief §6 : « Pa touché » n'est jamais une épreuve, jamais scoré.
|
||||
if (e.categorie === 'plante_dangereuse') {
|
||||
if (!e.danger) err(f, 'plante dangereuse sans bloc danger');
|
||||
for (const m of e.medias)
|
||||
if (m.usages.includes('epreuve'))
|
||||
err(f, `média « ${m.id} » : une plante dangereuse ne peut pas porter d'épreuve — le module Pa touché n'est ni chronométré ni scoré`);
|
||||
}
|
||||
|
||||
// Conditions de publication d'une entrée
|
||||
if (e.valide) {
|
||||
const photos = e.medias.filter((m) => m.valide && m.filiere === 'A' && m.type_media === 'photo');
|
||||
if (photos.length < 2)
|
||||
err(f, `entrée déclarée valide avec ${photos.length} photo(s) validée(s) — le brief §8 en exige au moins deux`);
|
||||
if (e.orthographe.statut !== 'relu')
|
||||
err(f, "entrée déclarée valide alors que l'orthographe kréyòl n'est pas relue (ADR 0005)");
|
||||
} else {
|
||||
warn(f, 'entrée non encore validée pour publication');
|
||||
}
|
||||
|
||||
if (!e.audio.gcf) warn(f, 'aucun enregistrement kréyòl — le jeu devra se replier en silence');
|
||||
if (e.orthographe.statut === 'a_relire') warn(f, 'orthographe kréyòl à relire');
|
||||
|
||||
entrees.push(e);
|
||||
}
|
||||
|
||||
// ── Rapport
|
||||
console.log(`\nEntrées : ${entrees.length}`);
|
||||
console.log(`Médias : ${nbMedias} (${nbMediasValides} validés, ${nbNC} sous clause NC)`);
|
||||
console.log(`Publiables : ${entrees.filter((e) => e.valide).length}`);
|
||||
|
||||
if (alertes.length) {
|
||||
console.log(`\n⚠ ${alertes.length} alerte(s) — n'empêchent pas le build :`);
|
||||
for (const a of alertes) console.log(` · ${a}`);
|
||||
}
|
||||
|
||||
if (erreurs.length) {
|
||||
console.error(`\n✘ ${erreurs.length} erreur(s) — build refusé :`);
|
||||
for (const e of erreurs) console.error(` · ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n✔ Dataset conforme. Aucune épreuve ne s’appuie sur un média non validé.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('✘', e.stack ?? e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-taxa.mjs — vérification taxonomique contre Wikidata et GBIF.
|
||||
*
|
||||
* Le brief §8 exige que chaque champ taxonomique soit vérifié contre Wikidata ET GBIF,
|
||||
* « pas contre ta mémoire ». Ce script est ce contrôle : il lit les noms scientifiques
|
||||
* d'amorçage, interroge les deux référentiels, et écrit un rapport de divergence.
|
||||
*
|
||||
* Il ne corrige rien tout seul : il constate. Les corrections sont reportées à la main
|
||||
* dans src/lib/data/fruits/*.json, avec la trace de la réponse d'API en regard.
|
||||
*
|
||||
* Sorties :
|
||||
* tools/data/out/taxa-verified.json — données brutes des deux API
|
||||
* docs/verification-taxons.md — rapport lisible, versionné
|
||||
*
|
||||
* Hors ligne : ce script vit dans tools/, jamais dans le bundle. Aucun appel réseau
|
||||
* ne subsiste au runtime du jeu (playbook P2 : bake au build).
|
||||
*/
|
||||
|
||||
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 WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql';
|
||||
const WIKIDATA_API = 'https://www.wikidata.org/w/api.php';
|
||||
const GBIF_MATCH = 'https://api.gbif.org/v1/species/match';
|
||||
const GBIF_SPECIES = 'https://api.gbif.org/v1/species';
|
||||
|
||||
/** Rang « famille » dans Wikidata. */
|
||||
const Q_FAMILLE = 'wd:Q35409';
|
||||
|
||||
async function getJson(url, params) {
|
||||
const u = new URL(url);
|
||||
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(`${res.status} ${res.statusText} — ${u}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Interroge Wikidata en une seule requête SPARQL : entité dont P225 (nom du taxon)
|
||||
* vaut exactement le nom cherché, plus la famille remontée par P171+ filtrée sur P105 = famille.
|
||||
*/
|
||||
async function wikidataBatch(noms) {
|
||||
const values = noms.map((n) => JSON.stringify(n)).join(' ');
|
||||
const query = `SELECT ?taxon ?name ?fam ?rang WHERE {
|
||||
VALUES ?name { ${values} }
|
||||
?taxon wdt:P225 ?name .
|
||||
OPTIONAL { ?taxon wdt:P105 ?r . ?r rdfs:label ?rang . FILTER(LANG(?rang) = "fr") }
|
||||
OPTIONAL { ?taxon wdt:P171+ ?f . ?f wdt:P105 ${Q_FAMILLE} ; wdt:P225 ?fam . }
|
||||
}`;
|
||||
const u = new URL(WIKIDATA_SPARQL);
|
||||
u.searchParams.set('query', query);
|
||||
const res = await fetch(u, {
|
||||
headers: { 'User-Agent': UA, Accept: 'application/sparql-results+json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`SPARQL ${res.status} ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
|
||||
const parNom = new Map();
|
||||
for (const b of json.results.bindings) {
|
||||
const nom = b.name.value;
|
||||
if (!parNom.has(nom)) parNom.set(nom, { qid: null, famille: null, rang: null });
|
||||
const e = parNom.get(nom);
|
||||
e.qid = b.taxon.value.replace('http://www.wikidata.org/entity/', '');
|
||||
if (b.fam) e.famille = b.fam.value;
|
||||
if (b.rang) e.rang = b.rang.value;
|
||||
}
|
||||
return parNom;
|
||||
}
|
||||
|
||||
/** Repli quand P225 ne correspond à rien : recherche plein texte, pour au moins pointer une piste. */
|
||||
async function wikidataRecherche(nom) {
|
||||
const json = await getJson(WIKIDATA_API, {
|
||||
action: 'wbsearchentities',
|
||||
search: nom,
|
||||
language: 'en',
|
||||
format: 'json',
|
||||
limit: '3',
|
||||
origin: '*'
|
||||
});
|
||||
return (json.search ?? []).map((r) => ({ qid: r.id, label: r.label, description: r.description }));
|
||||
}
|
||||
|
||||
async function gbif(nom) {
|
||||
const m = await getJson(GBIF_MATCH, { name: nom, strict: 'false' });
|
||||
const out = {
|
||||
usageKey: m.usageKey ?? null,
|
||||
scientificName: m.scientificName ?? null,
|
||||
canonicalName: m.canonicalName ?? null,
|
||||
rank: m.rank ?? null,
|
||||
status: m.status ?? null,
|
||||
matchType: m.matchType ?? null,
|
||||
confidence: m.confidence ?? null,
|
||||
famille: m.family ?? null,
|
||||
accepte: null
|
||||
};
|
||||
// Un synonyme est le piège classique de ce dataset : GBIF renvoie alors la clé du
|
||||
// synonyme et il faut aller chercher le nom accepté pour ne pas figer un nom périmé.
|
||||
if (out.status && out.status !== 'ACCEPTED' && out.usageKey) {
|
||||
try {
|
||||
const sp = await getJson(`${GBIF_SPECIES}/${out.usageKey}`, {});
|
||||
out.accepte = {
|
||||
usageKey: sp.acceptedKey ?? null,
|
||||
scientificName: sp.accepted ?? null
|
||||
};
|
||||
} catch {
|
||||
out.accepte = { erreur: 'lecture /species/{key} impossible' };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ok = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
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 noms = entrees.map((e) => e.taxon.scientifique);
|
||||
console.log(`→ Wikidata : ${noms.length} taxons en une requête SPARQL…`);
|
||||
const wd = await wikidataBatch(noms);
|
||||
|
||||
const resultats = [];
|
||||
for (const e of entrees) {
|
||||
const nom = e.taxon.scientifique;
|
||||
process.stdout.write(`→ GBIF : ${nom}… `);
|
||||
const g = await gbif(nom);
|
||||
console.log(g.status ?? 'AUCUN MATCH');
|
||||
|
||||
let w = wd.get(nom) ?? null;
|
||||
let pistes = null;
|
||||
if (!w) pistes = await wikidataRecherche(nom);
|
||||
|
||||
const ecarts = [];
|
||||
if (!w) ecarts.push('Wikidata : aucune entité avec P225 = ce nom exact');
|
||||
if (w && !w.famille) ecarts.push('Wikidata : famille non remontée par P171+');
|
||||
if (w?.famille && !ok(w.famille, e.taxon.famille))
|
||||
ecarts.push(`famille Wikidata « ${w.famille} » ≠ graine « ${e.taxon.famille} »`);
|
||||
if (g.famille && !ok(g.famille, e.taxon.famille))
|
||||
ecarts.push(`famille GBIF « ${g.famille} » ≠ graine « ${e.taxon.famille} »`);
|
||||
if (g.status && g.status !== 'ACCEPTED')
|
||||
ecarts.push(`GBIF : statut ${g.status} — nom accepté ${g.accepte?.scientificName ?? '?'}`);
|
||||
if (g.matchType && g.matchType !== 'EXACT') ecarts.push(`GBIF : matchType ${g.matchType}`);
|
||||
|
||||
resultats.push({
|
||||
id: e.id,
|
||||
categorie: e.categorie,
|
||||
graine: { scientifique: nom, famille: e.taxon.famille },
|
||||
wikidata: w,
|
||||
wikidata_pistes: pistes,
|
||||
gbif: g,
|
||||
ecarts
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(join(ROOT, 'tools', 'data', 'out'), { recursive: true });
|
||||
await writeFile(
|
||||
join(ROOT, 'tools', 'data', 'out', 'taxa-verified.json'),
|
||||
JSON.stringify({ genere_le: new Date().toISOString(), resultats }, null, 2) + '\n'
|
||||
);
|
||||
|
||||
const lignes = [
|
||||
'# Vérification taxonomique — Wikidata × GBIF',
|
||||
'',
|
||||
`> Généré par \`npm run verify:taxa\` le ${new Date().toISOString().slice(0, 10)}.`,
|
||||
'> Ne pas éditer à la main : rejouer le script quand la graine change.',
|
||||
'',
|
||||
'| id | nom de la graine | Wikidata | famille WD | GBIF | statut GBIF | famille GBIF | écarts |',
|
||||
'|---|---|---|---|---|---|---|---|'
|
||||
];
|
||||
for (const r of resultats) {
|
||||
lignes.push(
|
||||
`| \`${r.id}\` | *${r.graine.scientifique}* | ${r.wikidata?.qid ?? '—'} | ${r.wikidata?.famille ?? '—'} | ${r.gbif.usageKey ?? '—'} | ${r.gbif.status ?? '—'} | ${r.gbif.famille ?? '—'} | ${r.ecarts.length ? r.ecarts.join(' · ') : 'aucun'} |`
|
||||
);
|
||||
}
|
||||
const nbEcarts = resultats.filter((r) => r.ecarts.length).length;
|
||||
lignes.push('', `**${nbEcarts} entrée(s) sur ${resultats.length} présentent au moins un écart.**`, '');
|
||||
await writeFile(join(ROOT, 'docs', 'verification-taxons.md'), lignes.join('\n'));
|
||||
|
||||
console.log(`\n✔ ${resultats.length} taxons vérifiés, ${nbEcarts} avec écart.`);
|
||||
console.log(' tools/data/out/taxa-verified.json');
|
||||
console.log(' docs/verification-taxons.md');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('✘', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user