201 lines
7.1 KiB
JavaScript
201 lines
7.1 KiB
JavaScript
#!/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);
|
||
|
|
});
|