#!/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 n’a 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 n’est pas un défaut à masquer : c’est une tâche', 'publiée. Chaque ligne ci-dessous est quelque chose qu’une 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. C’est volontaire — une photo que personne ne peut trouver est presque', 'aussi inutile qu’une photo qui n’existe pas, et **la redécrire est déjà une contribution**.', '', '---', '', '## Vue d’ensemble', '', '| 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 n’existe que dans ce jeu reste un nom perdu partout ailleurs. Wikidata offre', 'deux emplacements pour l’inscrire 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( '', 'C’est la contribution la moins coûteuse du projet et l’une 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 qu’il 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ù l’espè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 d’observations sur une île ne veut pas dire que l’espèce y est rare : cela veut', 'souvent dire que personne ne l’a encore documentée. C’est 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); });