From e2de83ad4183d953d954ac46ba5a52d42340f87e Mon Sep 17 00:00:00 2001 From: Jean-Marc Date: Sat, 12 Sep 2026 20:33:47 +0000 Subject: [PATCH] =?UTF-8?q?V1.23.25=20Mise=20=C3=A0=20jour=20de=20la=20BDD?= =?UTF-8?q?=20des=20entreprises=20avec=2022=20et=2035?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + journal/ASPIC - Journal. Index.md | 2 +- scripts/sirene_extract.py | 466 ++++++++++++++++++++++++++++++ 3 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 scripts/sirene_extract.py diff --git a/.gitignore b/.gitignore index d772fec..9d4f8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # Répertoire d'échange temporaire de fichiers avec Claude — jamais versionné. claude_code/ + +# Cache bytecode Python, regenere automatiquement. +scripts/__pycache__/ diff --git a/journal/ASPIC - Journal. Index.md b/journal/ASPIC - Journal. Index.md index 684697c..27f1ecc 100644 --- a/journal/ASPIC - Journal. Index.md +++ b/journal/ASPIC - Journal. Index.md @@ -183,4 +183,4 @@ Version incrémentée une seule fois pour l'ensemble de cette session (uniquemen - Affiner individuellement l'identité/le relationnel de chaque collaborateur (Matilda, Viktor, Léon, Nikita, Sophia) — explicitement reporté par Jean-Marc le 11 septembre 2026 à plus tard ("quand je n'aurai que ça à faire"). - Créer un répertoire dédié sur le serveur pour transmettre directement des fichiers à Claude (photos notamment) — envisagé par Jean-Marc le 11 septembre 2026, pas encore fait. - **Services externes pour les futurs chantiers (prospection, réseaux sociaux) — réflexions stratégiques section 23 (11 septembre 2026).** Volet prospection largement avancé dans la nuit du 11 au 12 septembre : canal Sirene/RNE choisi et mis en œuvre pour de bon (voir réflexions stratégiques section 25) — décision de méthode de la section 23 confirmée dans les faits (gratuit d'abord, achat de contact seulement une fois la cible resserrée). Volet réseaux sociaux toujours entièrement ouvert, recherche à reprendre quand ce chantier s'ouvrira. -- **Prospection B2B (réflexions stratégiques section 25, 11-12 septembre 2026) — suite à donner :** étendre l'extraction Sirene/RNE aux départements 22 et 35 (puis 17 et 64 évoqués pour l'usage "déplacement personnel"), avec le script `/docker/aspic/scripts/sirene_extract.py` (hors dépôt applicatif, non versionné à ce stade — décider un jour s'il mérite son propre dépôt Git). Concevoir et construire la synchronisation quotidienne (API Sirene officielle `api.insee.fr` + API différentielle RNE/INPI `companies/diff`/`bilans-saisis`, toutes deux identifiées mais non implémentées). Phase3 (historique financier) alimentée pour l'instant par le seul script d'extraction ponctuel, pas encore branchée à une tâche récurrente réelle. Écran de sélection par isochrone (temps de trajet depuis un point quelconque, IGN Géoplateforme, démontré fonctionnel) — rien construit côté interface, idée seule actée à ce stade. +- **Prospection B2B (réflexions stratégiques section 25, 11-12 septembre 2026) — mise à jour du 12 septembre 2026 (après-midi) : les 4 départements bretons sont désormais tous extraits.** `Entreprises56/29/22/35Phase1` alimentées via `src/scripts/sirene_extract.py` (déplacé de `/docker/aspic/scripts/` dans le dépôt applicatif ce même après-midi, pour bénéficier du même suivi Git que le reste — voir `.gitignore` mis à jour pour exclure `scripts/__pycache__/`) : 167 468 + 174 691 + 128 904 + 221 646 = **692 709 entreprises**, Phase3 (historique financier) alimentée en parallèle pour chacun. Cas limite rencontré puis corrigé le même après-midi : sur la commune de **Rennes** (35238, pas Saint-Malo comme noté par erreur dans un premier temps), la section NAF "L" (activités immobilières) restait plafonnée à 10 000 même après repli par section — ajout d'un 3e niveau de repli par code NAF précis (liste complète des 732 codes récupérée depuis le message d'erreur de validation de l'API elle-même), Rennes réextraite avec succès derrière ce correctif. Restent à traiter : 17 et 64, évoqués pour l'usage "déplacement personnel", pas de calendrier fixé. **Suite immédiate décidée :** mettre en place la synchronisation quotidienne (API Sirene officielle `api.insee.fr` + API différentielle RNE/INPI `companies/diff`/`bilans-saisis`) déclenchée par n8n (déjà déployé sur le VPS, `n8n.mvl-tls.fr`) plutôt qu'une crontab classique — nécessite au préalable la création de deux comptes (INSEE, INPI), pas encore faite. Écran de sélection par isochrone (temps de trajet depuis un point quelconque, IGN Géoplateforme, démontré fonctionnel) — rien construit côté interface, idée seule actée à ce stade. diff --git a/scripts/sirene_extract.py b/scripts/sirene_extract.py new file mode 100644 index 0000000..06ec119 --- /dev/null +++ b/scripts/sirene_extract.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Extraction Sirene/RNE (recherche-entreprises.api.gouv.fr) par departement, +avec decoupage automatique commune -> section NAF pour contourner le plafond +de 10 000 resultats par requete de l'API. + +Alimente, dans la base ENTREPRISES (instance mysql2) : + - EntreprisesPhase1 (cree si absente, jamais ecrasee si presente) + - EntreprisesPhase2 (cree si absente) + - ENT_Communes (table partagee, alimentee une fois par departement) + +Ne touche jamais aux colonnes de qualification/prospection (ENT_StatutQualification, +ENT_CanalProspection, ENT_Phase2, ENT_ScoreCiblage, ENT_Notes) : seules les colonnes +issues de l'API sont mises a jour sur un SIREN deja present. + +Usage : + python3 sirene_extract.py --departements 56,17,64 --db-pass '...' + + (ou export ENTREPRISES_DB_PASS=... pour ne pas passer le mot de passe en argument) +""" +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +import pymysql + +API_SEARCH = "https://recherche-entreprises.api.gouv.fr/search" +API_GEO = "https://geo.api.gouv.fr/departements/{dep}/communes" +NAF_SECTIONS = list("ABCDEFGHIJKLMNOPQRSTU") + +# Liste complete des 732 codes NAF (rev. 2) reconnus par l'API, recuperee depuis son +# propre message d'erreur de validation. Utilisee comme 3e niveau de repli (apres +# commune puis section NAF) quand une section reste plafonnee a 10000 malgre le 2e +# niveau -- rare (1 cas sur ~950 communes traitees au 12 septembre 2026 : Rennes, +# section L, tranche NN), mais le decoupage par section seule ne suffit alors plus. +NAF_CODES = [ + '01.11Z', '01.12Z', '01.13Z', '01.14Z', '01.15Z', '01.16Z', '01.19Z', '01.21Z', '01.22Z', '01.23Z', + '01.24Z', '01.25Z', '01.26Z', '01.27Z', '01.28Z', '01.29Z', '01.30Z', '01.41Z', '01.42Z', '01.43Z', + '01.44Z', '01.45Z', '01.46Z', '01.47Z', '01.49Z', '01.50Z', '01.61Z', '01.62Z', '01.63Z', '01.64Z', + '01.70Z', '02.10Z', '02.20Z', '02.30Z', '02.40Z', '03.11Z', '03.12Z', '03.21Z', '03.22Z', '05.10Z', + '05.20Z', '06.10Z', '06.20Z', '07.10Z', '07.21Z', '07.29Z', '08.11Z', '08.12Z', '08.91Z', '08.92Z', + '08.93Z', '08.99Z', '09.10Z', '09.90Z', '10.11Z', '10.12Z', '10.13A', '10.13B', '10.20Z', '10.31Z', + '10.32Z', '10.39A', '10.39B', '10.41A', '10.41B', '10.42Z', '10.51A', '10.51B', '10.51C', '10.51D', + '10.52Z', '10.61A', '10.61B', '10.62Z', '10.71A', '10.71B', '10.71C', '10.71D', '10.72Z', '10.73Z', + '10.81Z', '10.82Z', '10.83Z', '10.84Z', '10.85Z', '10.86Z', '10.89Z', '10.91Z', '10.92Z', '11.01Z', + '11.02A', '11.02B', '11.03Z', '11.04Z', '11.05Z', '11.06Z', '11.07A', '11.07B', '12.00Z', '13.10Z', + '13.20Z', '13.30Z', '13.91Z', '13.92Z', '13.93Z', '13.94Z', '13.95Z', '13.96Z', '13.99Z', '14.11Z', + '14.12Z', '14.13Z', '14.14Z', '14.19Z', '14.20Z', '14.31Z', '14.39Z', '15.11Z', '15.12Z', '15.20Z', + '16.10A', '16.10B', '16.21Z', '16.22Z', '16.23Z', '16.24Z', '16.29Z', '17.11Z', '17.12Z', '17.21A', + '17.21B', '17.21C', '17.22Z', '17.23Z', '17.24Z', '17.29Z', '18.11Z', '18.12Z', '18.13Z', '18.14Z', + '18.20Z', '19.10Z', '19.20Z', '20.11Z', '20.12Z', '20.13A', '20.13B', '20.14Z', '20.15Z', '20.16Z', + '20.17Z', '20.20Z', '20.30Z', '20.41Z', '20.42Z', '20.51Z', '20.52Z', '20.53Z', '20.59Z', '20.60Z', + '21.10Z', '21.20Z', '22.11Z', '22.19Z', '22.21Z', '22.22Z', '22.23Z', '22.29A', '22.29B', '23.11Z', + '23.12Z', '23.13Z', '23.14Z', '23.19Z', '23.20Z', '23.31Z', '23.32Z', '23.41Z', '23.42Z', '23.43Z', + '23.44Z', '23.49Z', '23.51Z', '23.52Z', '23.61Z', '23.62Z', '23.63Z', '23.64Z', '23.65Z', '23.69Z', + '23.70Z', '23.91Z', '23.99Z', '24.10Z', '24.20Z', '24.31Z', '24.32Z', '24.33Z', '24.34Z', '24.41Z', + '24.42Z', '24.43Z', '24.44Z', '24.45Z', '24.46Z', '24.51Z', '24.52Z', '24.53Z', '24.54Z', '25.11Z', + '25.12Z', '25.21Z', '25.29Z', '25.30Z', '25.40Z', '25.50A', '25.50B', '25.61Z', '25.62A', '25.62B', + '25.71Z', '25.72Z', '25.73A', '25.73B', '25.91Z', '25.92Z', '25.93Z', '25.94Z', '25.99A', '25.99B', + '26.11Z', '26.12Z', '26.20Z', '26.30Z', '26.40Z', '26.51A', '26.51B', '26.52Z', '26.60Z', '26.70Z', + '26.80Z', '27.11Z', '27.12Z', '27.20Z', '27.31Z', '27.32Z', '27.33Z', '27.40Z', '27.51Z', '27.52Z', + '27.90Z', '28.11Z', '28.12Z', '28.13Z', '28.14Z', '28.15Z', '28.21Z', '28.22Z', '28.23Z', '28.24Z', + '28.25Z', '28.29A', '28.29B', '28.30Z', '28.41Z', '28.49Z', '28.91Z', '28.92Z', '28.93Z', '28.94Z', + '28.95Z', '28.96Z', '28.99A', '28.99B', '29.10Z', '29.20Z', '29.31Z', '29.32Z', '30.11Z', '30.12Z', + '30.20Z', '30.30Z', '30.40Z', '30.91Z', '30.92Z', '30.99Z', '31.01Z', '31.02Z', '31.03Z', '31.09A', + '31.09B', '32.11Z', '32.12Z', '32.13Z', '32.20Z', '32.30Z', '32.40Z', '32.50A', '32.50B', '32.91Z', + '32.99Z', '33.11Z', '33.12Z', '33.13Z', '33.14Z', '33.15Z', '33.16Z', '33.17Z', '33.19Z', '33.20A', + '33.20B', '33.20C', '33.20D', '35.11Z', '35.12Z', '35.13Z', '35.14Z', '35.21Z', '35.22Z', '35.23Z', + '35.30Z', '36.00Z', '37.00Z', '38.11Z', '38.12Z', '38.21Z', '38.22Z', '38.31Z', '38.32Z', '39.00Z', + '41.10A', '41.10B', '41.10C', '41.10D', '41.20A', '41.20B', '42.11Z', '42.12Z', '42.13A', '42.13B', + '42.21Z', '42.22Z', '42.91Z', '42.99Z', '43.11Z', '43.12A', '43.12B', '43.13Z', '43.21A', '43.21B', + '43.22A', '43.22B', '43.29A', '43.29B', '43.31Z', '43.32A', '43.32B', '43.32C', '43.33Z', '43.34Z', + '43.39Z', '43.91A', '43.91B', '43.99A', '43.99B', '43.99C', '43.99D', '43.99E', '45.11Z', '45.19Z', + '45.20A', '45.20B', '45.31Z', '45.32Z', '45.40Z', '46.11Z', '46.12A', '46.12B', '46.13Z', '46.14Z', + '46.15Z', '46.16Z', '46.17A', '46.17B', '46.18Z', '46.19A', '46.19B', '46.21Z', '46.22Z', '46.23Z', + '46.24Z', '46.31Z', '46.32A', '46.32B', '46.32C', '46.33Z', '46.34Z', '46.35Z', '46.36Z', '46.37Z', + '46.38A', '46.38B', '46.39A', '46.39B', '46.41Z', '46.42Z', '46.43Z', '46.44Z', '46.45Z', '46.46Z', + '46.47Z', '46.48Z', '46.49Z', '46.51Z', '46.52Z', '46.61Z', '46.62Z', '46.63Z', '46.64Z', '46.65Z', + '46.66Z', '46.69A', '46.69B', '46.69C', '46.71Z', '46.72Z', '46.73A', '46.73B', '46.74A', '46.74B', + '46.75Z', '46.76Z', '46.77Z', '46.90Z', '47.11A', '47.11B', '47.11C', '47.11D', '47.11E', '47.11F', + '47.19A', '47.19B', '47.21Z', '47.22Z', '47.23Z', '47.24Z', '47.25Z', '47.26Z', '47.29Z', '47.30Z', + '47.41Z', '47.42Z', '47.43Z', '47.51Z', '47.52A', '47.52B', '47.53Z', '47.54Z', '47.59A', '47.59B', + '47.61Z', '47.62Z', '47.63Z', '47.64Z', '47.65Z', '47.71Z', '47.72A', '47.72B', '47.73Z', '47.74Z', + '47.75Z', '47.76Z', '47.77Z', '47.78A', '47.78B', '47.78C', '47.79Z', '47.81Z', '47.82Z', '47.89Z', + '47.91A', '47.91B', '47.99A', '47.99B', '49.10Z', '49.20Z', '49.31Z', '49.32Z', '49.39A', '49.39B', + '49.39C', '49.41A', '49.41B', '49.41C', '49.42Z', '49.50Z', '50.10Z', '50.20Z', '50.30Z', '50.40Z', + '51.10Z', '51.21Z', '51.22Z', '52.10A', '52.10B', '52.21Z', '52.22Z', '52.23Z', '52.24A', '52.24B', + '52.29A', '52.29B', '53.10Z', '53.20Z', '55.10Z', '55.20Z', '55.30Z', '55.90Z', '56.10A', '56.10B', + '56.10C', '56.21Z', '56.29A', '56.29B', '56.30Z', '58.11Z', '58.12Z', '58.13Z', '58.14Z', '58.19Z', + '58.21Z', '58.29A', '58.29B', '58.29C', '59.11A', '59.11B', '59.11C', '59.12Z', '59.13A', '59.13B', + '59.14Z', '59.20Z', '60.10Z', '60.20A', '60.20B', '61.10Z', '61.20Z', '61.30Z', '61.90Z', '62.01Z', + '62.02A', '62.02B', '62.03Z', '62.09Z', '63.11Z', '63.12Z', '63.91Z', '63.99Z', '64.11Z', '64.19Z', + '64.20Z', '64.30Z', '64.91Z', '64.92Z', '64.99Z', '65.11Z', '65.12Z', '65.20Z', '65.30Z', '66.11Z', + '66.12Z', '66.19A', '66.19B', '66.21Z', '66.22Z', '66.29Z', '66.30Z', '68.10Z', '68.20A', '68.20B', + '68.31Z', '68.32A', '68.32B', '69.10Z', '69.20Z', '70.10Z', '70.21Z', '70.22Z', '71.11Z', '71.12A', + '71.12B', '71.20A', '71.20B', '72.11Z', '72.19Z', '72.20Z', '73.11Z', '73.12Z', '73.20Z', '74.10Z', + '74.20Z', '74.30Z', '74.90A', '74.90B', '75.00Z', '77.11A', '77.11B', '77.12Z', '77.21Z', '77.22Z', + '77.29Z', '77.31Z', '77.32Z', '77.33Z', '77.34Z', '77.35Z', '77.39Z', '77.40Z', '78.10Z', '78.20Z', + '78.30Z', '79.11Z', '79.12Z', '79.90Z', '80.10Z', '80.20Z', '80.30Z', '81.10Z', '81.21Z', '81.22Z', + '81.29A', '81.29B', '81.30Z', '82.11Z', '82.19Z', '82.20Z', '82.30Z', '82.91Z', '82.92Z', '82.99Z', + '84.11Z', '84.12Z', '84.13Z', '84.21Z', '84.22Z', '84.23Z', '84.24Z', '84.25Z', '84.30A', '84.30B', + '84.30C', '85.10Z', '85.20Z', '85.31Z', '85.32Z', '85.41Z', '85.42Z', '85.51Z', '85.52Z', '85.53Z', + '85.59A', '85.59B', '85.60Z', '86.10Z', '86.21Z', '86.22A', '86.22B', '86.22C', '86.23Z', '86.90A', + '86.90B', '86.90C', '86.90D', '86.90E', '86.90F', '87.10A', '87.10B', '87.10C', '87.20A', '87.20B', + '87.30A', '87.30B', '87.90A', '87.90B', '88.10A', '88.10B', '88.10C', '88.91A', '88.91B', '88.99A', + '88.99B', '90.01Z', '90.02Z', '90.03A', '90.03B', '90.04Z', '91.01Z', '91.02Z', '91.03Z', '91.04Z', + '92.00Z', '93.11Z', '93.12Z', '93.13Z', '93.19Z', '93.21Z', '93.29Z', '94.11Z', '94.12Z', '94.20Z', + '94.91Z', '94.92Z', '94.99Z', '95.11Z', '95.12Z', '95.21Z', '95.22Z', '95.23Z', '95.24Z', '95.25Z', + '95.29Z', '96.01A', '96.01B', '96.02A', '96.02B', '96.03Z', '96.04Z', '96.09Z', '97.00Z', '98.10Z', + '98.20Z', '99.00Z', +] + +CAP = 10000 +PAGE_SIZE = 25 +RATE_DELAY = 0.15 # ~6-7 req/s, sous le quota documente de l'API + +DDL_PHASE1 = """ +CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` ( + `ENT_Siren` char(9) NOT NULL, + `ENT_NomComplet` varchar(255) DEFAULT NULL, + `ENT_NomRaisonSociale` varchar(255) DEFAULT NULL, + `ENT_Sigle` varchar(50) DEFAULT NULL, + `ENT_AdresseSiege` varchar(255) DEFAULT NULL, + `ENT_CodePostal` varchar(20) DEFAULT NULL, + `ENT_Commune` varchar(100) DEFAULT NULL, + `ENT_Departement` char(3) DEFAULT NULL, + `ENT_Region` char(2) DEFAULT NULL, + `ENT_DateCreation` date DEFAULT NULL, + `ENT_EtatAdministratif` char(1) DEFAULT NULL, + `ENT_DateFermeture` date DEFAULT NULL, + `ENT_ActivitePrincipale` varchar(10) DEFAULT NULL, + `ENT_SectionActivite` char(1) DEFAULT NULL, + `ENT_NatureJuridique` varchar(10) DEFAULT NULL, + `ENT_CategorieJuridique` enum('Privee','Publique','Association') NOT NULL DEFAULT 'Privee', + `ENT_CategorieEntreprise` varchar(5) DEFAULT NULL, + `ENT_TrancheEffectifSalarie` varchar(3) DEFAULT NULL, + `ENT_AnneeEffectif` year DEFAULT NULL, + `ENT_DirigeantNom` varchar(150) DEFAULT NULL, + `ENT_DirigeantPrenoms` varchar(150) DEFAULT NULL, + `ENT_DirigeantQualite` varchar(255) DEFAULT NULL, + `ENT_FinancesAnnee` year DEFAULT NULL, + `ENT_FinancesCa` bigint DEFAULT NULL, + `ENT_FinancesResultatNet` bigint DEFAULT NULL, + `ENT_StatutQualification` enum('a_qualifier','qualifiee','rejetee','contactee') NOT NULL DEFAULT 'a_qualifier', + `ENT_CanalProspection` enum('AEvaluer','Ecartee','Email','Courrier','TelephoneRobot') NOT NULL DEFAULT 'AEvaluer', + `ENT_Phase2` varchar(1) NOT NULL DEFAULT 'N', + `ENT_ScoreCiblage` tinyint DEFAULT NULL, + `ENT_Notes` text, + `ENT_DateImport` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ENT_DateMajInsee` datetime DEFAULT NULL, + `ENT_ReponseBruteJson` json DEFAULT NULL, + PRIMARY KEY (`ENT_Siren`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +""" + +DDL_PHASE2 = """ +CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase2` ( + `ENT_Siren` char(9) NOT NULL, + `ENT_SiteWeb` varchar(255) DEFAULT NULL, + `ENT_Mail` varchar(255) DEFAULT NULL, + `ENT_Telephone` varchar(20) DEFAULT NULL, + `ENT_Portable` varchar(20) DEFAULT NULL, + PRIMARY KEY (`ENT_Siren`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +""" + +DDL_PHASE3 = """ +CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase3` ( + `ENT_Siren` char(9) NOT NULL, + `ENT_FinancesAnnee` year NOT NULL, + `ENT_FinancesCa` bigint DEFAULT NULL, + `ENT_FinancesResultatNet` bigint DEFAULT NULL, + `ENT_DateMaj` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`ENT_Siren`, `ENT_FinancesAnnee`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +""" + +DDL_COMMUNES = """ +CREATE TABLE IF NOT EXISTS `ENT_Communes` ( + `COM_CodeInsee` char(5) NOT NULL, + `COM_CodePostal` varchar(20) DEFAULT NULL, + `COM_Ville` varchar(100) NOT NULL, + `COM_Departement` char(3) NOT NULL, + `COM_Latitude` decimal(10,6) DEFAULT NULL, + `COM_Longitude` decimal(10,6) DEFAULT NULL, + PRIMARY KEY (`COM_CodeInsee`), + KEY `idx_departement` (`COM_Departement`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +""" + + +def log(msg): + print(msg, file=sys.stderr, flush=True) + + +def http_get_json(url, retries=5): + for attempt in range(retries): + try: + with urllib.request.urlopen(url, timeout=20) as resp: + return json.load(resp) + except (urllib.error.URLError, TimeoutError) as e: + if attempt == retries - 1: + raise + time.sleep(1 + attempt) + + +def fetch_communes_geo(dep): + url = API_GEO.format(dep=dep) + "?" + urllib.parse.urlencode( + {"fields": "code,nom,centre,codesPostaux,departement"} + ) + return http_get_json(url) + + +NON_DIFFUSIBLE = "[NON-DIFFUSIBLE]" + + +def clean(v): + """Les entites en diffusion partielle/non diffusible (RGPD) renvoient le + sentinel textuel [NON-DIFFUSIBLE] sur de nombreux champs (adresse, code postal, + coordonnees, nom/prenoms du dirigeant...). On le convertit en NULL plutot que + de le stocker (et ca evite aussi de depasser la taille des colonnes typees).""" + return None if v == NON_DIFFUSIBLE else v + + +def derive_categorie_juridique(nature_juridique): + if not nature_juridique: + return "Privee" + first = nature_juridique[0] + if first in ("7", "8"): + return "Publique" + if first == "9": + return "Association" + return "Privee" + + +def map_entreprise(r): + siege = r.get("siege") or {} + dirigeants = r.get("dirigeants") or [] + dirigeant = dirigeants[0] if dirigeants else {} + finances = r.get("finances") or {} + finances_annee = max(finances.keys()) if finances else None + fin = finances.get(finances_annee, {}) if finances_annee else {} + + return { + "ENT_Siren": r.get("siren"), + "ENT_NomComplet": clean(r.get("nom_complet")), + "ENT_NomRaisonSociale": clean(r.get("nom_raison_sociale")), + "ENT_Sigle": clean(r.get("sigle")), + "ENT_AdresseSiege": clean(siege.get("adresse")), + "ENT_CodePostal": clean(siege.get("code_postal")), + "ENT_Commune": clean(siege.get("libelle_commune")), + "ENT_Departement": clean(siege.get("departement")), + "ENT_Region": clean(siege.get("region")), + "ENT_DateCreation": clean(r.get("date_creation")), + "ENT_EtatAdministratif": clean(r.get("etat_administratif")), + "ENT_DateFermeture": clean(r.get("date_fermeture")), + "ENT_ActivitePrincipale": clean(r.get("activite_principale")), + "ENT_SectionActivite": clean(r.get("section_activite_principale")), + "ENT_NatureJuridique": clean(r.get("nature_juridique")), + "ENT_CategorieJuridique": derive_categorie_juridique(clean(r.get("nature_juridique"))), + "ENT_CategorieEntreprise": clean(r.get("categorie_entreprise")), + "ENT_TrancheEffectifSalarie": clean(r.get("tranche_effectif_salarie")), + "ENT_AnneeEffectif": clean(r.get("annee_tranche_effectif_salarie")), + "ENT_DirigeantNom": clean(dirigeant.get("nom")), + "ENT_DirigeantPrenoms": clean(dirigeant.get("prenoms")), + "ENT_DirigeantQualite": clean(dirigeant.get("qualite")), + "ENT_FinancesAnnee": finances_annee, + "ENT_FinancesCa": fin.get("ca"), + "ENT_FinancesResultatNet": fin.get("resultat_net"), + "ENT_ReponseBruteJson": json.dumps(r, ensure_ascii=False), + } + + +def probe_total(params): + p = dict(params, per_page=1) + data = http_get_json(API_SEARCH + "?" + urllib.parse.urlencode(p)) + time.sleep(RATE_DELAY) + return data.get("total_results", 0) + + +def paginate_all(params, seen): + p = dict(params, per_page=PAGE_SIZE) + page = 1 + out = [] + while True: + p["page"] = page + data = http_get_json(API_SEARCH + "?" + urllib.parse.urlencode(p)) + time.sleep(RATE_DELAY) + results = data.get("results", []) + if not results: + break + for r in results: + siren = r.get("siren") + if siren and siren not in seen: + seen.add(siren) + out.append(r) + total_pages = data.get("total_pages", page) + if page >= total_pages or page >= 400: # 400*25 = 10000, garde-fou + break + page += 1 + return out + + +def fetch_commune_entreprises(code_commune, seen): + base = {"code_commune": code_commune, "etat_administratif": "A"} + total = probe_total(base) + if total < CAP: + return paginate_all(base, seen) + + log(f" commune {code_commune} plafonnee ({total}), repli par section NAF") + out = [] + for section in NAF_SECTIONS: + sec_params = dict(base, section_activite_principale=section) + sec_total = probe_total(sec_params) + if sec_total == 0: + continue + if sec_total < CAP: + out.extend(paginate_all(sec_params, seen)) + continue + + # 3e niveau de repli : la section a elle seule reste plafonnee (cas rare, + # ex. Rennes/section L). On decoupe par code NAF precis (732 codes possibles) -- + # un code implique deja sa section, donc on l'utilise seul, sans le combiner + # au filtre de section. + log(f" commune {code_commune} section {section} encore plafonnee ({sec_total}), " + f"repli par code NAF precis") + for code in NAF_CODES: + code_params = dict(base, activite_principale=code) + code_total = probe_total(code_params) + if code_total == 0: + continue + if code_total >= CAP: + log(f" !! commune {code_commune} code {code} encore plafonnee " + f"({code_total}) -- a decouper manuellement (ex: par tranche_effectif_salarie)") + out.extend(paginate_all(code_params, seen)) + return out + + +def upsert_entreprise(conn, table, row): + cols = list(row.keys()) + placeholders = ", ".join(["%s"] * len(cols)) + colnames = ", ".join(f"`{c}`" for c in cols) + updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols if c != "ENT_Siren") + sql = ( + f"INSERT INTO `{table}` ({colnames}) VALUES ({placeholders}) " + f"ON DUPLICATE KEY UPDATE {updates}, `ENT_DateMajInsee`=NOW()" + ) + with conn.cursor() as cur: + cur.execute(sql, list(row.values())) + + +def upsert_finances(conn, table, row): + if row.get("ENT_FinancesAnnee") is None: + return + fin = { + "ENT_Siren": row["ENT_Siren"], + "ENT_FinancesAnnee": row["ENT_FinancesAnnee"], + "ENT_FinancesCa": row["ENT_FinancesCa"], + "ENT_FinancesResultatNet": row["ENT_FinancesResultatNet"], + } + cols = list(fin.keys()) + placeholders = ", ".join(["%s"] * len(cols)) + colnames = ", ".join(f"`{c}`" for c in cols) + updates = ", ".join( + f"`{c}`=VALUES(`{c}`)" for c in cols if c not in ("ENT_Siren", "ENT_FinancesAnnee") + ) + sql = ( + f"INSERT INTO `{table}` ({colnames}) VALUES ({placeholders}) " + f"ON DUPLICATE KEY UPDATE {updates}" + ) + with conn.cursor() as cur: + cur.execute(sql, list(fin.values())) + + +def ensure_tables(conn, dep): + with conn.cursor() as cur: + cur.execute(DDL_PHASE1.format(dep=dep)) + cur.execute(DDL_PHASE2.format(dep=dep)) + cur.execute(DDL_PHASE3.format(dep=dep)) + cur.execute(DDL_COMMUNES) + + +def ensure_communes(conn, dep, communes_geo): + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM ENT_Communes WHERE COM_Departement=%s", (dep,)) + (count,) = cur.fetchone() + if count > 0: + log(f" ENT_Communes deja alimentee pour le {dep} ({count} communes), on ne rejoue pas") + return + rows = [] + for c in communes_geo: + lon, lat = c["centre"]["coordinates"] + cp = ",".join(c.get("codesPostaux", [])) + dep_code = c["departement"]["code"] if isinstance(c.get("departement"), dict) else c.get("departement") + rows.append((c["code"], cp, c["nom"], dep_code, lat, lon)) + cur.executemany( + "INSERT INTO ENT_Communes " + "(COM_CodeInsee, COM_CodePostal, COM_Ville, COM_Departement, COM_Latitude, COM_Longitude) " + "VALUES (%s,%s,%s,%s,%s,%s)", + rows, + ) + log(f" ENT_Communes : {len(rows)} communes inserees pour le {dep}") + + +def run_department(conn, dep): + log(f"=== Departement {dep} ===") + ensure_tables(conn, dep) + communes_geo = fetch_communes_geo(dep) + log(f" {len(communes_geo)} communes (source geo.api.gouv.fr)") + ensure_communes(conn, dep, communes_geo) + + table1 = f"Entreprises{dep}Phase1" + table3 = f"Entreprises{dep}Phase3" + seen = set() + total_upserts = 0 + total_finances = 0 + for c in communes_geo: + results = fetch_commune_entreprises(c["code"], seen) + for r in results: + row = map_entreprise(r) + if row["ENT_Siren"]: + upsert_entreprise(conn, table1, row) + total_upserts += 1 + if row["ENT_FinancesAnnee"] is not None: + upsert_finances(conn, table3, row) + total_finances += 1 + if results: + log(f" {c['nom']} ({c['code']}) : {len(results)} entreprises") + log(f"Departement {dep} termine : {total_upserts} entreprises upsertees dans {table1}, " + f"{total_finances} lignes financieres upsertees dans {table3}") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--departements", required=True, + help="Liste separee par des virgules, ex: 56,17,64") + ap.add_argument("--db-host", default=os.environ.get("ENTREPRISES_DB_HOST", "mysql2")) + ap.add_argument("--db-port", type=int, default=int(os.environ.get("ENTREPRISES_DB_PORT", "3306"))) + ap.add_argument("--db-user", default=os.environ.get("ENTREPRISES_DB_USER", "crm_user")) + ap.add_argument("--db-pass", default=os.environ.get("ENTREPRISES_DB_PASS")) + ap.add_argument("--db-name", default=os.environ.get("ENTREPRISES_DB_NAME", "ENTREPRISES")) + args = ap.parse_args() + + if not args.db_pass: + ap.error("mot de passe requis : --db-pass ou variable d'environnement ENTREPRISES_DB_PASS") + + departements = [d.strip() for d in args.departements.split(",") if d.strip()] + + conn = pymysql.connect( + host=args.db_host, port=args.db_port, user=args.db_user, password=args.db_pass, + database=args.db_name, charset="utf8mb4", autocommit=True, + ) + try: + for dep in departements: + run_department(conn, dep) + finally: + conn.close() + + +if __name__ == "__main__": + main()