feat(irpf): couche data open source — Eurostat + INSEE build-time

- eurostat.ts : client JSON-stat 2.0, fetch multi-pays, parser flat index
- insee.ts : client BDM SDMX-JSON (prêt pour clé API)
- indicators-config.ts : 32 indicateurs sur 14 thèmes (finances, emploi,
  santé, éducation, cohésion, logement, énergie, numérique, agriculture)
  avec mapping Eurostat dataset + filtres validés
- fetch-indicators.ts : script pré-build → src/data/live/indicators.json
- fetch-indicators integration : Astro hook astro:build:start (cache 6h)
- KpiLive.astro : composant avec badge live, comparaison UE, code couleur
- donnees/index.astro : tableau de bord France avec 7 groupes + tableau
  comparatif France-Allemagne-Espagne-Italie-Suède-Pays-Bas-UE27
- package.json : scripts fetch-data + fetch-and-build

18 indicateurs Eurostat avec valeur FR correcte (Eurostat) au premier run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Reverdin 2026-06-07 00:06:05 +02:00
parent 28b406c3f3
commit a35c43661b
11 changed files with 1497 additions and 14 deletions

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# Données live générées en build-time (Eurostat + INSEE)
src/data/live/indicators.json

View file

@ -3,11 +3,13 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { fetchIndicatorsIntegration } from './src/integrations/fetch-indicators.ts';
// https://astro.build/config
export default defineConfig({
site: 'https://france.reverdin.eu',
integrations: [
fetchIndicatorsIntegration(),
mdx(),
sitemap(),
],

View file

@ -9,7 +9,9 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"fetch-data": "npx tsx src/scripts/fetch-indicators.ts",
"fetch-and-build": "npm run fetch-data && npm run build"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
@ -19,6 +21,7 @@
"astro": "^6.4.4",
"pagefind": "^1.5.2",
"tailwindcss": "^4.3.0",
"tsx": "^4.19.4",
"typescript": "^6.0.3"
}
}

309
pnpm-lock.yaml generated
View file

@ -13,22 +13,25 @@ importers:
version: 0.9.9(prettier@3.8.3)(typescript@6.0.3)
'@astrojs/mdx':
specifier: ^6.0.2
version: 6.0.2(astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(yaml@2.9.0))
version: 6.0.2(astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(tsx@4.22.4)(yaml@2.9.0))
'@astrojs/sitemap':
specifier: ^3.7.3
version: 3.7.3
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
version: 4.3.0(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))
astro:
specifier: ^6.4.4
version: 6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(yaml@2.9.0)
version: 6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(tsx@4.22.4)(yaml@2.9.0)
pagefind:
specifier: ^1.5.2
version: 1.5.2
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
tsx:
specifier: ^4.19.4
version: 4.22.4
typescript:
specifier: ^6.0.3
version: 6.0.3
@ -148,156 +151,312 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.28.0':
resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.28.0':
resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.28.0':
resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.28.0':
resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.28.0':
resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.28.0':
resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.28.0':
resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.0':
resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.28.0':
resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.28.0':
resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.28.0':
resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.28.0':
resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.28.0':
resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.28.0':
resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.28.0':
resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.28.0':
resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.28.0':
resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.28.0':
resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.0':
resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.28.0':
resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.0':
resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.28.0':
resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.28.0':
resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.28.0':
resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.28.0':
resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.28.0':
resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@ -1080,6 +1239,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@ -1852,6 +2016,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.22.4:
resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
engines: {node: '>=18.0.0'}
hasBin: true
typesafe-path@0.2.2:
resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==}
@ -2261,13 +2430,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/mdx@6.0.2(astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(yaml@2.9.0))':
'@astrojs/mdx@6.0.2(astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@astrojs/internal-helpers': 0.10.0
'@astrojs/markdown-remark': 7.2.0
'@mdx-js/mdx': 3.1.1
acorn: 8.16.0
astro: 6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(yaml@2.9.0)
astro: 6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(tsx@4.22.4)(yaml@2.9.0)
es-module-lexer: 2.1.0
estree-util-visit: 2.0.0
hast-util-to-html: 9.0.5
@ -2363,81 +2532,159 @@ snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/aix-ppc64@0.28.0':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm64@0.28.0':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-arm@0.28.0':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/android-x64@0.28.0':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.28.0':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.28.0':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.28.0':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.28.0':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.28.0':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-arm@0.28.0':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-ia32@0.28.0':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-loong64@0.28.0':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.28.0':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.28.0':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.28.0':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-s390x@0.28.0':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/linux-x64@0.28.0':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.28.0':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.28.0':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.28.0':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.28.0':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.28.0':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.28.0':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.28.0':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-ia32@0.28.0':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@esbuild/win32-x64@0.28.0':
optional: true
'@img/colour@1.1.0':
optional: true
@ -2791,12 +3038,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
'@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))':
'@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0
tailwindcss: 4.3.0
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)
'@types/debug@4.1.13':
dependencies:
@ -2926,7 +3173,7 @@ snapshots:
astring@1.9.0: {}
astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(yaml@2.9.0):
astro@6.4.4(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.1)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
'@astrojs/compiler': 4.0.0
'@astrojs/internal-helpers': 0.10.0
@ -2978,8 +3225,8 @@ snapshots:
unist-util-visit: 5.1.0
unstorage: 1.17.5
vfile: 6.0.3
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)
vitefu: 1.1.3(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)
vitefu: 1.1.3(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
zod: 4.4.3
@ -3204,6 +3451,35 @@ snapshots:
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
esbuild@0.28.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.0
'@esbuild/android-arm': 0.28.0
'@esbuild/android-arm64': 0.28.0
'@esbuild/android-x64': 0.28.0
'@esbuild/darwin-arm64': 0.28.0
'@esbuild/darwin-x64': 0.28.0
'@esbuild/freebsd-arm64': 0.28.0
'@esbuild/freebsd-x64': 0.28.0
'@esbuild/linux-arm': 0.28.0
'@esbuild/linux-arm64': 0.28.0
'@esbuild/linux-ia32': 0.28.0
'@esbuild/linux-loong64': 0.28.0
'@esbuild/linux-mips64el': 0.28.0
'@esbuild/linux-ppc64': 0.28.0
'@esbuild/linux-riscv64': 0.28.0
'@esbuild/linux-s390x': 0.28.0
'@esbuild/linux-x64': 0.28.0
'@esbuild/netbsd-arm64': 0.28.0
'@esbuild/netbsd-x64': 0.28.0
'@esbuild/openbsd-arm64': 0.28.0
'@esbuild/openbsd-x64': 0.28.0
'@esbuild/openharmony-arm64': 0.28.0
'@esbuild/sunos-x64': 0.28.0
'@esbuild/win32-arm64': 0.28.0
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
escalade@3.2.0: {}
escape-string-regexp@5.0.0: {}
@ -4406,6 +4682,12 @@ snapshots:
tslib@2.8.1:
optional: true
tsx@4.22.4:
dependencies:
esbuild: 0.28.0
optionalDependencies:
fsevents: 2.3.3
typesafe-path@0.2.2: {}
typescript-auto-import-cache@0.3.6:
@ -4510,7 +4792,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0):
vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@ -4523,11 +4805,12 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
lightningcss: 1.32.0
tsx: 4.22.4
yaml: 2.9.0
vitefu@1.1.3(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)):
vitefu@1.1.3(vite@7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)):
optionalDependencies:
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)
vite: 7.3.5(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)
volar-service-css@0.0.70(@volar/language-service@2.4.28):
dependencies:

