133 lines
5.4 KiB
JavaScript
133 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Validation du corpus street (« An lari a », mode Mapillary).
|
|
* « Fetch, pas recall » : chaque mapillary_id est revérifié contre l'API
|
|
* Mapillary (existence, computed_geometry < 100 m de la vérité curée, crédit
|
|
* auteur, flag pano) et chaque wikidata_id contre Wikidata (sitelink frwiki).
|
|
*
|
|
* Usage : node scripts/validate-street.mjs
|
|
* Token lu depuis .env (MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN).
|
|
* 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 STREET = JSON.parse(readFileSync(join(ROOT, 'src/lib/server/data/street.json'), 'utf8'));
|
|
const UA = { 'User-Agent': 'JWE-OKI/2.0 (validation corpus street; contact: o-k-i.net)' };
|
|
|
|
function chargeEnv() {
|
|
try {
|
|
for (const ligne of readFileSync(join(ROOT, '.env'), 'utf8').split('\n')) {
|
|
const m = ligne.match(/^\s*([A-Z_]+)\s*=\s*(.+?)\s*$/);
|
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
|
}
|
|
} catch {
|
|
/* pas de .env */
|
|
}
|
|
}
|
|
chargeEnv();
|
|
const TOKEN = process.env.MAPILLARY_TOKEN || process.env.PUBLIC_MAPILLARY_TOKEN;
|
|
|
|
const getJSON = async (url, headers = UA, essais = 3) => {
|
|
for (let i = 0; ; i++) {
|
|
const res = await fetch(url, { headers });
|
|
if (res.ok) return res.json();
|
|
// 429 (rate limit Wikidata/Mapillary) ou 5xx : backoff puis retry
|
|
if ((res.status === 429 || res.status >= 500) && i < essais - 1) {
|
|
await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
|
|
continue;
|
|
}
|
|
throw new Error(`HTTP ${res.status} sur ${url}`);
|
|
}
|
|
};
|
|
|
|
let fails = 0;
|
|
const fail = (id, msg) => {
|
|
fails++;
|
|
console.log(`FAIL ${id} — ${msg}`);
|
|
};
|
|
|
|
const distKm = (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));
|
|
};
|
|
|
|
// --- Schéma ------------------------------------------------------------------
|
|
const CHAMPS = ['id', 'nom', 'nom_creole', 'region', 'commune', 'coordonnees', 'difficulte', 'mapillary_id', 'credit', 'wikidata_id', 'indices'];
|
|
const REGIONS = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
|
const ids = new Set();
|
|
const mlyIds = new Set();
|
|
for (const l of STREET) {
|
|
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 (mlyIds.has(l.mapillary_id)) fail(l.nom, `mapillary_id dupliqué ${l.mapillary_id}`);
|
|
mlyIds.add(l.mapillary_id);
|
|
if (!REGIONS.includes(l.region)) fail(l.nom, `région invalide: ${l.region}`);
|
|
if (!['FACILE', 'MOYEN', 'DIFFICILE', 'EXPERT'].includes(l.difficulte)) fail(l.nom, `difficulté invalide`);
|
|
if (!l.wikidata_id) fail(l.nom, `wikidata_id null (le mode street documente la commune)`);
|
|
if (!(l.aire_commune_km2 > 0)) fail(l.nom, `aire_commune_km2 invalide`);
|
|
}
|
|
const parRegion = Object.groupBy(STREET, (l) => l.region);
|
|
for (const r of REGIONS) {
|
|
const n = parRegion[r]?.length ?? 0;
|
|
if (n < 3) fail(r, `seulement ${n} lieux (< 3)`);
|
|
}
|
|
console.log(`Schéma : ${STREET.length} lieux contrôlés (${REGIONS.map((r) => `${r}=${parRegion[r]?.length ?? 0}`).join(', ')}).`);
|
|
|
|
if (!TOKEN) {
|
|
console.log('Token Mapillary absent — vérifications API sautées (schéma seul).');
|
|
} else {
|
|
// --- Mapillary : existence, géométrie, crédit, pano -------------------------
|
|
console.log('\n| Lieu | mapillary_id | écart coords | crédit | pano | PASS/FAIL |');
|
|
console.log('|---|---|---|---|---|---|');
|
|
for (const l of STREET) {
|
|
try {
|
|
const img = await getJSON(
|
|
`https://graph.mapillary.com/${l.mapillary_id}?access_token=${encodeURIComponent(TOKEN)}&fields=id,is_pano,captured_at,computed_geometry,creator`
|
|
);
|
|
const geo = img.computed_geometry;
|
|
const d = distKm(l.coordonnees, { lat: geo.coordinates[1], lon: geo.coordinates[0] });
|
|
const notes = [];
|
|
let ok = true;
|
|
if (d > 0.1) {
|
|
ok = false;
|
|
notes.push(`écart ${(d * 1000).toFixed(0)} m > 100 m`);
|
|
}
|
|
if ((img.creator?.username ?? null) !== l.credit.username) {
|
|
ok = false;
|
|
notes.push(`crédit « ${l.credit.username} » ≠ « ${img.creator?.username} »`);
|
|
}
|
|
if (!!img.is_pano !== !!l.is_pano) notes.push(`pano API=${img.is_pano} corpus=${l.is_pano} (non bloquant)`);
|
|
if (!ok) fail(l.nom, notes.join(' ; '));
|
|
console.log(`| ${l.nom} | ${l.mapillary_id} | ${(d * 1000).toFixed(0)} m | ${l.credit.username} | ${img.is_pano ? 'oui' : 'non'} | ${ok ? 'PASS' : 'FAIL'} |`);
|
|
} catch (e) {
|
|
fail(l.nom, `Mapillary : ${e.message}`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
}
|
|
}
|
|
|
|
// --- Wikidata : sitelink frwiki pour chaque commune ---------------------------
|
|
const qids = STREET.map((l) => l.wikidata_id).filter(Boolean).join('|');
|
|
const data = await getJSON(
|
|
`https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${qids}&props=sitelinks&sitefilter=frwiki&format=json`
|
|
);
|
|
console.log('\n| Lieu | QID commune | sitelink frwiki | PASS/FAIL |');
|
|
console.log('|---|---|---|---|');
|
|
for (const l of STREET) {
|
|
const site = data.entities?.[l.wikidata_id]?.sitelinks?.frwiki?.title;
|
|
if (!site) fail(l.nom, `${l.wikidata_id} sans sitelink frwiki`);
|
|
console.log(`| ${l.nom} | ${l.wikidata_id} | ${site ?? 'ABSENT'} | ${site ? 'PASS' : 'FAIL'} |`);
|
|
}
|
|
|
|
console.log(fails ? `\n${fails} FAIL.` : '\nTout est conforme.');
|
|
process.exit(fails ? 1 : 0);
|