From a35c43661bc525d915f594383115749a2963bc54 Mon Sep 17 00:00:00 2001 From: Marc Reverdin Date: Sun, 7 Jun 2026 00:06:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(irpf):=20couche=20data=20open=20source=20?= =?UTF-8?q?=E2=80=94=20Eurostat=20+=20INSEE=20build-time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 + astro.config.mjs | 2 + package.json | 5 +- pnpm-lock.yaml | 309 +++++++++++++++++- src/components/KpiLive.astro | 90 ++++++ src/integrations/fetch-indicators.ts | 50 +++ src/lib/eurostat.ts | 136 ++++++++ src/lib/indicators-config.ts | 455 +++++++++++++++++++++++++++ src/lib/insee.ts | 105 +++++++ src/pages/donnees/index.astro | 222 +++++++++++++ src/scripts/fetch-indicators.ts | 134 ++++++++ 11 files changed, 1497 insertions(+), 14 deletions(-) create mode 100644 src/components/KpiLive.astro create mode 100644 src/integrations/fetch-indicators.ts create mode 100644 src/lib/eurostat.ts create mode 100644 src/lib/indicators-config.ts create mode 100644 src/lib/insee.ts create mode 100644 src/pages/donnees/index.astro create mode 100644 src/scripts/fetch-indicators.ts diff --git a/.gitignore b/.gitignore index 16d54bb..8196001 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index ee1f462..1841a47 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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(), ], diff --git a/package.json b/package.json index 10f2aca..c36efa6 100644 --- a/package.json +++ b/package.json @@ -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" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d3c084..19f46d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/components/KpiLive.astro b/src/components/KpiLive.astro new file mode 100644 index 0000000..0c4de35 --- /dev/null +++ b/src/components/KpiLive.astro @@ -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; + 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); +--- + +
+ + +
+ + Eurostat {annee} +
+ + +
+ {valeurFormatted} + {valeur !== null && {unite}} +
+ + +
0 ? '0.75rem' : '0'};`}>{label}
+ + + {!compact && paysKeys.length > 0 && valeur !== null && ( +
+ {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 ( + + {isEU ? 'UE' : code} {formatted} {unite} + + ); + })} +
+ )} + + {note &&
{note}
} +
+ + diff --git a/src/integrations/fetch-indicators.ts b/src/integrations/fetch-indicators.ts new file mode 100644 index 0000000..fb83c51 --- /dev/null +++ b/src/integrations/fetch-indicators.ts @@ -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}`); + } + }, + }, + }; +} diff --git a/src/lib/eurostat.ts b/src/lib/eurostat.ts new file mode 100644 index 0000000..4b10bbb --- /dev/null +++ b/src/lib/eurostat.ts @@ -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; // { FR: {...}, DE: {...}, ... } + +interface JSONStat { + id: string[]; + size: number[]; + dimension: Record; + label: Record; + }; + }>; + value: Record | 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(idx)]; + + const getValue = Array.isArray(data.value) + ? (idx: number) => (data.value as number[])[idx] + : (idx: number) => (data.value as Record)[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, + geos: string[] = ['FR', 'DE', 'ES', 'IT', 'SE', 'NL', 'EU27_2020'], +): Promise { + 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, + pays: string[], +): Promise { + return fetchIndicateur(dataset, filters, pays); +} diff --git a/src/lib/indicators-config.ts b/src/lib/indicators-config.ts new file mode 100644 index 0000000..f3e365b --- /dev/null +++ b/src/lib/indicators-config.ts @@ -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 = { + 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; + // 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 = {}; +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); + } +} diff --git a/src/lib/insee.ts b/src/lib/insee.ts new file mode 100644 index 0000000..ff39c4b --- /dev/null +++ b/src/lib/insee.ts @@ -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; +} + +/** + * Fetch une série INSEE BDM par son identifiant. + * Retourne la dernière valeur disponible. + */ +export async function fetchSerie(serieId: string): Promise { + 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; diff --git a/src/pages/donnees/index.astro b/src/pages/donnees/index.astro new file mode 100644 index 0000000..ae39eb0 --- /dev/null +++ b/src/pages/donnees/index.astro @@ -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']; +--- + + + + +
+
+
IRPF — DONNÉES OUVERTES
+

Tableau de bord France

+

+ 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. +

+ {generatedAt && ( +
+ + Mis à jour le {generatedAt} — {indicateursData.indicators?.length || 0} indicateurs collectés +
+ )} +
+
+ + +
+
+ +
+
+ +
+ + + {GROUPES.map(groupe => { + const inds = groupe.ids.map(id => byId[id]).filter(Boolean); + if (inds.length === 0) return null; + return ( +
+
+
+

{groupe.titre}

+
+
+
+ {inds.map((ind: any) => ( + + ))} +
+
+ ); + })} + + +
+
+

Comparatif France — Europe

+
+ Source : Eurostat API +
+ +
+ + + + + {PAYS_ORDRE.map(code => ( + + ))} + + + + {INDICATEURS_TABLE.map((id, idx) => { + const ind = byId[id]; + if (!ind) return null; + return ( + + + {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 ( + + ); + })} + + ); + })} + +
Indicateur + {code === 'EU27_2020' ? 'UE27' : code} +
+ {ind.label} +
{ind.unite}
+
+ {formatted} +
+
+

+ Dernière mise à jour : {generatedAt || 'N/A'} — Eurostat API JSON-stat 2.0 +

+
+ + +
+

Sources et méthodologie

+
+
+ Eurostat — 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. +
+
+ Comparaison — France vs Allemagne, Espagne, Italie, Suède, Pays-Bas et moyenne UE27. + Les valeurs en rouge indiquent un retard par rapport à la moyenne UE ; + les valeurs en vert indiquent une avance. + Données non disponibles (N/D) = non publiées par Eurostat pour cette période. +
+
+
+ +
+ +
+ + diff --git a/src/scripts/fetch-indicators.ts b/src/scripts/fetch-indicators.ts new file mode 100644 index 0000000..df31138 --- /dev/null +++ b/src/scripts/fetch-indicators.ts @@ -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; + sens_positif?: 'hausse' | 'baisse'; + note?: string; +} + +interface IndicateursJSON { + generated_at: string; + source: string; + indicators: IndicateurResult[]; + insee: Record; +} + +// ─── FETCH ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('[fetch-indicators] Démarrage...'); + const startTime = Date.now(); + + const indicators: IndicateurResult[] = []; + const inseeData: Record = {}; + + // 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); +});