View file

@ -0,0 +1,90 @@
---
/**
* KpiLive — Indicateur open data avec badge "données en direct"
* Affiche valeur France + mini comparatif pays si demandé.
*/
interface PaysValue {
value: number | null;
year: string;
label: string;
}
interface Props {
id?: string;
label: string;
valeur: number | null;
annee: string;
unite: string;
sens_positif?: 'hausse' | 'baisse';
pays?: Record<string, PaysValue>;
note?: string;
compact?: boolean;
}
const { label, valeur, annee, unite, sens_positif, pays, note, compact = false } = Astro.props;
// Tendance : comparer FR à EU27 si dispo
const eu27 = pays?.['EU27_2020']?.value ?? null;
let tendance: 'meilleur' | 'pire' | 'neutre' = 'neutre';
if (valeur !== null && eu27 !== null) {
const diff = valeur - eu27;
if (sens_positif === 'hausse') tendance = diff > 0.5 ? 'meilleur' : diff < -0.5 ? 'pire' : 'neutre';
if (sens_positif === 'baisse') tendance = diff < -0.5 ? 'meilleur' : diff > 0.5 ? 'pire' : 'neutre';
}
const valeurFormatted = valeur !== null
? (Math.abs(valeur) >= 100 ? Math.round(valeur).toLocaleString('fr-FR') : valeur.toFixed(1).replace('.0', ''))
: 'N/D';
const tenantColor = tendance === 'meilleur' ? '#16a34a' : tendance === 'pire' ? '#dc2626' : '#6b7280';
const tenantBg = tendance === 'meilleur' ? '#f0fdf4' : tendance === 'pire' ? '#fef2f2' : '#f9fafb';
// 3 pays clés pour comparaison rapide
const paysKeys = ['DE', 'SE', 'EU27_2020'].filter(k => pays?.[k]?.value !== null);
---
<div class={`kpi-live ${compact ? 'kpi-live--compact' : ''}`} style={`background: ${tenantBg}; border: 1px solid ${tendance === 'neutre' ? '#e5e7eb' : tenantColor + '40'}; border-radius: 0.5rem; padding: ${compact ? '0.75rem 1rem' : '1rem 1.25rem'}; position: relative; overflow: hidden;`}>
<!-- Badge live -->
<div style="position: absolute; top: 0.5rem; right: 0.5rem; display: flex; align-items: center; gap: 0.3rem;">
<span style="width: 6px; height: 6px; background: #22c55e; border-radius: 50%; animation: pulse 2s infinite;"></span>
<span style="font-size: 0.6rem; color: #6b7280; letter-spacing: 0.05em; font-weight: 500;">Eurostat {annee}</span>
</div>
<!-- Valeur principale -->
<div style="display: flex; align-items: baseline; gap: 0.35rem; margin-bottom: 0.15rem;">
<span style={`font-size: ${compact ? '1.35rem' : '1.6rem'}; font-weight: 700; color: ${valeur !== null ? '#0f172a' : '#9ca3af'}; line-height: 1;`}>{valeurFormatted}</span>
{valeur !== null && <span style="font-size: 0.75rem; color: #6b7280; font-weight: 400;">{unite}</span>}
</div>
<!-- Label -->
<div style={`font-size: ${compact ? '0.75rem' : '0.8rem'}; color: #374151; font-weight: 500; line-height: 1.3; margin-bottom: ${!compact && paysKeys.length > 0 ? '0.75rem' : '0'};`}>{label}</div>
<!-- Comparaison rapide -->
{!compact && paysKeys.length > 0 && valeur !== null && (
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{paysKeys.map(code => {
const pVal = pays?.[code];
if (!pVal || pVal.value === null) return null;
const isEU = code === 'EU27_2020';
const v = pVal.value;
const formatted = Math.abs(v) >= 100 ? Math.round(v).toLocaleString('fr-FR') : v.toFixed(1).replace('.0', '');
return (
<span style="font-size: 0.65rem; color: #6b7280; background: white; border: 1px solid #e5e7eb; padding: 0.1rem 0.4rem; border-radius: 0.25rem;">
{isEU ? 'UE' : code} {formatted} {unite}
</span>
);
})}
</div>
)}
{note && <div style="font-size: 0.65rem; color: #9ca3af; margin-top: 0.5rem; font-style: italic;">{note}</div>}
</div>
<style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View file

