#!/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/.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); });