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);
|
|||
|
|
});
|