@ -0,0 +1,50 @@
/**
* Intégration Astro Fetch automatique des indicateurs open data en pré-build
* S'exécute via le hook astro:build:start (avant la génération des pages)
*/
import type { AstroIntegration } from 'astro';
import { existsSync, statSync } from 'fs';
import { join } from 'path';
const INDICATORS_FILE = new URL('../../src/data/live/indicators.json', import.meta.url).pathname;
const MAX_AGE_HOURS = 6; // Rafraîchir si plus vieux que 6h
function isStale(): boolean {
if (!existsSync(INDICATORS_FILE)) return true;
const stat = statSync(INDICATORS_FILE);
const ageMs = Date.now() - stat.mtimeMs;
return ageMs > MAX_AGE_HOURS * 3600 * 1000;
}
export function fetchIndicatorsIntegration(): AstroIntegration {
return {
name: 'irpf:fetch-indicators',
hooks: {
'astro:build:start': async ({ logger }) => {
if (!isStale()) {
logger.info('Indicateurs open data — cache frais (< 6h), skip fetch');
return;
}
logger.info('Indicateurs open data — fetch Eurostat + INSEE...');
try {
// Import dynamique pour éviter les problèmes de résolution au démarrage
const { spawnSync } = await import('child_process');
const scriptPath = new URL('../scripts/fetch-indicators.ts', import.meta.url).pathname;
const result = spawnSync('npx', ['tsx', scriptPath], {
stdio: 'inherit',
encoding: 'utf-8',
timeout: 120_000, // 2 min max
});
if (result.status !== 0) {
logger.warn('Fetch indicateurs échoué — build continue avec données existantes');
} else {
logger.info('Indicateurs mis à jour ✓');
}
} catch (e) {
logger.warn(`Fetch indicateurs: ${(e as Error).message}`);
}
},
},
};
}

136
src/lib/eurostat.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* Client Eurostat API JSON-stat 2.0
* CORS ouvert, pas d'authentification requise.
* Doc: https://wikis.ec.europa.eu/display/EUROSTATHELP/API+Statistics+-+data+query
*/
const BASE = 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data';
export interface EurostatValue {
value: number | null;
year: string;
unit?: string;
}
export type GeoValues = Record<string, EurostatValue>; // { FR: {...}, DE: {...}, ... }
interface JSONStat {
id: string[];
size: number[];
dimension: Record<string, {
label: string;
category: {
index: Record<string, number>;
label: Record<string, string>;
};
}>;
value: Record<string, number> | number[];
}
/**
* Parse une réponse JSON-stat et extrait la dernière valeur pour chaque pays.
* Supporte les requêtes multi-géographies.
*/
function parseJSONStat(data: JSONStat, geos: string[]): GeoValues {
const result: GeoValues = {};
const timeIdx = data.id.indexOf('time');
const geoIdx = data.id.indexOf('geo');
if (timeIdx === -1 || geoIdx === -1) return result;
const timeCat = data.dimension['time']?.category;
const geoCat = data.dimension['geo']?.category;
if (!timeCat || !geoCat) return result;
// Trouver l'année la plus récente avec une valeur
const timeEntries = Object.entries(timeCat.index).sort((a, b) => b[1] - a[1]); // décroissant
// Calculer les strides pour l'indexation flat
const strides: number[] = new Array(data.id.length).fill(1);
for (let i = data.id.length - 2; i >= 0; i--) {
strides[i] = strides[i + 1] * data.size[i + 1];
}
const values = Array.isArray(data.value)
? data.value
: (idx: number) => (data.value as Record<string, number>)[String(idx)];
const getValue = Array.isArray(data.value)
? (idx: number) => (data.value as number[])[idx]
: (idx: number) => (data.value as Record<string, number>)[String(idx)];
for (const geo of geos) {
const geoPos = geoCat.index[geo];
if (geoPos === undefined) continue;
// Chercher la dernière année non-nulle
let found = false;
for (const [year, timePos] of timeEntries) {
// Calcul de l'index flat : somme de (position * stride) pour chaque dim
// Les autres dims ont taille 1 donc position 0
const flatIdx = geoPos * strides[geoIdx] + timePos * strides[timeIdx];
const val = getValue(flatIdx);
if (val !== null && val !== undefined && !isNaN(val)) {
result[geo] = { value: val, year };
found = true;
break;
}
}
if (!found) result[geo] = { value: null, year: '' };
}
return result;
}
/**
* Fetch un indicateur Eurostat pour plusieurs pays.
* Retourne les dernières valeurs disponibles par pays.
*/
export async function fetchIndicateur(
dataset: string,
filters: Record<string, string | string[]>,
geos: string[] = ['FR', 'DE', 'ES', 'IT', 'SE', 'NL', 'EU27_2020'],
): Promise<GeoValues> {
try {
const url = new URL(`${BASE}/${dataset}`);
url.searchParams.set('lang', 'en');
url.searchParams.set('format', 'JSON');
for (const geo of geos) url.searchParams.append('geo', geo);
for (const [key, val] of Object.entries(filters)) {
if (Array.isArray(val)) {
for (const v of val) url.searchParams.append(key, v);
} else {
url.searchParams.set(key, val);
}
}
const res = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
console.warn(`[eurostat] ${dataset} → HTTP ${res.status}`);
return {};
}
const data: JSONStat = await res.json();
return parseJSONStat(data, geos);
} catch (e) {
console.warn(`[eurostat] ${dataset}${(e as Error).message}`);
return {};
}
}
/**
* Fetch la même série pour N pays alias plus lisible.
*/
export async function fetchMultiPays(
dataset: string,
filters: Record<string, string>,
pays: string[],
): Promise<GeoValues> {
return fetchIndicateur(dataset, filters, pays);
}

View file

