fix(insee): parser SDMX-ML XML — l'API BDM ne supporte pas JSON

L'API portail-api.insee.fr retourne uniquement du XML (SDMX-ML),
peu importe l'Accept header. Suppression du code SDMX-JSON et
remplacement par un parser XML via regex sur les attributs <Obs>.

- Base URL : https://api.insee.fr/series/BDM/data/SERIES_BDM/{id}
- Aucune clé API requise (accès public)
- Résultat : 7/11 séries retournent des valeurs (dont chômage 7.4%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Reverdin 2026-06-07 10:23:02 +02:00
parent b687720cce
commit 6638195e44
2 changed files with 49 additions and 52 deletions

View file

@ -1,28 +1,49 @@
/**
* Client INSEE BDM (Banque de Données Macroéconomiques)
* API SDMX publique pas de clé requise pour les séries publiques.
* Doc: https://api.insee.fr/catalogue/site/themes/wso2/subthemes/insee/pages/item-info.jag?name=BDM&version=V1&provider=INSEE
* Portail : https://portail-api.insee.fr — API publique, aucune clé requise.
* Format : SDMX-ML XML (l'API ne supporte pas JSON).
* Doc: https://api.insee.fr/series/BDM
*/
const BASE = 'https://api.insee.fr/series/BDM/V1';
const BASE = 'https://api.insee.fr/series/BDM/data/SERIES_BDM';
export interface INSEEValue {
value: number | null;
period: string; // ex: "2024-Q3" ou "2024"
unit?: string;
period: string; // ex: "2026-Q1" → on extrait l'année
year: string; // ex: "2026"
label?: string;
}
interface SDMXObs {
[period: string]: [number | null, ...unknown[]];
/**
* Parse la réponse SDMX-ML XML d'INSEE BDM.
* Extrait les observations <Obs TIME_PERIOD="..." OBS_VALUE="..."/>
* et retourne la plus récente avec une valeur non nulle.
*/
function parseSDMXXML(xml: string): INSEEValue {
// Extraire toutes les observations
const obsRegex = /<Obs\s+([^/]+)\/>/g;
const observations: { period: string; value: number }[] = [];
for (const match of xml.matchAll(obsRegex)) {
const attrs = match[1];
const period = attrs.match(/TIME_PERIOD="([^"]+)"/)?.[1];
const valStr = attrs.match(/OBS_VALUE="([^"]+)"/)?.[1];
if (period && valStr) {
const value = parseFloat(valStr);
if (!isNaN(value)) {
observations.push({ period, value });
}
}
}
interface SDMXSeries {
observations: SDMXObs;
attributes?: unknown[];
}
if (observations.length === 0) return { value: null, period: '', year: '' };
interface SDMXDataset {
series?: Record<string, SDMXSeries>;
// La plus récente (elles sont triées chronologiquement dans la réponse)
const last = observations[observations.length - 1];
// Extraire l'année : "2026-Q1" → "2026", "2025-A" → "2025", "2025" → "2025"
const year = last.period.split('-')[0];
return { value: last.value, period: last.period, year };
}
/**
@ -31,67 +52,43 @@ interface SDMXDataset {
*/
export async function fetchSerie(serieId: string): Promise<INSEEValue> {
try {
const url = `${BASE}/data/SERIES_BDM/${serieId}?lastNObservations=4&format=sdmx-json`;
const url = `${BASE}/${serieId}?lastNObservations=4`;
const res = await fetch(url, {
headers: {
Accept: 'application/json',
// Pas besoin de token pour les séries publiques
// Pas de token — API publique
Accept: 'application/xml',
},
signal: AbortSignal.timeout(12_000),
});
if (!res.ok) {
console.warn(`[insee] ${serieId} → HTTP ${res.status}`);
return { value: null, period: '' };
return { value: null, period: '', year: '' };
}
const data = await res.json();
// Parcourir la structure SDMX-JSON
const datasets: SDMXDataset[] = data?.dataSets || [];
const firstDataset = datasets[0];
if (!firstDataset?.series) return { value: null, period: '' };
const firstSeries = Object.values(firstDataset.series)[0] as SDMXSeries;
if (!firstSeries?.observations) return { value: null, period: '' };
// Trouver la période la plus récente
const structure = data?.structure;
const timeDimension = structure?.dimensions?.observation?.find(
(d: { id: string }) => d.id === 'TIME_PERIOD'
);
const obsEntries = Object.entries(firstSeries.observations);
if (obsEntries.length === 0) return { value: null, period: '' };
// L'index SDMX-JSON → période via timeDimension.values
const lastEntry = obsEntries[obsEntries.length - 1];
const obsIdx = parseInt(lastEntry[0]);
const period = timeDimension?.values?.[obsIdx]?.id || lastEntry[0];
const value = (lastEntry[1] as unknown[])[0] as number | null;
return { value, period };
const xml = await res.text();
return parseSDMXXML(xml);
} catch (e) {
console.warn(`[insee] ${serieId}${(e as Error).message}`);
return { value: null, period: '' };
return { value: null, period: '', year: '' };
}
}
/**
* Séries INSEE courantes (IDs BDM)
* Source: https://www.bdm.insee.fr/bdm2/choixCriteres?codeGroupe=CONSO_MENAGES
* Trouver les IDs sur : https://www.bdm.insee.fr
*/
export const SERIES_INSEE = {
// PIB
PIB_VOLUME_T: '010565692', // PIB volume, variation trimestrielle
PIB_VOLUME_T: '010565692', // PIB volume, variation trimestrielle CVS
PIB_VOLUME_A: '010565695', // PIB volume, variation annuelle
// Inflation
IPC_ENSEMBLE: '001759970', // IPC ensemble, variation annuelle
IPC_ALIMENTAIRE: '001759971', // IPC alimentation
IPC_ALIMENTAIRE: '001759971', // IPC alimentation, variation annuelle
// Emploi
TAUX_CHOMAGE: '001688526', // Taux de chômage BIT France métro
TAUX_CHOMAGE: '001688526', // Taux de chômage BIT France métro (trimestriel)
TAUX_EMPLOI: '001688527', // Taux d'emploi 15-64 ans
EMPLOI_TOTAL: '001688535', // Emploi total (milliers)
@ -100,6 +97,6 @@ export const SERIES_INSEE = {
DETTE_PUBLIQUE: '001639746', // Dette publique % PIB (Maastricht)
// Ménages
POUVOIR_ACHAT: '010565799', // Pouvoir d'achat des ménages
POUVOIR_ACHAT: '010565799', // Pouvoir d'achat par unité de consommation
TAUX_EPARGNE: '001637643', // Taux d'épargne des ménages
} as const;

View file

@ -41,7 +41,7 @@ interface IndicateursJSON {
generated_at: string;
source: string;
indicators: IndicateurResult[];
insee: Record<string, { value: number | null; period: string }>;
insee: Record<string, { value: number | null; period: string; year: string }>;
}
// ─── FETCH ────────────────────────────────────────────────────────────────────
@ -51,7 +51,7 @@ async function main() {
const startTime = Date.now();
const indicators: IndicateurResult[] = [];
const inseeData: Record<string, { value: number | null; period: string }> = {};
const inseeData: Record<string, { value: number | null; period: string; year: string }> = {};
// 1. Eurostat — fetch en parallèle par batch de 5
console.log(`[fetch-indicators] Fetch ${INDICATEURS.filter(i => i.source === 'eurostat').length} indicateurs Eurostat...`);