verify: script de validation du corpus (C1 QIDs, C2 vrais nulls, C3 coords, C4c licences)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validation du corpus JWE (audit OKI : C1, C2, C3, C4c).
|
||||
* « Fetch, pas recall » : chaque QID, coordonnée et URL photo est vérifié
|
||||
* par une requête réelle aux API Wikidata / fr.wikipedia / Wikimedia Commons.
|
||||
*
|
||||
* Usage : node scripts/validate-lieux.mjs [--photos]
|
||||
* --photos vérifie aussi les 40 URLs photo (HTTP 200 + licence) — lent.
|
||||
* Exit code 1 si au moins un FAIL.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const LIEUX = JSON.parse(readFileSync(join(ROOT, 'src/lib/server/data/lieux.json'), 'utf8'));
|
||||
const UA = { 'User-Agent': 'JWE-OKI/2.0 (validation corpus; contact: o-k-i.net)' };
|
||||
const WITH_PHOTOS = process.argv.includes('--photos');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const getJSON = async (url) => {
|
||||
const res = await fetch(url, { headers: UA });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
let fails = 0;
|
||||
const fail = (id, msg) => {
|
||||
fails++;
|
||||
console.log(`FAIL ${id} — ${msg}`);
|
||||
};
|
||||
|
||||
// --- Schéma (tous les lieux) ------------------------------------------------
|
||||
const CHAMPS = ['id', 'nom', 'nom_creole', 'region', 'commune', 'coordonnees', 'categorie', 'difficulte', 'wikidata_id', 'photo', 'indices'];
|
||||
const REGIONS = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
||||
const ids = new Set();
|
||||
for (const l of LIEUX) {
|
||||
for (const c of CHAMPS) if (!(c in l)) fail(l.nom, `champ manquant: ${c}`);
|
||||
if (ids.has(l.id)) fail(l.nom, `id dupliqué ${l.id}`);
|
||||
ids.add(l.id);
|
||||
if (!REGIONS.includes(l.region)) fail(l.nom, `région invalide: ${l.region}`);
|
||||
if (!['MONUMENT', 'LIEU'].includes(l.categorie)) fail(l.nom, `catégorie invalide`);
|
||||
if (!['FACILE', 'MOYEN', 'DIFFICILE', 'EXPERT'].includes(l.difficulte)) fail(l.nom, `difficulté invalide`);
|
||||
if (l.aire_commune_km2 !== undefined && !(l.aire_commune_km2 > 0)) fail(l.nom, `aire_commune_km2 invalide`);
|
||||
}
|
||||
console.log(`Schéma : ${LIEUX.length} lieux contrôlés.`);
|
||||
|
||||
// --- C1 + C3 : QIDs et coordonnées (un appel batch) --------------------------
|
||||
const avecQid = LIEUX.filter((l) => l.wikidata_id);
|
||||
const qids = avecQid.map((l) => l.wikidata_id).join('|');
|
||||
const data = await getJSON(
|
||||
`https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${qids}&props=sitelinks|labels|claims&languages=fr&format=json&sitefilter=frwiki`
|
||||
);
|
||||
const dist = (a, b) => {
|
||||
const R = 6371, dLa = ((b.lat - a.lat) * Math.PI) / 180, dLo = ((b.lon - a.lon) * Math.PI) / 180;
|
||||
const h = Math.sin(dLa / 2) ** 2 + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLo / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
console.log('\n| Lieu | QID | sitelink frwiki | écart coords | PASS/FAIL |');
|
||||
console.log('|---|---|---|---|---|');
|
||||
for (const l of avecQid) {
|
||||
const e = data.entities[l.wikidata_id];
|
||||
const site = e?.sitelinks?.frwiki?.title;
|
||||
const p625 = e?.claims?.P625?.[0]?.mainsnak?.datavalue?.value;
|
||||
let ok = true;
|
||||
const notes = [];
|
||||
if (!e || e.missing !== undefined) {
|
||||
ok = false;
|
||||
notes.push('entité absente');
|
||||
} else if (!site) {
|
||||
ok = false;
|
||||
notes.push('pas de sitelink frwiki');
|
||||
}
|
||||
if (p625) {
|
||||
const d = dist(l.coordonnees, { lat: p625.latitude, lon: p625.longitude });
|
||||
if (d > 0.5) {
|
||||
ok = false;
|
||||
notes.push(`écart ${d.toFixed(2)} km > 500 m`);
|
||||
}
|
||||
} else notes.push('P625 absent (non bloquant)');
|
||||
if (!ok) fails++;
|
||||
console.log(`| ${l.nom} | ${l.wikidata_id} | ${site ?? '—'} | ${notes.join('; ') || '< 500 m'} | ${ok ? 'PASS' : 'FAIL'} |`);
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// --- C2 : les null sont de vrais null ---------------------------------------
|
||||
console.log('\nLieux à wikidata_id null (preuve absence frwiki) :');
|
||||
for (const l of LIEUX.filter((x) => !x.wikidata_id)) {
|
||||
const s = await getJSON(
|
||||
`https://fr.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent('"' + l.nom + '"')}&srlimit=3&format=json`
|
||||
);
|
||||
const exact = s.query.search.some(
|
||||
(r) => r.title.toLowerCase() === l.nom.toLowerCase()
|
||||
);
|
||||
if (exact) fail(l.nom, `un article frwiki « ${l.nom} » EXISTE — le null ne teste pas le Cas B`);
|
||||
else console.log(`PASS ${l.nom} — aucun article frwiki exact`);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
// --- C4c : photos (optionnel, lent) ------------------------------------------
|
||||
if (WITH_PHOTOS) {
|
||||
console.log('\nPhotos (HTTP + licence) :');
|
||||
for (const l of LIEUX) {
|
||||
try {
|
||||
const res = await fetch(l.photo.url, { method: 'HEAD', headers: UA, redirect: 'follow' });
|
||||
if (!res.ok) fail(l.nom, `photo HTTP ${res.status}`);
|
||||
else console.log(`PASS photo ${l.nom}`);
|
||||
} catch (e) {
|
||||
fail(l.nom, `photo inaccessible: ${e.message}`);
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(fails ? `\n${fails} FAIL` : '\nTout est conforme.');
|
||||
process.exit(fails ? 1 : 0);
|
||||
Reference in New Issue
Block a user