Le manque devient une information de première classe : mesuré, daté, affiché, et accompagné de la marche à suivre pour le combler (ADR 0006). Mesure - rapport:manques : couverture des 5 vues pédagogiques dans les fonds publics, présence du kréyòl sur Wikidata, observations GBIF par île, manques internes du dataset → docs/manques.md. - Le kréyòl est absent des communs : Wikidata enregistre 124 noms vernaculaires pour ces 13 espèces (zh, en, pt, sl) et aucun en kréyòl ; 0 étiquette gcf sur 13 items. - L'écorce est la vue manquante : 3 trous sur 65, tous des écorces — mansiniyé 0 fichier, aki 0, kénèt 1. - Pl@ntNet publie en CC BY-SA là où iNaturalist est à ~93 % CC BY-NC : meilleur canal de contribution à effort égal. Correction d'une mesure fausse avant publication - Le premier rapport annonçait 28 vues manquantes sur 65. Le groupement OR de la recherche Commons ne se comporte pas comme attendu : « section » seul remonte 17 fichiers là où « (section OR sliced OR cut OR coupe) » en remonte 0. Un mot, une requête, avec gestion du débit — le chiffre réel est 3 sur 65. Publier les 28 aurait envoyé des bénévoles photographier ce qui existait déjà. Documents - docs/contribuer.md : six gestes, du nom kréyòl sur Wikidata (5 min) à l'enregistrement d'une voix, avec le tableau de choix de licence et la consigne de sécurité pour photographier le mancenillier. - docs/adr/0006-projet-participatif.md : aucun dépôt interne, aucune requête tierce au runtime, la porte de validation reste inchangée. - docs/courriers/appel-liberation-tramil.md : brouillon d'appel public, à n'envisager qu'après la demande privée. Publication au porteur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
#!/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);
|
||
});
|