@ -0,0 +1,455 @@
/**
* Catalogue complet des indicateurs IRPF
* Sources : Eurostat (principal) + INSEE BDM (national)
*
* Chaque indicateur est mappé à un thème IRPF et à sa source de données.
* Utilisé par le script fetch-indicators.ts pour générer indicators.json.
*/
export const PAYS_COMPARES = ['FR', 'DE', 'ES', 'IT', 'SE', 'NL', 'EU27_2020'] as const;
export type PaysCode = typeof PAYS_COMPARES[number];
export const PAYS_LABEL: Record<string, string> = {
FR: 'France',
DE: 'Allemagne',
ES: 'Espagne',
IT: 'Italie',
SE: 'Suède',
NL: 'Pays-Bas',
EU27_2020: 'Moyenne UE27',
};
// ─── TYPES ───────────────────────────────────────────────────────────────────
export interface IndicateurConfig {
id: string; // identifiant unique IRPF (ex: "deficit_pib")
label: string; // libellé court affiché
description: string; // description pour tooltip
unite: string; // "% PIB", "années", "pour 1 000 hab.", etc.
theme: string | string[]; // slug(s) du/des thème(s) IRPF
source: 'eurostat' | 'insee';
// Eurostat
dataset?: string;
filters?: Record<string, string>;
// INSEE
serie_id?: string;
// Méta
sens_positif?: 'hausse' | 'baisse'; // hausse = bon signe pour la France
note?: string;
}
// ─── INDICATEURS EUROSTAT ────────────────────────────────────────────────────
export const INDICATEURS: IndicateurConfig[] = [
// ── Économie & Finances publiques ─────────────────────────────────────────
{
id: 'deficit_pib',
label: 'Déficit public',
description: 'Capacité (+) ou besoin () de financement des administrations publiques',
unite: '% PIB',
theme: 'economie-finances',
source: 'eurostat',
dataset: 'gov_10a_main',
filters: { na_item: 'B9', sector: 'S13', unit: 'PC_GDP' },
sens_positif: 'hausse',
note: 'Valeur négative = déficit. Plafond Maastricht : 3 % PIB.',
},
{
id: 'dette_pib',
label: 'Dette publique',
description: 'Dette publique brute consolidée (critère de Maastricht)',
unite: '% PIB',
theme: 'economie-finances',
source: 'eurostat',
dataset: 'gov_10dd_edpt1',
filters: { na_item: 'GD', sector: 'S13', unit: 'PC_GDP' },
sens_positif: 'baisse',
note: 'Plafond Maastricht : 60 % PIB.',
},
{
id: 'depenses_publiques_pib',
label: 'Dépenses publiques',
description: 'Total des dépenses des administrations publiques',
unite: '% PIB',
theme: 'economie-finances',
source: 'eurostat',
dataset: 'gov_10a_main',
filters: { na_item: 'TE', sector: 'S13', unit: 'PC_GDP' },
sens_positif: 'baisse',
},
{
id: 'prelevements_obligatoires',
label: 'Prélèvements obligatoires',
description: 'Total des recettes fiscales et cotisations sociales',
unite: '% PIB',
theme: 'economie-finances',
source: 'eurostat',
dataset: 'gov_10a_main',
filters: { na_item: 'TR', sector: 'S13', unit: 'PC_GDP' },
sens_positif: 'baisse',
},
{
id: 'croissance_pib',
label: 'Croissance du PIB',
description: 'Taux de croissance réel du PIB en volume',
unite: '% / an',
theme: ['economie-finances', 'emploi-travail'],
source: 'eurostat',
dataset: 'nama_10_gdp',
filters: { na_item: 'B1GQ', unit: 'CLV_PCH_PRE' },
sens_positif: 'hausse',
},
{
id: 'depenses_sociales_pib',
label: 'Dépenses sociales',
description: 'Dépenses de protection sociale (pensions, santé, chômage, famille)',
unite: '% PIB',
theme: ['economie-finances', 'cohesion-sociale'],
source: 'eurostat',
dataset: 'tps00098',
filters: { unit: 'PC_GDP' },
sens_positif: 'baisse',
},
// ── Emploi & Travail ───────────────────────────────────────────────────────
{
id: 'taux_chomage',
label: 'Chômage longue durée',
description: 'Chômeurs depuis ≥ 12 mois en % de la population active',
unite: '%',
theme: 'emploi-travail',
source: 'eurostat',
dataset: 'sdg_08_40',
filters: { sex: 'T' },
sens_positif: 'baisse',
note: 'Indicateur SDG 8 (chômage longue durée). Taux total BIT France ≈ 7,3 % (INSEE 2025).',
},
{
id: 'taux_chomage_jeunes',
label: 'Chômage des jeunes',
description: 'Taux de chômage des 15-24 ans',
unite: '%',
theme: 'emploi-travail',
source: 'eurostat',
dataset: 'une_rt_a',
filters: { sex: 'T', age: 'Y15-24', unit: 'PC_ACT' },
sens_positif: 'baisse',
},
{
id: 'taux_emploi',
label: "Taux d'emploi",
description: "Part de la population de 20-64 ans en emploi",
unite: '%',
theme: 'emploi-travail',
source: 'eurostat',
dataset: 'sdg_08_30',
filters: { sex: 'T' },
sens_positif: 'hausse',
},
{
id: 'taux_emploi_seniors',
label: 'Emploi seniors (55-64 ans)',
description: "Part de la population de 55-64 ans en emploi",
unite: '%',
theme: 'emploi-travail',
source: 'eurostat',
dataset: 'sdg_08_30',
filters: { sex: 'T', age: 'Y55-64' },
sens_positif: 'hausse',
},
// ── Santé ─────────────────────────────────────────────────────────────────
{
id: 'esperance_vie',
label: 'Espérance de vie à la naissance',
description: 'Espérance de vie des deux sexes combinés',
unite: 'années',
theme: 'sante',
source: 'eurostat',
dataset: 'demo_mlexpec',
filters: { sex: 'T', age: 'Y_LT1' },
sens_positif: 'hausse',
},
{
id: 'depenses_sante_pib',
label: 'Dépenses de santé',
description: 'Total des dépenses courantes de santé (publiques + privées)',
unite: '% PIB',
theme: 'sante',
source: 'eurostat',
dataset: 'tps00207',
filters: { unit: 'PC_GDP' },
sens_positif: 'hausse',
note: 'Plus élevé = système plus généreux. Attention aux effets qualité.',
},
{
id: 'mortalite_traitable',
label: 'Mortalité traitable',
description: 'Décès évitables grâce aux soins médicaux (pour 100 000 hab.)',
unite: 'pour 100 000 hab.',
theme: 'sante',
source: 'eurostat',
dataset: 'hlth_cdeath_101',
filters: { sex: 'T', unit: 'RT' },
sens_positif: 'baisse',
},
{
id: 'mortalite_preventable',
label: 'Mortalité prévenable',
description: 'Décès évitables par la prévention (mode de vie, facteurs de risque)',
unite: 'pour 100 000 hab.',
theme: 'sante',
source: 'eurostat',
dataset: 'hlth_cdeath_102',
filters: { sex: 'T', unit: 'RT' },
sens_positif: 'baisse',
},
// ── Éducation ─────────────────────────────────────────────────────────────
{
id: 'depenses_education_pib',
label: "Dépenses d'éducation",
description: "Dépenses publiques en éducation (tous niveaux)",
unite: '% PIB',
theme: 'education',
source: 'eurostat',
dataset: 'educ_uoe_fine09',
filters: { unit: 'PC_GDP', isced11: 'TOTAL' },
sens_positif: 'hausse',
},
{
id: 'decrochage_scolaire',
label: 'Décrochage scolaire',
description: "Part des 18-24 ans sans diplôme du secondaire et hors formation",
unite: '%',
theme: 'education',
source: 'eurostat',
dataset: 'edat_lfse_14',
filters: { sex: 'T', unit: 'PC' },
sens_positif: 'baisse',
},
{
id: 'diplomes_superieurs',
label: 'Diplômés de l\'enseignement supérieur',
description: "Part de la population 25-34 ans avec un diplôme du supérieur",
unite: '%',
theme: 'education',
source: 'eurostat',
dataset: 'edat_lfse_03',
filters: { sex: 'T', age: 'Y25-34', unit: 'PC' },
sens_positif: 'hausse',
},
// ── Cohésion sociale & Inégalités ─────────────────────────────────────────
{
id: 'gini',
label: 'Coefficient de Gini',
description: "Mesure des inégalités de revenus après impôts (0=égalité parfaite, 100=inégalité totale)",
unite: 'indice',
theme: 'cohesion-sociale',
source: 'eurostat',
dataset: 'ilc_di12',
filters: {},
sens_positif: 'baisse',
},
{
id: 'taux_pauvrete',
label: 'Taux de pauvreté',
description: "Part de la population sous le seuil de pauvreté (60% du revenu médian)",
unite: '%',
theme: 'cohesion-sociale',
source: 'eurostat',
dataset: 'ilc_peps01n',
filters: { unit: 'PC', age: 'TOTAL', sex: 'T' },
sens_positif: 'baisse',
},
{
id: 'taux_pauvrete_enfants',
label: 'Pauvreté des enfants',
description: "Taux de pauvreté des moins de 18 ans",
unite: '%',
theme: 'cohesion-sociale',
source: 'eurostat',
dataset: 'ilc_peps01n',
filters: { unit: 'PC', age: 'Y_LT18', sex: 'T' },
sens_positif: 'baisse',
},
// ── Logement ──────────────────────────────────────────────────────────────
{
id: 'indice_prix_immobilier',
label: 'Prix immobilier',
description: "Indice des prix résidentiels (base 100 = 2015)",
unite: 'indice base 100 (2015)',
theme: 'logement',
source: 'eurostat',
dataset: 'prc_hpi_a',
filters: { purchase: 'TOTAL' },
sens_positif: 'baisse',
note: 'Hausse = aggravation de l\'accès à la propriété.',
},
{
id: 'surcharge_logement',
label: 'Surcharge du coût logement',
description: "Part des ménages dont le coût du logement dépasse 40% du revenu",
unite: '%',
theme: 'logement',
source: 'eurostat',
dataset: 'ilc_lvho07a',
filters: { hhtyp: 'TOTAL', incgrp: 'TOTAL', unit: 'PC' },
sens_positif: 'baisse',
},
{
id: 'taux_proprietaires',
label: 'Taux de propriétaires',
description: "Part de la population propriétaire de son logement",
unite: '%',
theme: 'logement',
source: 'eurostat',
dataset: 'ilc_lvho02',
filters: { incgrp: 'TOTAL', hhtyp: 'TOTAL', unit: 'PC' },
sens_positif: 'hausse',
},
// ── Environnement & Énergie ────────────────────────────────────────────────
{
id: 'emissions_ghg_per_capita',
label: 'Émissions GES par habitant',
description: "Émissions de gaz à effet de serre en tonnes CO2 équivalent par habitant",
unite: 't CO₂eq / hab.',
theme: 'energie-environnement',
source: 'eurostat',
dataset: 'sdg_13_10',
filters: { unit: 'T_HAB' },
sens_positif: 'baisse',
},
{
id: 'part_energies_renouvelables',
label: 'Énergies renouvelables',
description: "Part des renouvelables dans la consommation d'énergie finale",
unite: '%',
theme: 'energie-environnement',
source: 'eurostat',
dataset: 'nrg_ind_ren',
filters: {},
sens_positif: 'hausse',
},
{
id: 'intensite_energetique',
label: 'Intensité énergétique',
description: "Consommation d'énergie primaire par unité de PIB",
unite: 'kgoe / k€ PIB',
theme: 'energie-environnement',
source: 'eurostat',
dataset: 'nrg_ind_ei',
filters: { unit: 'KGOE_TEUR_PPS' },
sens_positif: 'baisse',
},
// ── Agriculture ────────────────────────────────────────────────────────────
{
id: 'balance_agroalimentaire',
label: 'Balance agroalimentaire',
description: "Solde exportations-importations de produits agroalimentaires",
unite: 'M€',
theme: 'agriculture',
source: 'eurostat',
dataset: 'ext_lt_intratrd',
filters: { sitc06: 'SITC0_1', flow: 'BAL', unit: 'MIO_EUR' },
sens_positif: 'hausse',
},
// ── Numérique ─────────────────────────────────────────────────────────────
{
id: 'desi_score',
label: 'Score DESI',
description: "Indice de l'économie et de la société numériques (score global)",
unite: 'score',
theme: 'numerique',
source: 'eurostat',
dataset: 'desi_3a2_shbb',
filters: {},
sens_positif: 'hausse',
note: 'DESI composite — voir Eurostat pour sous-indices.',
},
{
id: 'competences_numeriques',
label: 'Compétences numériques de base',
description: "Part de la population (16-74 ans) avec des compétences numériques au moins basiques",
unite: '%',
theme: 'numerique',
source: 'eurostat',
dataset: 'isoc_sk_dskl_i21',
filters: { unit: 'PC_IND', indic_is: 'I_DSK_AB' },
sens_positif: 'hausse',
},
// ── Démocratie & Gouvernance ───────────────────────────────────────────────
{
id: 'confiance_institutions',
label: 'Confiance dans les institutions nationales',
description: "Part des citoyens faisant confiance au gouvernement national",
unite: '%',
theme: 'democratie',
source: 'eurostat',
dataset: 'ilc_pw03',
filters: { indic_wb: 'TRUSTINST', unit: 'PC' },
sens_positif: 'hausse',
},
// ── Sécurité ──────────────────────────────────────────────────────────────
{
id: 'taux_homicide',
label: 'Taux d\'homicides intentionnels',
description: "Homicides intentionnels pour 100 000 habitants",
unite: 'pour 100 000 hab.',
theme: 'securite',
source: 'eurostat',
dataset: 'crim_hom_vrel',
filters: { unit: 'RT' },
sens_positif: 'baisse',
},
// ── Europe ────────────────────────────────────────────────────────────────
{
id: 'soutien_ue',
label: "Soutien à l'appartenance à l'UE",
description: "Part estimée favorable à l'appartenance à l'UE (Eurobaromètre)",
unite: '%',
theme: 'europe',
source: 'eurostat',
dataset: 'eb_trust_inst_eu_ec_ep_2',
filters: { unit: 'PC', obs_status: 'T' },
sens_positif: 'hausse',
},
];
// Index par ID pour accès rapide
export const INDICATEURS_BY_ID = Object.fromEntries(
INDICATEURS.map(i => [i.id, i])
);
// Index par thème
export const INDICATEURS_BY_THEME: Record<string, IndicateurConfig[]> = {};
for (const ind of INDICATEURS) {
const themes = Array.isArray(ind.theme) ? ind.theme : [ind.theme];
for (const t of themes) {
if (!INDICATEURS_BY_THEME[t]) INDICATEURS_BY_THEME[t] = [];
INDICATEURS_BY_THEME[t].push(ind);
}
}

