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>
196 lines
7.5 KiB
JavaScript
196 lines
7.5 KiB
JavaScript
#!/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);
|
||
});
|