Files
ki-fwi/tools/data/build-dataset.mjs
T

201 lines
7.1 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* build-dataset.mjs — compose le dataset à partir de la graine et des sorties vérifiées.
*
* fruits.seed.json (contenu éditorial d'amorçage)
* + tools/data/out/taxa-verified.json (Wikidata + GBIF, réponses d'API)
* + tools/data/out/commons-candidats.json (médias sous licence libre)
* → src/lib/data/fruits/<id>.json
*
* AMORÇAGE SEUL. Une fois les fichiers écrits, ils sont édités à la main par le porteur
* (choix des photos, assignation des vues, passage de `valide` à true, corrections kréyòl).
* Le script refuse donc d'écraser un fichier existant sans `--force`, pour ne jamais
* détruire une validation humaine.
*/
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DEST = join(ROOT, 'src', 'lib', 'data', 'fruits');
const FORCE = process.argv.includes('--force');
/** Nombre de médias candidats pré-inscrits par entrée. Le brief §8 en exige au moins deux. */
const MEDIAS_PAR_ENTREE = 3;
/** Licences à attribution — recopié de la classification d'ingest-commons.mjs. */
const SANS_ATTRIBUTION = new Set(['CC0-1.0', 'PD']);
const AVEC_SA = new Set(['CC-BY-SA-2.0', 'CC-BY-SA-2.5', 'CC-BY-SA-3.0', 'CC-BY-SA-4.0', 'CC-BY-NC-SA-4.0']);
const AVEC_NC = new Set(['CC-BY-NC-4.0', 'CC-BY-NC-SA-4.0']);
const slug = (s) =>
s
.replace(/^File:/, '')
.replace(/\.[a-z0-9]+$/i, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/, '');
/**
* Une planche gravée du XVIIIe siècle est exacte mais stylisée : elle ne peut pas servir
* d'épreuve de reconnaissance du réel. On la détecte grossièrement — c'est un pré-tri,
* pas un verdict : le porteur corrige le champ à la relecture.
*/
const INDICES_PLANCHE = /blanco|merian|k[oö]hler|flora|sylva|botanical|plate|lithograph|engrav|descourtilz|1[6-9]\d\d/i;
function typeMedia(c) {
// La date de prise de vue fait partie du texte examiné : sans elle, la planche de
// Jacquin (1780) ressortait en « photo » — son titre et son auteur ne disent rien
// de son siècle. Constaté en session 0, corrigé ici.
const hay = `${c.titre} ${c.description ?? ''} ${c.auteur ?? ''} ${c.date_prise_de_vue ?? ''}`;
if (INDICES_PLANCHE.test(hay)) return 'planche_botanique';
// La photographie de terrain n'existe pas avant le XXe siècle dans ces fonds.
const annee = Number((c.date_prise_de_vue ?? '').match(/\b(1[5-9]\d\d)\b/)?.[1]);
if (annee && annee < 1900) return 'planche_botanique';
return 'photo';
}
/**
* Écart constaté dans la graine : les dangers de niveau « vigilance » (kénèt, pòm-kannèl)
* portent leur texte dans `note`, les niveaux « élevé » et « critique » dans `resume`.
* Le schéma impose un champ unique — on normalise vers `resume` sans rien réécrire.
* Écart signalé dans docs/sessions/00-cadre-et-donnees.md, conformément au brief §9.
*/
function normaliserDanger(d) {
if (!d) return null;
const { note, resume, ...reste } = d;
return { ...reste, resume: resume ?? note };
}
/** Deux planches Blanco tronquées au même slug : on désambiguïse plutôt que de perdre un média. */
function idsUniques(medias) {
const vus = new Map();
return medias.map((m) => {
const n = (vus.get(m.id) ?? 0) + 1;
vus.set(m.id, n);
return n === 1 ? m : { ...m, id: `${m.id}-${n}` };
});
}
async function existe(p) {
try {
await access(p);
return true;
} catch {
return false;
}
}
async function main() {
const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8'));
const taxa = JSON.parse(await readFile(join(ROOT, 'tools/data/out/taxa-verified.json'), 'utf8'));
const medias = JSON.parse(await readFile(join(ROOT, 'tools/data/out/commons-candidats.json'), 'utf8'));
const parId = (arr) => new Map(arr.map((r) => [r.id, r]));
const T = parId(taxa.resultats);
const M = parId(medias.resultats);
const dateVerif = taxa.genere_le.slice(0, 10);
const entrees = [
...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })),
...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' }))
];
await mkdir(DEST, { recursive: true });
let ecrits = 0;
let ignores = 0;
for (const e of entrees) {
const chemin = join(DEST, `${e.id}.json`);
if (!FORCE && (await existe(chemin))) {
console.log(${e.id} — existe déjà, conservé (--force pour écraser)`);
ignores++;
continue;
}
const t = T.get(e.id);
if (!t?.wikidata?.qid || !t?.gbif?.usageKey) {
console.error(`✘ ${e.id} — taxonomie non vérifiée, entrée non écrite`);
continue;
}
const candidats = idsUniques(
(M.get(e.id)?.candidats ?? []).slice(0, MEDIAS_PAR_ENTREE).map((c) => ({
id: slug(c.titre),
filiere: 'A',
type_media: typeMedia(c),
vue: null,
usages: [],
source: 'wikimedia_commons',
auteur: c.auteur,
licence: c.licence,
licence_url: c.licence_url ?? null,
url_fichier: c.url_fichier,
url_description: c.url_description,
largeur: c.largeur ?? null,
hauteur: c.hauteur ?? null,
mime: c.mime ?? null,
description: c.description ?? null,
date_prise_de_vue: c.date_prise_de_vue ?? null,
attribution_requise: !SANS_ATTRIBUTION.has(c.licence),
partage_identique: AVEC_SA.has(c.licence),
usage_non_commercial: AVEC_NC.has(c.licence),
composite: false,
valide: false,
valide_par: null,
valide_le: null,
note_validation: null
}))
);
const entree = {
$schema: '../../../../fruits.schema.json',
id: e.id,
categorie: e.categorie,
noms: e.noms,
orthographe: { norme: 'gwadloupeyen', statut: 'a_relire', relu_par: null },
taxon: {
scientifique: t.gbif.canonicalName ?? e.taxon.scientifique,
famille: t.gbif.famille ?? e.taxon.famille,
wikidata: t.wikidata.qid,
gbif: t.gbif.usageKey,
verifie_le: dateVerif,
verifie_contre: ['wikidata', 'gbif']
},
iles: e.iles,
...(e.categorie === 'fruit' ? { saison_mois: e.saison_mois } : {}),
// Les cinq plantes du module « Pa touché » sortent à comestible: false.
// C'est le défaut prudent, pas la vérité complète : l'akée mûre et la pomme-cajou
// se mangent sous conditions strictes. La nuance vit dans danger.resume et doit
// être arbitrée par un humain avant publication (voir docs/a-arbitrer.md).
comestible: e.categorie === 'fruit' ? (e.comestible ?? true) : false,
danger: normaliserDanger(e.danger),
indices: e.indices ?? {},
medias: candidats,
audio: { gcf: null, locuteur: null },
sources_factuelles: [],
valide: false
};
if (e.$comment) entree.$comment = e.$comment;
await writeFile(chemin, JSON.stringify(entree, null, '\t') + '\n');
console.log(`✔ ${e.id}${candidats.length} médias, ${entree.taxon.wikidata} / ${entree.taxon.gbif}`);
ecrits++;
}
console.log(`\n${ecrits} entrée(s) écrite(s), ${ignores} conservée(s).`);
}
main().catch((err) => {
console.error('✘', err.message);
process.exit(1);
});