105
src/lib/insee.ts Normal file
View file

@ -0,0 +1,105 @@
/**
* 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
*/
const BASE = 'https://api.insee.fr/series/BDM/V1';
export interface INSEEValue {
value: number | null;
period: string; // ex: "2024-Q3" ou "2024"
unit?: string;
}
interface SDMXObs {
[period: string]: [number | null, ...unknown[]];
}
interface SDMXSeries {
observations: SDMXObs;
attributes?: unknown[];
}
interface SDMXDataset {
series?: Record<string, SDMXSeries>;
}
/**
* Fetch une série INSEE BDM par son identifiant.
* Retourne la dernière valeur disponible.
*/
export async function fetchSerie(serieId: string): Promise<INSEEValue> {
try {
const url = `${BASE}/data/SERIES_BDM/${serieId}?lastNObservations=4&format=sdmx-json`;
const res = await fetch(url, {
headers: {
Accept: 'application/json',
// Pas besoin de token pour les séries publiques
},
signal: AbortSignal.timeout(12_000),
});
if (!res.ok) {
console.warn(`[insee] ${serieId} → HTTP ${res.status}`);
return { value: null, period: '' };
}
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 };
} catch (e) {
console.warn(`[insee] ${serieId}${(e as Error).message}`);
return { value: null, period: '' };
}
}
/**
* Séries INSEE courantes (IDs BDM)
* Source: https://www.bdm.insee.fr/bdm2/choixCriteres?codeGroupe=CONSO_MENAGES
*/
export const SERIES_INSEE = {
// PIB
PIB_VOLUME_T: '010565692', // PIB volume, variation trimestrielle
PIB_VOLUME_A: '010565695', // PIB volume, variation annuelle
// Inflation
IPC_ENSEMBLE: '001759970', // IPC ensemble, variation annuelle
IPC_ALIMENTAIRE: '001759971', // IPC alimentation
// Emploi
TAUX_CHOMAGE: '001688526', // Taux de chômage BIT France métro
TAUX_EMPLOI: '001688527', // Taux d'emploi 15-64 ans
EMPLOI_TOTAL: '001688535', // Emploi total (milliers)
// Finances publiques
DEFICIT_PUBLIC: '001639745', // Déficit public % PIB
DETTE_PUBLIQUE: '001639746', // Dette publique % PIB (Maastricht)
// Ménages
POUVOIR_ACHAT: '010565799', // Pouvoir d'achat des ménages
TAUX_EPARGNE: '001637643', // Taux d'épargne des ménages
} as const;

