Files
ki-fwi/tools/data/rapport-manques.mjs
T

350 lines
14 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* rapport-manques.mjs — mesure ce qui manque, et où le trouver.
*
* Le projet est participatif : un manque n'est pas un défaut à masquer, c'est une tâche à
* publier. Ce script produit l'inventaire des trous — dans les données du projet ET dans
* les fonds publics — pour que chacun puisse en combler un.
*
* Deux mesures distinctes :
*
* 1. MANQUES INTERNES — ce que le dataset n'a pas : photos validées, vues assignées,
* enregistrement kréyòl, sources factuelles, relecture, saisonnalité sourcée.
*
* 2. DISPONIBILITÉ EXTERNE — ce que les fonds publics contiennent pour chaque vue
* pédagogique. Un trou ici veut dire : « personne au monde n'a publié cette photo
* sous licence libre », donc c'est une photo à aller prendre.
*
* LIMITE À CONNAÎTRE : la recherche Commons porte sur le TEXTE des pages de fichier, pas
* sur le contenu des images. Une photo de feuille dont la description ne dit pas « leaf »
* est comptée comme absente. Ce n'est pas un défaut de la mesure : une photo que personne
* ne peut trouver est presque aussi inutile qu'une photo qui n'existe pas — et la
* redécrire sur Commons est justement une contribution utile.
*
* Sortie : docs/manques.md
*/
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 UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)';
/**
* Les vues pédagogiques, avec les mots par lesquels on les cherche dans plusieurs langues.
*
* Chaque mot est interrogé SÉPARÉMENT. Une première version groupait les mots par `OR` —
* la syntaxe est acceptée par l'API mais ne se comporte pas comme attendu : sur
* Annona muricata, `section` seul remonte 17 fichiers alors que
* `(section OR sliced OR cut OR coupe)` en remonte 0. Le rapport affichait donc des
* manques inexistants. Un mot, une requête.
*/
const VUES = {
fruit_entier: ['fruit', 'fruits', 'fruta', 'frutas'],
coupe: ['section', 'sliced', 'pulp', 'cut'],
feuille: ['leaf', 'leaves', 'feuille', 'hoja'],
ecorce: ['bark', 'trunk', 'ecorce', 'corteza'],
port: ['tree', 'arbre', 'arbol', 'habit']
};
/** Seuil en dessous duquel une vue est déclarée manquante dans les fonds publics. */
const SEUIL_VUE = 3;
const ILES = { gp: 'GP', mq: 'MQ', gf: 'GF', re: 'RE' };
const NOM_ILE = { gp: 'Guadeloupe', mq: 'Martinique', gf: 'Guyane', re: 'La Réunion' };
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Les API Wikimedia limitent le débit et répondent alors autre chose que du JSON.
* Une réponse illisible comptée comme « 0 fichier » fabriquerait un manque imaginaire :
* on réessaie, et on échoue franchement plutôt que de renvoyer un chiffre faux.
*/
async function getJson(url, params, essais = 4) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
let derniere;
for (let i = 0; i < essais; i++) {
try {
const res = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return await res.json();
} catch (e) {
derniere = e;
await pause(600 * (i + 1));
}
}
throw new Error(`${u.host} injoignable après ${essais} essais : ${derniere?.message}`);
}
async function commonsCompte(recherche) {
const json = await getJson('https://commons.wikimedia.org/w/api.php', {
action: 'query',
format: 'json',
list: 'search',
srnamespace: '6',
srlimit: '1',
srinfo: 'totalhits',
srsearch: recherche
});
return json.query?.searchinfo?.totalhits ?? 0;
}
/**
* Nombre de fichiers trouvables pour une vue : le maximum obtenu sur les mots de la vue.
* Le maximum et non la somme — un même fichier peut répondre à plusieurs mots, et une
* somme surestimerait la couverture, donc sous-estimerait le manque.
*/
async function commonsPourVue(nomSci, mots) {
let meilleur = 0;
let motRetenu = null;
for (const mot of mots) {
const n = await commonsCompte(`"${nomSci}" ${mot} filetype:bitmap`);
if (n > meilleur) {
meilleur = n;
motRetenu = mot;
}
await pause(350);
}
return { n: meilleur, mot: motRetenu };
}
const commonsTotal = (nomSci) => commonsCompte(`"${nomSci}" filetype:bitmap`);
/**
* Le kréyòl est-il présent dans les communs numériques, ou seulement dans ce dépôt ?
*
* Deux endroits où un nom kréyòl a sa place sur Wikidata :
* — l'étiquette de langue `gcf` (kréyòl gwadloupéyen) ;
* — la propriété P1843, « nom vernaculaire du taxon », avec sa langue.
*
* Un nom qui n'existe que dans ce jeu reste un nom perdu ailleurs. C'est la contribution
* la moins coûteuse du projet, et l'une des plus durables.
*/
async function wikidataKreyol(qids) {
const labels = await getJson('https://www.wikidata.org/w/api.php', {
action: 'wbgetentities',
ids: qids.join('|'),
props: 'labels',
languages: 'gcf',
format: 'json'
});
const etat = {};
for (const q of qids) {
etat[q] = { etiquette: labels.entities?.[q]?.labels?.gcf?.value ?? null, vernaculaires: null, gcfVern: false };
await pause(250);
try {
const cl = await getJson('https://www.wikidata.org/w/api.php', {
action: 'wbgetclaims',
entity: q,
property: 'P1843',
format: 'json'
});
const noms = cl.claims?.P1843 ?? [];
etat[q].vernaculaires = noms.length;
etat[q].gcfVern = noms.some((c) => c.mainsnak?.datavalue?.value?.language === 'gcf');
} catch {
/* le compte reste null : mieux vaut un trou qu'un chiffre inventé */
}
}
return etat;
}
/** Observations GBIF pour ce taxon dans cette île, et combien portent une image. */
async function gbifIle(taxonKey, pays) {
const [tout, avecImage] = await Promise.all([
getJson('https://api.gbif.org/v1/occurrence/search', { taxonKey: String(taxonKey), country: pays, limit: '0' }),
getJson('https://api.gbif.org/v1/occurrence/search', {
taxonKey: String(taxonKey),
country: pays,
mediaType: 'StillImage',
limit: '0'
})
]);
return { total: tout.count ?? 0, avecImage: avecImage.count ?? 0 };
}
async function main() {
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
const rapports = [];
for (const f of fichiers) {
const e = JSON.parse(await readFile(join(DATA, f), 'utf8'));
process.stdout.write(`→ ${e.id}… `);
// ── 1. Manques internes
const photos = e.medias.filter((m) => m.type_media === 'photo');
const planches = e.medias.filter((m) => m.type_media === 'planche_botanique');
const validees = e.medias.filter((m) => m.valide);
const vuesAssignees = new Set(e.medias.map((m) => m.vue).filter(Boolean));
const internes = [];
if (validees.length < 2) internes.push(`${validees.length} média validé sur ${e.medias.length} — il en faut au moins 2`);
if (photos.length < 2) internes.push(`${photos.length} photographie(s) parmi les candidats, ${planches.length} planche(s) ancienne(s)`);
if (!vuesAssignees.size) internes.push('aucune vue assignée — personne na encore regardé les images');
if (!e.audio.gcf) internes.push('aucun enregistrement du nom kréyòl');
if (!e.sources_factuelles.length) internes.push('aucune source factuelle citée pour les indices');
if (e.orthographe.statut !== 'relu') internes.push('orthographe kréyòl non relue');
if (e.categorie === 'fruit' && e.saison_mois?.length) internes.push('saisonnalité non sourcée et non ventilée par île');
// ── 2. Disponibilité externe
const total = await commonsTotal(e.taxon.scientifique);
await pause(350);
const vues = {};
for (const [vue, mots] of Object.entries(VUES)) {
vues[vue] = await commonsPourVue(e.taxon.scientifique, mots);
}
const iles = {};
for (const code of e.iles) {
if (!ILES[code]) continue;
iles[code] = await gbifIle(e.taxon.gbif, ILES[code]);
await pause(200);
}
const vuesManquantes = Object.entries(vues)
.filter(([, v]) => v.n < SEUIL_VUE)
.map(([v]) => v);
console.log(`${total} fichiers Commons · ${vuesManquantes.length} vue(s) sous le seuil`);
rapports.push({ e, internes, total, vues, vuesManquantes, iles, photos: photos.length, planches: planches.length });
}
process.stdout.write('→ Wikidata : présence du kréyòl… ');
const kreyol = await wikidataKreyol(rapports.map((r) => r.e.taxon.wikidata));
const avecEtiquette = Object.values(kreyol).filter((k) => k.etiquette).length;
const avecVernaculaire = Object.values(kreyol).filter((k) => k.gcfVern).length;
console.log(`${avecEtiquette} étiquette(s), ${avecVernaculaire} nom(s) vernaculaire(s)`);
// ── Écriture du rapport, du plus démuni au mieux servi
rapports.sort((a, b) => b.vuesManquantes.length - a.vuesManquantes.length || b.internes.length - a.internes.length);
const l = [
'# Ce qui manque — et comment le combler',
'',
`> Généré par \`npm run rapport:manques\` le ${new Date().toISOString().slice(0, 10)}.`,
'> Sortie de script : ne pas éditer à la main.',
'',
'Ce projet est participatif. Un manque nest pas un défaut à masquer : cest une tâche',
'publiée. Chaque ligne ci-dessous est quelque chose quune personne peut aller combler,',
'et dont le bénéfice dépasse ce jeu — une photo versée à Wikimedia Commons sert à tous.',
'',
'**Comment contribuer :** [`docs/contribuer.md`](contribuer.md).',
'',
'## Comment lire ce rapport',
'',
`Une vue est déclarée **manquante** quand les fonds publics contiennent moins de ${SEUIL_VUE} fichiers`,
'trouvables pour elle. La mesure porte sur le **texte** des pages de fichier Commons, pas sur',
'le contenu des images : une photo de feuille dont la description ne dit pas « leaf » est',
'comptée absente. Cest volontaire — une photo que personne ne peut trouver est presque',
'aussi inutile quune photo qui nexiste pas, et **la redécrire est déjà une contribution**.',
'',
'---',
'',
'## Vue densemble',
'',
'| entrée | fichiers Commons | fruit | coupe | feuille | écorce | port | médias validés |',
'|---|---|---|---|---|---|---|---|'
];
const mark = (n) => (n < SEUIL_VUE ? `**${n}** ⚠` : String(n));
for (const r of rapports) {
l.push(
`| \`${r.e.id}\` | ${r.total} | ${mark(r.vues.fruit_entier.n)} | ${mark(r.vues.coupe.n)} | ${mark(r.vues.feuille.n)} | ${mark(r.vues.ecorce.n)} | ${mark(r.vues.port.n)} | ${r.e.medias.filter((m) => m.valide).length}/${r.e.medias.length} |`
);
}
const totalVuesManquantes = rapports.reduce((n, r) => n + r.vuesManquantes.length, 0);
l.push(
'',
`**${totalVuesManquantes} vues manquantes sur ${rapports.length * Object.keys(VUES).length}** dans les fonds publics libres.`,
'',
'---',
'',
'## Le kréyòl dans les communs numériques',
'',
'Un nom qui nexiste que dans ce jeu reste un nom perdu partout ailleurs. Wikidata offre',
'deux emplacements pour linscrire durablement : l**étiquette** de langue `gcf`, et la',
'propriété **P1843**, « nom vernaculaire du taxon ».',
'',
`**${avecEtiquette} étiquette(s) kréyòl sur ${rapports.length}** · **${avecVernaculaire} nom(s) vernaculaire(s) kréyòl sur ${rapports.length}**.`,
'',
'| entrée | nom kréyòl | étiquette `gcf` | noms vernaculaires enregistrés | dont kréyòl |',
'|---|---|---|---|---|'
);
for (const r of rapports) {
const k = kreyol[r.e.taxon.wikidata] ?? {};
l.push(
`| [\`${r.e.id}\`](https://www.wikidata.org/wiki/${r.e.taxon.wikidata}) | ${r.e.noms.gcf} | ${k.etiquette ? '✔ ' + k.etiquette : '**absente** ⚠'} | ${k.vernaculaires ?? '?'} | ${k.gcfVern ? '✔' : '**non** ⚠'} |`
);
}
l.push(
'',
'Cest la contribution la moins coûteuse du projet et lune des plus durables : quelques',
'minutes par entrée, un bénéfice qui dépasse largement ce jeu. Marche à suivre dans',
'[`contribuer.md`](contribuer.md).',
'',
'---',
''
);
for (const r of rapports) {
const e = r.e;
l.push(`## ${e.noms.gcf} — *${e.taxon.scientifique}*`, '');
l.push(
`\`${e.id}\` · ${e.categorie === 'fruit' ? 'fruit' : '⚠ plante dangereuse'} · ${e.iles.map((i) => NOM_ILE[i] ?? i).join(', ')}`,
''
);
if (r.vuesManquantes.length) {
l.push(`### 📷 Photographies à prendre (${r.vuesManquantes.length} vue(s))`, '');
for (const v of r.vuesManquantes) {
const indice = e.indices[v === 'fruit_entier' ? 'fruit' : v];
l.push(`- **${v}** — ${r.vues[v].n} fichier(s) trouvable(s) sur Commons.`);
if (indice) l.push(` Ce quil faut montrer : ${indice}`);
}
l.push('');
} else {
l.push('### 📷 Photographies', '', 'Les cinq vues sont couvertes dans les fonds publics. Reste à les choisir et les valider.', '');
}
if (Object.keys(r.iles).length) {
l.push('### 🗺️ Où lespèce est déjà observée', '');
l.push('| île | observations GBIF | dont avec image |', '|---|---|---|');
for (const [code, v] of Object.entries(r.iles))
l.push(`| ${NOM_ILE[code] ?? code} | ${v.total} | ${v.avecImage} |`);
l.push(
'',
'Peu dobservations sur une île ne veut pas dire que lespèce y est rare : cela veut',
'souvent dire que personne ne la encore documentée. Cest aussi une contribution.',
''
);
}
if (r.internes.length) {
l.push('### 📝 Manques dans les données du projet', '');
for (const m of r.internes) l.push(`- ${m}`);
l.push('');
}
l.push(
'### 🔗 Aller voir',
'',
`- [Commons — tous les fichiers](https://commons.wikimedia.org/w/index.php?search=${encodeURIComponent(`"${e.taxon.scientifique}" filetype:bitmap`)}&ns6=1)`,
`- [Wikidata ${e.taxon.wikidata}](https://www.wikidata.org/wiki/${e.taxon.wikidata}) · [GBIF ${e.taxon.gbif}](https://www.gbif.org/species/${e.taxon.gbif})`,
`- [iNaturalist](https://www.inaturalist.org/search?q=${encodeURIComponent(e.taxon.scientifique)}) · [Pl@ntNet](https://identify.plantnet.org/)`,
''
);
}
await writeFile(join(ROOT, 'docs', 'manques.md'), l.join('\n'));
console.log(`\n✔ docs/manques.md — ${totalVuesManquantes} vues manquantes sur ${rapports.length * Object.keys(VUES).length}.`);
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});