#!/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', ] # Plage de divisions (2 premiers chiffres du code NAF) par section -- permet de # limiter le repli par code NAF precis (3e niveau) aux seuls codes de la section # en cours, au lieu de retester les 732 codes en entier a chaque section plafonnee # (bug corrige le 13/09/2026 : provoquait un blocage de plusieurs heures sur les # grandes villes, ex. Toulouse, ou plusieurs sections restent plafonnees). SECTION_DIVISIONS = { "A": range(1, 4), "B": range(5, 10), "C": range(10, 34), "D": range(35, 36), "E": range(36, 40), "F": range(41, 44), "G": range(45, 48), "H": range(49, 54), "I": range(55, 57), "J": range(58, 64), "K": range(64, 67), "L": range(68, 69), "M": range(69, 76), "N": range(77, 83), "O": range(84, 85), "P": range(85, 86), "Q": range(86, 89), "R": range(90, 94), "S": range(94, 97), "T": range(97, 99), "U": range(99, 100), } def codes_for_section(section): divisions = SECTION_DIVISIONS[section] return [c for c in NAF_CODES if int(c[:2]) in divisions] 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_CodeCommune` char(5) 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`), KEY `idx_commune` (`ENT_CodeCommune`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8 """ 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(100) 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 """ DDL_DEPARTEMENTS = """ CREATE TABLE IF NOT EXISTS `ENT_Departements` ( `DEP_Code` char(3) NOT NULL, `DEP_Nom` varchar(100) NOT NULL, `DEP_NbEntreprises` int NOT NULL DEFAULT 0, `DEP_DateExtractionInitiale` datetime DEFAULT NULL, `DEP_DateDerniereSyncFormalites` datetime DEFAULT NULL, PRIMARY KEY (`DEP_Code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """ DDL_CONFIG = """ CREATE TABLE IF NOT EXISTS `ENT_Config` ( `CFG_Cle` varchar(100) NOT NULL, `CFG_Valeur` varchar(255) DEFAULT NULL, PRIMARY KEY (`CFG_Cle`) ) 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) # Paris, Lyon et Marseille (PLM) : dans les donnees Sirene, le siege d'une # entreprise de ces trois villes est quasi toujours code au niveau de # l'arrondissement (75101-75120, 69381-69389, 13201-13216), jamais au code # commune englobant (75056, 69123, 13055) -- verifie empiriquement le 14 # septembre 2026 sur Marseille : code_commune=13055 ne renvoie qu'une dizaine # de resultats, alors que chaque arrondissement (13201 a 13216) est a lui # seul plafonne ou proche du plafond de 10 000. Le code englobant est donc # remplace par la liste de ses arrondissements avant extraction. COMMUNES_ARRONDISSEMENTS = {"75": "75056", "69": "69123", "13": "13055"} def fetch_arrondissements_geo(dep): url = "https://geo.api.gouv.fr/communes?" + urllib.parse.urlencode( {"codeDepartement": dep, "type": "arrondissement-municipal", "fields": "code,nom,centre,codesPostaux,departement"} ) return http_get_json(url) def fetch_communes_geo(dep): url = API_GEO.format(dep=dep) + "?" + urllib.parse.urlencode( {"fields": "code,nom,centre,codesPostaux,departement"} ) communes = http_get_json(url) code_englobant = COMMUNES_ARRONDISSEMENTS.get(dep) if code_englobant: communes = [c for c in communes if c["code"] != code_englobant] communes.extend(fetch_arrondissements_geo(dep)) return communes def fetch_departement_nom(dep): url = f"https://geo.api.gouv.fr/departements/{dep}?fields=code,nom" data = http_get_json(url) return data.get("nom", dep) def upsert_departement(conn, dep, nom, nb_entreprises): with conn.cursor() as cur: cur.execute( "INSERT INTO ENT_Departements (DEP_Code, DEP_Nom, DEP_NbEntreprises, DEP_DateExtractionInitiale) " "VALUES (%s, %s, %s, NOW()) " "ON DUPLICATE KEY UPDATE DEP_Nom=VALUES(DEP_Nom), DEP_NbEntreprises=VALUES(DEP_NbEntreprises)", (dep, nom, nb_entreprises), ) 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_CodeCommune": clean(siege.get("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 codes_for_section(section): 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) cur.execute(DDL_DEPARTEMENTS) cur.execute(DDL_CONFIG) 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") with conn.cursor() as cur: cur.execute(f"SELECT COUNT(*) FROM `{table1}`") (nb_total,) = cur.fetchone() nom_dep = fetch_departement_nom(dep) upsert_departement(conn, dep, nom_dep, nb_total) log(f"Departement {dep} termine : {total_upserts} entreprises upsertees dans {table1}, " f"{total_finances} lignes financieres upsertees dans {table3} (total {nb_total} -- " f"ENT_Departements mise a jour)") 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()