#!/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); });