verify: scripts curation + validation corpus street (fetch reel Mapillary/geo.api/Wikidata, retry 429)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Curation du corpus street (« An lari a ») — mode Mapillary.
|
||||
* « Fetch, pas recall » : pour chaque commune cible, interroge l'API Mapillary
|
||||
* (bbox autour du centre communal, préférence 360° + qualité + récence),
|
||||
* geo.api.gouv.fr (centre, superficie), api-adresse (nom de rue) et Wikidata
|
||||
* (QID de la commune via le sitelink frwiki).
|
||||
*
|
||||
* Usage : MAPILLARY_TOKEN=… node scripts/curate-street.mjs
|
||||
* (le token est aussi lu depuis .env : MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN)
|
||||
* Écrit src/lib/server/data/street.json.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
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;
|
||||
if (!TOKEN) {
|
||||
console.error('Token Mapillary manquant (MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const UA = { 'User-Agent': 'JWE-OKI/2.0 (curation corpus street; contact: o-k-i.net)' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const getJSON = async (url, headers = UA) => {
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Communes cibles : 5 par région, difficulté = précision exigée (pas de FACILE :
|
||||
// rayon 50 km = trivial en mode street). titreWiki = titre frwiki exact (désambiguïsé).
|
||||
const SPOTS = [
|
||||
// — Guadeloupe (971) —
|
||||
{ region: 'GUADELOUPE', commune: 'Pointe-à-Pitre', titreWiki: 'Pointe-à-Pitre', difficulte: 'MOYEN', indice1: 'Île papillon, aile droite', indice2: 'Port de commerce principal de l’archipel' },
|
||||
{ region: 'GUADELOUPE', commune: 'Les Abymes', titreWiki: 'Les Abymes', difficulte: 'MOYEN', indice1: 'Île papillon, aile droite', indice2: 'Commune la plus peuplée de l’archipel' },
|
||||
{ region: 'GUADELOUPE', commune: 'Le Gosier', titreWiki: 'Le Gosier', difficulte: 'DIFFICILE', indice1: 'Île papillon, aile droite', indice2: 'Station balnéaire au sud de la Grande-Terre' },
|
||||
{ region: 'GUADELOUPE', commune: 'Sainte-Anne', titreWiki: 'Sainte-Anne (Guadeloupe)', difficulte: 'DIFFICILE', indice1: 'Île papillon, aile droite', indice2: 'Plages au sud de la Grande-Terre' },
|
||||
{ region: 'GUADELOUPE', commune: 'Basse-Terre', titreWiki: 'Basse-Terre', difficulte: 'EXPERT', indice1: 'Île papillon, aile gauche', indice2: 'Chef-lieu au pied du volcan' },
|
||||
// — Martinique (972) —
|
||||
{ region: 'MARTINIQUE', commune: 'Fort-de-France', titreWiki: 'Fort-de-France', difficulte: 'MOYEN', indice1: 'Île aux fleurs', indice2: 'Chef-lieu, sur une grande baie' },
|
||||
{ region: 'MARTINIQUE', commune: 'Le Lamentin', titreWiki: 'Le Lamentin', difficulte: 'MOYEN', indice1: 'Île aux fleurs', indice2: 'Banlieue du chef-lieu, zone aéroportuaire' },
|
||||
{ region: 'MARTINIQUE', commune: 'Saint-Pierre', titreWiki: 'Saint-Pierre (Martinique)', difficulte: 'DIFFICILE', indice1: 'Île aux fleurs, côte caraïbe nord', indice2: 'Ville détruite par une éruption en 1902' },
|
||||
{ region: 'MARTINIQUE', commune: 'Le François', titreWiki: 'Le François', difficulte: 'DIFFICILE', indice1: 'Île aux fleurs, côte atlantique', indice2: 'Bourg face aux îlets du sud' },
|
||||
{ region: 'MARTINIQUE', commune: 'Sainte-Marie', titreWiki: 'Sainte-Marie (Martinique)', difficulte: 'EXPERT', indice1: 'Île aux fleurs, côte atlantique nord', indice2: 'Musée de la banane et rhum' },
|
||||
// — Guyane (973) —
|
||||
{ region: 'GUYANE', commune: 'Cayenne', titreWiki: 'Cayenne', difficulte: 'MOYEN', indice1: 'Terre de forêt amazonienne', indice2: 'Chef-lieu, sur l’île du même nom' },
|
||||
{ region: 'GUYANE', commune: 'Kourou', titreWiki: 'Kourou', difficulte: 'DIFFICILE', indice1: 'Terre de forêt amazonienne', indice2: 'Ville des fusées' },
|
||||
{ region: 'GUYANE', commune: 'Saint-Laurent-du-Maroni', titreWiki: 'Saint-Laurent-du-Maroni', difficulte: 'DIFFICILE', indice1: 'Terre de forêt amazonienne, à l’ouest', indice2: 'Ville du fleuve Maroni, ancien bagne' },
|
||||
{ region: 'GUYANE', commune: 'Matoury', titreWiki: 'Matoury', difficulte: 'EXPERT', indice1: 'Terre de forêt amazonienne', indice2: 'Banlieue sud du chef-lieu' },
|
||||
{ region: 'GUYANE', commune: 'Remire-Montjoly', titreWiki: 'Remire-Montjoly', difficulte: 'EXPERT', indice1: 'Terre de forêt amazonienne', indice2: 'Plages à l’est du chef-lieu' },
|
||||
// — La Réunion (974) —
|
||||
{ region: 'REUNION', commune: 'Saint-Denis', titreWiki: 'Saint-Denis (La Réunion)', difficulte: 'MOYEN', indice1: 'Île intense de l’océan Indien', indice2: 'Chef-lieu, au nord' },
|
||||
{ region: 'REUNION', commune: 'Saint-Pierre', titreWiki: 'Saint-Pierre (La Réunion)', difficulte: 'MOYEN', indice1: 'Île intense de l’océan Indien', indice2: 'Capitale du sud, front de mer animé' },
|
||||
{ region: 'REUNION', commune: 'Le Tampon', titreWiki: 'Le Tampon', difficulte: 'DIFFICILE', indice1: 'Île intense de l’océan Indien', indice2: 'Commune des hauts, dans le sud' },
|
||||
{ region: 'REUNION', commune: 'Saint-Paul', titreWiki: 'Saint-Paul (La Réunion)', difficulte: 'DIFFICILE', indice1: 'Île intense de l’océan Indien', indice2: 'Côte ouest, premier site de peuplement' },
|
||||
{ region: 'REUNION', commune: 'Saint-André', titreWiki: 'Saint-André (La Réunion)', difficulte: 'EXPERT', indice1: 'Île intense de l’océan Indien', indice2: 'Côte est, pays de la canne' }
|
||||
];
|
||||
|
||||
const PREFIXE_REGION = { GUADELOUPE: '971', MARTINIQUE: '972', GUYANE: '973', REUNION: '974' };
|
||||
|
||||
async function centreCommune(spot) {
|
||||
const data = await getJSON(
|
||||
`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(spot.commune)}&codeDepartement=${PREFIXE_REGION[spot.region]}&fields=centre,surface,code,mairie&limit=8`
|
||||
);
|
||||
const c = data[0];
|
||||
if (!c) throw new Error(`commune introuvable sur geo.api.gouv.fr`);
|
||||
// La mairie est un meilleur ancrage que le centroïde (les communes DOM
|
||||
// englobent des reliefs : le centroïde tombe souvent hors du centre-ville).
|
||||
const p = c.mairie?.coordinates ? c.mairie.coordinates : c.centre.coordinates;
|
||||
return { lat: p[1], lon: p[0], surfaceHa: c.surface };
|
||||
}
|
||||
|
||||
async function chercherImage(lat, lon) {
|
||||
const champs = 'id,is_pano,captured_at,computed_geometry,geometry,creator,quality_score';
|
||||
const note = (img) => (img.quality_score ?? 0.5) * 1e13 + (img.captured_at ?? 0);
|
||||
// 1) bbox autour de l'ancre (< 0,01° de côté, contrainte API), pano d'abord
|
||||
const demi = 0.0045;
|
||||
const bbox = `${lon - demi},${lat - demi},${lon + demi},${lat + demi}`;
|
||||
for (const pano of ['&is_pano=true', '']) {
|
||||
const data = await getJSON(
|
||||
`https://graph.mapillary.com/images?access_token=${encodeURIComponent(TOKEN)}&fields=${champs}&bbox=${bbox}&limit=200${pano}`
|
||||
);
|
||||
if (data.data?.length) {
|
||||
const triees = [...data.data].sort((a, b) => note(b) - note(a));
|
||||
return { image: triees[0], total: data.data.length };
|
||||
}
|
||||
}
|
||||
// 2) grille de recherches par rayon (50 m) autour de l'ancre
|
||||
const parId = new Map();
|
||||
for (const dLat of [-0.004, 0, 0.004]) {
|
||||
for (const dLon of [-0.004, 0, 0.004]) {
|
||||
try {
|
||||
const data = await getJSON(
|
||||
`https://graph.mapillary.com/images?access_token=${encodeURIComponent(TOKEN)}&fields=${champs}&lat=${lat + dLat}&lng=${lon + dLon}&radius=50&limit=10`
|
||||
);
|
||||
for (const img of data.data ?? []) parId.set(img.id, img);
|
||||
} catch {
|
||||
/* point sans couverture */
|
||||
}
|
||||
await sleep(120);
|
||||
}
|
||||
}
|
||||
if (!parId.size) return null;
|
||||
const triees = [...parId.values()].sort((a, b) => note(b) - note(a));
|
||||
return { image: triees[0], total: parId.size };
|
||||
}
|
||||
|
||||
async function nomDeRue(lat, lon) {
|
||||
try {
|
||||
const data = await getJSON(`https://api-adresse.data.gouv.fr/reverse/?lon=${lon}&lat=${lat}&type=street`);
|
||||
const f = data.features?.[0]?.properties;
|
||||
return f?.name ?? f?.street ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// QIDs des communes via sitelinks frwiki (un seul appel batch)
|
||||
async function qidsCommunes() {
|
||||
const titres = SPOTS.map((s) => s.titreWiki);
|
||||
const data = await getJSON(
|
||||
`https://www.wikidata.org/w/api.php?action=wbgetentities&sites=frwiki&titles=${encodeURIComponent(titres.join('|'))}&props=sitelinks&sitefilter=frwiki&format=json&redirects=yes`
|
||||
);
|
||||
const parTitre = {};
|
||||
for (const [qid, e] of Object.entries(data.entities ?? {})) {
|
||||
if (e.sitelinks?.frwiki?.title) parTitre[e.sitelinks.frwiki.title] = qid;
|
||||
}
|
||||
const redirige = {};
|
||||
for (const r of data.redirects ?? []) redirige[r.from] = r.to;
|
||||
const res = {};
|
||||
for (const s of SPOTS) {
|
||||
const t = redirige[s.titreWiki] ?? s.titreWiki;
|
||||
res[s.titreWiki] = parTitre[t] ?? null;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const qids = await qidsCommunes();
|
||||
const lieux = [];
|
||||
let n = 0;
|
||||
for (const spot of SPOTS) {
|
||||
n++;
|
||||
try {
|
||||
const centre = await centreCommune(spot);
|
||||
await sleep(150);
|
||||
const trouve = await chercherImage(centre.lat, centre.lon);
|
||||
if (!trouve) {
|
||||
console.log(`VIDE ${spot.commune} (${spot.region}) — aucune image dans la bbox`);
|
||||
continue;
|
||||
}
|
||||
const img = trouve.image;
|
||||
const geo = img.computed_geometry ?? img.geometry;
|
||||
const coords = { lat: geo.coordinates[1], lon: geo.coordinates[0] };
|
||||
await sleep(150);
|
||||
const rue = await nomDeRue(coords.lat, coords.lon);
|
||||
const qid = qids[spot.titreWiki];
|
||||
lieux.push({
|
||||
id: `st-${String(n).padStart(2, '0')}`,
|
||||
nom: rue ? `${rue}, ${spot.commune}` : `Centre de ${spot.commune}`,
|
||||
nom_creole: `Lari ${spot.commune}`,
|
||||
region: spot.region,
|
||||
commune: spot.commune,
|
||||
coordonnees: coords,
|
||||
difficulte: spot.difficulte,
|
||||
mapillary_id: img.id,
|
||||
credit: {
|
||||
username: img.creator?.username ?? 'contributeur Mapillary',
|
||||
captured_at: new Date(img.captured_at).toISOString().slice(0, 10)
|
||||
},
|
||||
is_pano: !!img.is_pano,
|
||||
wikidata_id: qid,
|
||||
indices: [
|
||||
{ niveau: 1, texte: spot.indice1 },
|
||||
{ niveau: 2, texte: spot.indice2 }
|
||||
],
|
||||
aire_commune_km2: Math.round(centre.surfaceHa) / 100 // geo.api : hectares → km²
|
||||
});
|
||||
console.log(
|
||||
`OK ${spot.commune.padEnd(24)} img ${img.id} pano=${img.is_pano ? 'oui' : 'NON'} q=${img.quality_score?.toFixed(2) ?? '—'} pool=${trouve.total} qid=${qid ?? 'ABSENT'} rue=${rue ?? '—'}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(`ERREUR ${spot.commune} — ${e.message}`);
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
writeFileSync(join(ROOT, 'src/lib/server/data/street.json'), JSON.stringify(lieux, null, 2) + '\n');
|
||||
console.log(`\n${lieux.length} lieux écrits dans src/lib/server/data/street.json`);
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user