View file

@ -0,0 +1,222 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import KpiLive from '../../components/KpiLive.astro';
import { INDICATEURS_BY_THEME, PAYS_LABEL } from '../../lib/indicators-config.ts';
// Charger le JSON des indicateurs (généré en pré-build)
let indicateursData: any = { indicators: [], insee: {}, generated_at: null };
try {
const raw = await import('../../data/live/indicators.json');
indicateursData = raw.default || raw;
} catch {
// Données non disponibles (premier build sans fetch)
}
// Indexer par ID pour accès rapide
const byId = Object.fromEntries(
(indicateursData.indicators || []).map((i: any) => [i.id, i])
);
const generatedAt = indicateursData.generated_at
? new Date(indicateursData.generated_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: null;
// Groupes d'indicateurs pour le dashboard
const GROUPES = [
{
titre: 'Finances publiques',
couleur: '#1e3a6e',
bg: '#eff6ff',
ids: ['deficit_pib', 'dette_pib', 'depenses_publiques_pib', 'prelevements_obligatoires', 'croissance_pib', 'depenses_sociales_pib'],
},
{
titre: 'Emploi & Marché du travail',
couleur: '#7c3aed',
bg: '#f5f3ff',
ids: ['taux_emploi', 'taux_chomage', 'taux_chomage_jeunes', 'taux_emploi_seniors'],
},
{
titre: 'Santé',
couleur: '#dc2626',
bg: '#fef2f2',
ids: ['esperance_vie', 'depenses_sante_pib'],
},
{
titre: 'Éducation',
couleur: '#0891b2',
bg: '#ecfeff',
ids: ['decrochage_scolaire', 'diplomes_superieurs', 'depenses_education_pib'],
},
{
titre: 'Cohésion sociale',
couleur: '#d97706',
bg: '#fffbeb',
ids: ['gini', 'taux_pauvrete', 'taux_pauvrete_enfants'],
},
{
titre: 'Logement',
couleur: '#059669',
bg: '#f0fdf4',
ids: ['indice_prix_immobilier', 'surcharge_logement', 'taux_proprietaires'],
},
{
titre: 'Environnement & Énergie',
couleur: '#16a34a',
bg: '#f0fdf4',
ids: ['emissions_ghg_per_capita', 'part_energies_renouvelables', 'intensite_energetique'],
},
];
const PAYS_ORDRE = ['FR', 'DE', 'ES', 'IT', 'SE', 'NL', 'EU27_2020'];
const INDICATEURS_TABLE = ['deficit_pib', 'dette_pib', 'depenses_publiques_pib', 'taux_chomage', 'taux_emploi', 'esperance_vie', 'gini', 'emissions_ghg_per_capita'];
---
<BaseLayout
title="Tableau de bord France — IRPF"
description="Indicateurs clés de la France comparés à ses voisins européens. Données Eurostat en temps réel."
>
<!-- Header -->
<section style="background: linear-gradient(135deg, #0f172a 0%, #1e3a6e 100%); color: white; padding: 3rem 1.5rem 2rem;">
<div style="max-width: 1200px; margin: 0 auto;">
<div style="font-size: 0.75rem; letter-spacing: 0.1em; color: #93c5fd; margin-bottom: 0.75rem;">IRPF — DONNÉES OUVERTES</div>
<h1 style="font-family: 'Playfair Display', serif; font-size: 2.25rem; margin-bottom: 1rem;">Tableau de bord France</h1>
<p style="color: #cbd5e1; max-width: 650px; line-height: 1.7; margin-bottom: 1.5rem;">
Indicateurs clés de la France comparés à l'Allemagne, l'Espagne, l'Italie, la Suède, les Pays-Bas et la moyenne UE27.
Sources : Eurostat API (données ouvertes) + INSEE BDM.
</p>
{generatedAt && (
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #94a3b8;">
<span style="width: 8px; height: 8px; background: #22c55e; border-radius: 50;"></span>
Mis à jour le {generatedAt} — {indicateursData.indicators?.length || 0} indicateurs collectés
</div>
)}
</div>
</section>
<!-- Navigation indicateurs -->
<div style="background: white; border-bottom: 1px solid #e5e7eb; position: sticky; top: 0; z-index: 10;">
<div style="max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; overflow-x: auto;">
<nav style="display: flex; gap: 0; white-space: nowrap;">
{GROUPES.map(g => (
<a href={`#${g.titre.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
style="padding: 0.875rem 1rem; font-size: 0.8rem; font-weight: 500; color: #6b7280; text-decoration: none; border-bottom: 2px solid transparent; display: inline-block;">
{g.titre}
</a>
))}
<a href="#comparaison" style="padding: 0.875rem 1rem; font-size: 0.8rem; font-weight: 500; color: #1e3a6e; text-decoration: none; border-bottom: 2px solid #1e3a6e;">
Comparatif EU →
</a>
</nav>
</div>
</div>
<div style="max-width: 1200px; margin: 0 auto; padding: 3rem 1.5rem;">
<!-- Groupes d'indicateurs -->
{GROUPES.map(groupe => {
const inds = groupe.ids.map(id => byId[id]).filter(Boolean);
if (inds.length === 0) return null;
return (
<section id={groupe.titre.toLowerCase().replace(/[^a-z0-9]+/g, '-')} style="margin-bottom: 3rem;">
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.25rem;">
<div style={`width: 3px; height: 1.5rem; background: ${groupe.couleur}; border-radius: 2px;`}></div>
<h2 style={`font-size: 1.1rem; font-weight: 700; color: ${groupe.couleur}; margin: 0;`}>{groupe.titre}</h2>
<div style="flex: 1; height: 1px; background: #e5e7eb;"></div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem;">
{inds.map((ind: any) => (
<KpiLive
label={ind.label}
valeur={ind.france?.value ?? null}
annee={ind.france?.year || ''}
unite={ind.unite}
sens_positif={ind.sens_positif}
pays={ind.pays}
note={ind.note}
/>
))}
</div>
</section>
);
})}
<!-- Tableau comparatif -->
<section id="comparaison" style="margin-top: 2rem;">
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem;">
<h2 style="font-family: 'Playfair Display', serif; font-size: 1.5rem; color: #0f172a; margin: 0;">Comparatif France — Europe</h2>
<div style="flex: 1; height: 1px; background: #e5e7eb;"></div>
<span style="font-size: 0.75rem; color: #9ca3af;">Source : Eurostat API</span>
</div>
<div style="overflow-x: auto; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">
<thead>
<tr style="background: #f8fafc; border-bottom: 2px solid #e5e7eb;">
<th style="text-align: left; padding: 0.75rem 1rem; font-weight: 600; color: #374151; min-width: 200px;">Indicateur</th>
{PAYS_ORDRE.map(code => (
<th style="text-align: right; padding: 0.75rem 0.75rem; font-weight: 600; color: code === 'FR' ? '#1e3a6e' : '#374151; min-width: 80px;">
{code === 'EU27_2020' ? 'UE27' : code}
</th>
))}
</tr>
</thead>
<tbody>
{INDICATEURS_TABLE.map((id, idx) => {
const ind = byId[id];
if (!ind) return null;
return (
<tr style={`border-bottom: 1px solid #e5e7eb; background: ${idx % 2 === 0 ? 'white' : '#f8fafc'};`}>
<td style="padding: 0.625rem 1rem; font-weight: 500; color: #374151;">
{ind.label}
<div style="font-size: 0.7rem; color: #9ca3af; font-weight: 400;">{ind.unite}</div>
</td>
{PAYS_ORDRE.map(code => {
const pv = code === 'FR' ? ind.france : ind.pays?.[code];
const val = pv?.value;
const formatted = val !== null && val !== undefined
? (Math.abs(val) >= 100 ? Math.round(val).toLocaleString('fr-FR') : val.toFixed(1))
: '—';
const isFr = code === 'FR';
return (
<td style={`text-align: right; padding: 0.625rem 0.75rem; font-weight: ${isFr ? '700' : '400'}; color: ${isFr ? '#1e3a6e' : '#374151'};`}>
{formatted}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<p style="font-size: 0.72rem; color: #9ca3af; margin-top: 0.75rem;">
Dernière mise à jour : {generatedAt || 'N/A'} — Eurostat API JSON-stat 2.0
</p>
</section>
<!-- Note méthodologique -->
<section style="background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1.5rem; margin-top: 2rem;">
<h3 style="font-size: 0.9rem; font-weight: 700; color: #374151; margin-bottom: 0.75rem;">Sources et méthodologie</h3>
<div style="font-size: 0.8rem; color: #6b7280; line-height: 1.7; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<strong>Eurostat</strong> — API JSON-stat 2.0, données officielles de l'Office statistique de l'Union européenne.
Mise à jour automatique à chaque rebuild du site (max 6h de délai).
CORS ouvert, pas d'authentification requise.
</div>
<div>
<strong>Comparaison</strong> — France vs Allemagne, Espagne, Italie, Suède, Pays-Bas et moyenne UE27.
Les valeurs <span style="color: #dc2626;">en rouge</span> indiquent un retard par rapport à la moyenne UE ;
les valeurs <span style="color: #16a34a;">en vert</span> indiquent une avance.
Données non disponibles (N/D) = non publiées par Eurostat pour cette période.
</div>
</div>
</section>
</div>
</BaseLayout>
<style>
th { white-space: nowrap; }
</style>

View file

@ -0,0 +1,134 @@
/**
* Script de collecte des indicateurs open data
* Exécuté en pre-build : génère src/data/live/indicators.json
*
* Sources :
* - Eurostat API (CORS ouvert, JSON-stat 2.0)
* - INSEE BDM API (SDMX-JSON, public)
*
* Usage :
* npx tsx src/scripts/fetch-indicators.ts
* npm run fetch-data
*/
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { fetchIndicateur } from '../lib/eurostat.js';
import { fetchSerie, SERIES_INSEE } from '../lib/insee.js';
import { INDICATEURS, PAYS_COMPARES, PAYS_LABEL } from '../lib/indicators-config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(__dirname, '../../src/data/live');
const OUT_FILE = join(OUT_DIR, 'indicators.json');
// ─── TYPES SORTIE ─────────────────────────────────────────────────────────────
interface IndicateurResult {
id: string;
label: string;
unite: string;
theme: string | string[];
derniere_maj: string;
france: { value: number | null; year: string };
pays: Record<string, { value: number | null; year: string; label: string }>;
sens_positif?: 'hausse' | 'baisse';
note?: string;
}
interface IndicateursJSON {
generated_at: string;
source: string;
indicators: IndicateurResult[];
insee: Record<string, { value: number | null; period: string }>;
}
// ─── FETCH ────────────────────────────────────────────────────────────────────
async function main() {
console.log('[fetch-indicators] Démarrage...');
const startTime = Date.now();
const indicators: IndicateurResult[] = [];
const inseeData: Record<string, { value: number | null; period: 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...`);
const eurostatInds = INDICATEURS.filter(i => i.source === 'eurostat' && i.dataset);
const BATCH = 5;
for (let i = 0; i < eurostatInds.length; i += BATCH) {
const batch = eurostatInds.slice(i, i + BATCH);
const results = await Promise.allSettled(
batch.map(ind =>
fetchIndicateur(
ind.dataset!,
ind.filters || {},
[...PAYS_COMPARES],
).then(geoValues => ({ ind, geoValues }))
)
);
for (const res of results) {
if (res.status === 'rejected') continue;
const { ind, geoValues } = res.value;
const fr = geoValues['FR'] || { value: null, year: '' };
const pays: IndicateurResult['pays'] = {};
for (const [code, val] of Object.entries(geoValues)) {
pays[code] = { ...val, label: PAYS_LABEL[code] || code };
}
indicators.push({
id: ind.id,
label: ind.label,
unite: ind.unite,
theme: ind.theme,
derniere_maj: new Date().toISOString().slice(0, 10),
france: fr,
pays,
sens_positif: ind.sens_positif,
note: ind.note,
});
console.log(`${ind.id}: FR=${fr.value !== null ? fr.value : 'N/D'} (${fr.year})`);
}
}
// 2. INSEE — fetch séries nationales
console.log(`[fetch-indicators] Fetch ${Object.keys(SERIES_INSEE).length} séries INSEE...`);
const inseeResults = await Promise.allSettled(
Object.entries(SERIES_INSEE).map(async ([key, id]) => {
const val = await fetchSerie(id);
return { key, val };
})
);
for (const res of inseeResults) {
if (res.status === 'rejected') continue;
inseeData[res.value.key] = res.value.val;
console.log(` ✓ INSEE.${res.value.key}: ${res.value.val.value} (${res.value.val.period})`);
}
// 3. Écriture du fichier JSON
mkdirSync(OUT_DIR, { recursive: true });
const output: IndicateursJSON = {
generated_at: new Date().toISOString(),
source: 'Eurostat API + INSEE BDM API',
indicators,
insee: inseeData,
};
writeFileSync(OUT_FILE, JSON.stringify(output, null, 2), 'utf-8');
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`[fetch-indicators] ✅ ${indicators.length} indicateurs Eurostat + ${Object.keys(inseeData).length} séries INSEE → ${OUT_FILE} (${elapsed}s)`);
}
main().catch(err => {
console.error('[fetch-indicators] ERREUR:', err);
process.exit(1);
});