V1.23.28 Mise à jour de la BDD des entreprises et stratégies

This commit is contained in:
2026-09-15 21:45:02 +00:00
parent e2de83ad41
commit 2825430bf4
9 changed files with 871 additions and 7 deletions
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Calcule et stocke, pour chaque commune de ENT_Communes, le nombre d'entreprises
actives par categorie de taille (EI, TPE, PME, ETI, GE) -- pre-calcule pour que
le futur module de recherche par perimetre geographique n'ait jamais a compter
en temps reel sur les tables Entreprises*Phase1 (qui grossissent).
Regle de classification (une entreprise tombe dans une seule categorie) :
- ENT_CategorieEntreprise = 'GE' -> GE
- ENT_CategorieEntreprise = 'ETI' -> ETI
- ENT_CategorieEntreprise = 'PME' -> PME
- categorie NULL et nature juridique = entrepreneur individuel (1er
chiffre de ENT_NatureJuridique = '1') -> EI
- categorie NULL, tout le reste -> TPE
(ENT_CategorieEntreprise, quand renseignee par l'INSEE, est deja fiable et
prioritaire ; les entreprises non categorisees par l'INSEE -- la grande
majorite -- sont reparties entre EI et TPE selon leur forme juridique.)
Concu pour tourner une fois par jour (cf ENT_Communes.COM_DateMajStats) : idem
potent, recalcule et ecrase les compteurs a chaque execution, jamais un delta.
Usage :
python3 ent_communes_stats.py
python3 ent_communes_stats.py --departements 56,29
"""
import argparse
import os
import sys
import pymysql
def log(msg):
print(msg, file=sys.stderr, flush=True)
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def compute_departement(conn, dep):
table1 = f"Entreprises{dep}Phase1"
# IMPORTANT : Entreprises<dep>Phase1 contient aussi des entreprises dont le
# siege est dans un AUTRE departement (etablissement secondaire dans <dep> --
# la "pollution" deja identifiee). On restreint donc explicitement aux
# communes qui appartiennent reellement a <dep> (via ENT_Communes), pour
# qu'un departement ne puisse jamais ecraser les stats d'une commune qui ne
# lui appartient pas avec un sous-ensemble incomplet.
sql = f"""
SELECT
t.ENT_CodeCommune,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'GE'), 0) AS ge,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'ETI'), 0) AS eti,
COALESCE(SUM(t.ENT_CategorieEntreprise = 'PME'), 0) AS pme,
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND LEFT(t.ENT_NatureJuridique, 1) = '1'), 0) AS ei,
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND
(t.ENT_NatureJuridique IS NULL OR LEFT(t.ENT_NatureJuridique, 1) != '1')), 0) AS tpe
FROM `{table1}` t
INNER JOIN ENT_Communes c
ON c.COM_CodeInsee COLLATE utf8mb4_0900_ai_ci = t.ENT_CodeCommune AND c.COM_Departement = %s
WHERE t.ENT_CodeCommune IS NOT NULL
GROUP BY t.ENT_CodeCommune
"""
with conn.cursor() as cur:
cur.execute(sql, (dep,))
rows = cur.fetchall()
with conn.cursor() as cur:
for code_commune, ge, eti, pme, ei, tpe in rows:
cur.execute(
"UPDATE ENT_Communes SET COM_EI=%s, COM_TPE=%s, COM_PME=%s, COM_ETI=%s, "
"COM_GE=%s, COM_DateMajStats=NOW() WHERE COM_CodeInsee=%s",
(ei, tpe, pme, eti, ge, code_commune),
)
log(f" {dep} : {len(rows)} communes mises a jour")
return len(rows)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--departements", default=None,
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
args = ap.parse_args()
env = load_env(args.env_file)
conn = pymysql.connect(
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
)
if args.departements:
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
else:
with conn.cursor() as cur:
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
departements = [row[0] for row in cur.fetchall()]
log(f"Departements a traiter : {departements}")
total = 0
try:
for dep in departements:
total += compute_departement(conn, dep)
finally:
conn.close()
log(f"Termine : {total} communes mises a jour au total")
if __name__ == "__main__":
main()
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
Backfill historique des comptes annuels (API RNE/INPI, /api/bilans-saisis)
dans EntreprisesXXPhase3, pour toutes les entreprises deja connues (une ligne
par SIREN+annee, jamais ecrasee -- voir la conception Phase3).
Contrairement a l'extraction Sirene (sirene_extract.py), cet appel API n'est
pas filtrable par departement : on l'interroge par lot de SIREN (max ~300
avant de depasser la limite de longueur d'URL du serveur, on utilise 250 par
securite), sans limite de date, pour recuperer tout l'historique disponible
(comptes annuels dispo depuis 2017 selon l'espace personnel INPI).
Les donnees saisies sont une liasse fiscale brute (codes CERFA), pas un CA/
resultat net direct -- la table TYPE_BILAN_MAPPING ci-dessous traduit les
codes pertinents pour chaque type de bilan (C, S, K, AC, AS, B), etablie le
12 septembre 2026 a partir de la doc officielle INPI (v5, juin 2025) et
verifiee empiriquement sur un vrai bilan (EDF, SIREN 552081317).
Regle de colonne (verifiee empiriquement) : quand un code liasse a une
colonne m3, elle contient le total de l'exercice en cours (m4 = exercice
precedent) ; sinon c'est m1 qui est l'exercice en cours.
Les bilans en confidentialite non publique ne sont pas exploites (CA/
resultat net non extraits), par respect du choix de confidentialite declare.
Usage :
python3 inpi_bilans_backfill.py
python3 inpi_bilans_backfill.py --departements 56,29
Identifiants lus depuis .env (ENTREPRISES_DB_*, INPI_USERNAME, INPI_PASSWORD)
dans le meme dossier, jamais en argument.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import pymysql
class QuotaExceededError(Exception):
"""Quota de l'API INPI depasse (distinct d'une simple limitation de debit
passagere) -- inutile de retenter, il faut arreter et laisser le quota se
regenerer (journalier selon toute vraisemblance) avant de reprendre."""
API_LOGIN = "https://registre-national-entreprises.inpi.fr/api/sso/login"
API_BILANS_SAISIS = "https://registre-national-entreprises.inpi.fr/api/bilans-saisis"
BATCH_SIZE = 250 # 300 confirme fonctionnel, marge de securite prise
PAGE_SIZE = 100
RATE_DELAY = 0.2
# code liasse -> [codes a sommer pour le CA] / [codes a sommer pour le resultat net]
TYPE_BILAN_MAPPING = {
"C": {"ca": ["FJ"], "rn": ["HN"]},
"S": {"ca": ["209", "210", "214", "215", "217", "218"], "rn": ["310"]},
"K": {"ca": ["FJ"], "rn": ["R6"]},
"AC": {"ca": ["FP"], "rn": ["HO"]},
"AS": {"ca": ["EA", "EB", "EC", "ED", "EE"], "rn": ["FJ"]},
"B": {"ca": [], "rn": ["R3"]},
}
# Priorite quand plusieurs bilans existent pour le meme SIREN+annee (frequent :
# une societe mere depose a la fois ses comptes individuels et consolides).
# On privilegie systematiquement les comptes de l'entite elle-meme (C, AC, AS,
# S) a un chiffre de groupe consolide (K) qui ne represente plus la societe
# precise -- et B (banque) en tout dernier recours seulement.
TYPE_PRIORITY = {"C": 0, "AC": 0, "AS": 1, "S": 2, "K": 3, "B": 4}
def log(msg):
print(msg, file=sys.stderr, flush=True)
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def http_get_json(url, auth, retries=8):
# "auth" est un dict mutable {"token", "username", "password"} plutot qu'un
# jeton brut, pour pouvoir se reconnecter tout seul sur un 401 (jeton perime
# en cours de traitement, constate le 14/09/2026 sur une execution de plusieurs
# heures -- l'ancien code retentait 8 fois avec le meme jeton mort, ce qui ne
# pouvait jamais reussir, et perdait tous les lots restants du departement).
for attempt in range(retries):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {auth['token']}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 401:
if attempt == retries - 1:
raise
log(" !! 401 Unauthorized -- jeton probablement expire, reconnexion")
auth["token"] = login(auth["username"], auth["password"])
continue
if e.code == 429:
# Deux cas bien distincts derriere le meme code HTTP 429, constates
# le 14/09/2026 : une limitation de debit passagere (retentable avec
# patience) et un vrai quota de service depasse (corps de reponse
# {"errorCode":"quota_exceeded","webserviceCode":"QUOTA_SERVICE"} --
# la, retenter ne sert a rien, il faut arreter tout de suite. Avant
# cette distinction, le script s'est acharne des heures a retenter
# un quota epuise, perdant lot apres lot pour rien.
try:
body = json.loads(e.read())
except Exception:
body = {}
if body.get("errorCode") == "quota_exceeded":
raise QuotaExceededError(body.get("message", "quota INPI depasse"))
if attempt == retries - 1:
raise
retry_after = e.headers.get("Retry-After") if e.headers else None
wait = int(retry_after) if retry_after and retry_after.isdigit() else min(10 * (2 ** attempt), 300)
log(f" !! 429 Too Many Requests -- pause de {wait}s avant nouvelle tentative "
f"({attempt + 1}/{retries})")
time.sleep(wait)
continue
if attempt == retries - 1:
raise
log(f" retry apres erreur reseau ({e})")
time.sleep(2 + attempt)
except (urllib.error.URLError, TimeoutError) as e:
if attempt == retries - 1:
raise
log(f" retry apres erreur reseau ({e})")
time.sleep(2 + attempt)
def login(username, password):
req = urllib.request.Request(
API_LOGIN,
data=json.dumps({"username": username, "password": password}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
return json.load(resp)["token"]
def get_liasse_value(pages, code):
for page in pages:
for l in page.get("liasses", []):
if l.get("code") == code:
raw = l.get("m3") if l.get("m3") not in (None, "") else l.get("m1")
if raw in (None, ""):
continue
try:
return int(raw)
except ValueError:
continue
return None
def sum_codes(pages, codes):
if not codes:
return None
total = 0
found_any = False
for code in codes:
v = get_liasse_value(pages, code)
if v is not None:
total += v
found_any = True
return total if found_any else None
def parse_bilan(entry):
"""Retourne (annee, type_bilan, ca, resultat_net) ou None si non exploitable."""
if entry.get("deleted"):
return None
if entry.get("confidentiality") not in (None, "Public"):
return None
bilan_saisi = entry.get("bilanSaisi")
if not bilan_saisi:
return None
bilan = bilan_saisi.get("bilan", {})
identite = bilan.get("identite", {})
type_bilan = identite.get("codeTypeBilan")
mapping = TYPE_BILAN_MAPPING.get(type_bilan)
if mapping is None:
return None
date_cloture = identite.get("dateClotureExercice")
if not date_cloture or len(date_cloture) < 4:
return None
annee = date_cloture[:4]
pages = bilan.get("detail", {}).get("pages", [])
ca = sum_codes(pages, mapping["ca"])
rn = sum_codes(pages, mapping["rn"])
if ca is None and rn is None:
return None
return annee, type_bilan, ca, rn
MAX_PAGES_PAR_LOT = 100 # garde-fou : 100*PAGE_SIZE = 10 000 resultats max par lot,
# au-dela quelque chose ne tourne pas rond (curseur bloque
# ou volume anormal) -- ne jamais boucler indefiniment.
def fetch_bilans_for_batch(sirens, auth):
# Le curseur searchAfter doit porter sur l'identifiant unique du document
# (champ "id", ex. un ObjectId), jamais sur le SIREN : un SIREN a en general
# plusieurs bilans (plusieurs annees, parfois individuel + consolide), donc
# il revient plusieurs fois dans les resultats -- l'utiliser comme curseur
# provoquait un faux "curseur bloque" des qu'un SIREN se trouvait en
# dernniere position d'une page, abandonnant le lot prematurement et
# perdant les bilans non encore recuperes (bug trouve le 14/09/2026 :
# ~14% des lots du departement 31 perdaient ainsi une partie de leur
# historique). Verifie empiriquement : le champ "id" donne une pagination
# propre, sans doublon ni repli, contrairement au SIREN.
out = []
search_after = None
pages_lues = 0
while True:
pages_lues += 1
if pages_lues > MAX_PAGES_PAR_LOT:
log(f" !! lot bloque apres {MAX_PAGES_PAR_LOT} pages "
f"(searchAfter={search_after}) -- abandon de ce lot pour eviter une "
f"boucle infinie, {len(out)} resultats deja recuperes conserves")
break
params = [("siren[]", s) for s in sirens]
params.append(("pageSize", str(PAGE_SIZE)))
if search_after:
params.append(("searchAfter", search_after))
url = API_BILANS_SAISIS + "?" + urllib.parse.urlencode(params)
data = http_get_json(url, auth)
time.sleep(RATE_DELAY)
if not data:
break
out.extend(data)
if len(data) < PAGE_SIZE:
break
nouveau_search_after = data[-1].get("id")
if not nouveau_search_after or nouveau_search_after == search_after:
log(f" !! curseur searchAfter bloque sur {nouveau_search_after} -- "
f"abandon de ce lot, {len(out)} resultats deja recuperes conserves")
break
search_after = nouveau_search_after
return out
def upsert_finance(conn, table, siren, annee, ca, rn):
sql = (
f"INSERT INTO `{table}` (ENT_Siren, ENT_FinancesAnnee, ENT_FinancesCa, ENT_FinancesResultatNet) "
f"VALUES (%s,%s,%s,%s) "
f"ON DUPLICATE KEY UPDATE ENT_FinancesCa=VALUES(ENT_FinancesCa), "
f"ENT_FinancesResultatNet=VALUES(ENT_FinancesResultatNet)"
)
with conn.cursor() as cur:
cur.execute(sql, (siren, annee, ca, rn))
def backfill_departement(conn, dep, auth):
table1 = f"Entreprises{dep}Phase1"
table3 = f"Entreprises{dep}Phase3"
with conn.cursor() as cur:
cur.execute(f"SELECT ENT_Siren FROM `{table1}`")
sirens = [row[0] for row in cur.fetchall()]
log(f"=== Departement {dep} : {len(sirens)} entreprises a traiter ===")
total_rows = 0
total_ignores_confidentiel = 0
total_types_inconnus = 0
unknown_types = {}
for i in range(0, len(sirens), BATCH_SIZE):
batch = sirens[i:i + BATCH_SIZE]
try:
entries = fetch_bilans_for_batch(batch, auth)
except QuotaExceededError:
log(f" !! quota INPI depasse au lot {i}-{i+len(batch)} -- arret immediat du "
f"departement {dep} (inutile de continuer, le quota est global au compte, "
f"pas au departement)")
raise
except Exception as e:
log(f" !! lot {i}-{i+len(batch)} : erreur {e}, on continue")
continue
# best[(siren, annee)] = (priorite, ca, rn) -- on ne garde que le
# bilan de plus haute priorite (cf TYPE_PRIORITY) par SIREN+annee,
# pour ne jamais laisser un bilan consolide (K) ecraser au hasard
# le bilan individuel (C) de la meme annee, ou l'inverse.
best = {}
for entry in entries:
siren = entry.get("siren")
if not siren:
continue
if entry.get("confidentiality") not in (None, "Public"):
total_ignores_confidentiel += 1
continue
bilan_saisi = entry.get("bilanSaisi")
if bilan_saisi:
type_bilan = bilan_saisi.get("bilan", {}).get("identite", {}).get("codeTypeBilan")
if type_bilan and type_bilan not in TYPE_BILAN_MAPPING:
total_types_inconnus += 1
unknown_types[type_bilan] = unknown_types.get(type_bilan, 0) + 1
parsed = parse_bilan(entry)
if parsed is None:
continue
annee, type_bilan, ca, rn = parsed
prio = TYPE_PRIORITY.get(type_bilan, 99)
key = (siren, annee)
current = best.get(key)
if current is None or prio < current[0]:
best[key] = (prio, ca, rn)
for (siren, annee), (_prio, ca, rn) in best.items():
upsert_finance(conn, table3, siren, annee, ca, rn)
total_rows += 1
if (i // BATCH_SIZE) % 20 == 0:
log(f" ... {i+len(batch)}/{len(sirens)} entreprises traitees, "
f"{total_rows} lignes financieres upsertees jusqu'ici")
log(f"Departement {dep} termine : {total_rows} lignes financieres upsertees dans {table3} "
f"({total_ignores_confidentiel} bilans confidentiels ignores, "
f"{total_types_inconnus} types de bilan non mappes : {unknown_types})")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--departements", default=None,
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
args = ap.parse_args()
env = load_env(args.env_file)
conn = pymysql.connect(
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
)
if args.departements:
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
else:
with conn.cursor() as cur:
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
departements = [row[0] for row in cur.fetchall()]
log(f"Departements a traiter : {departements}")
auth = {
"token": login(env["INPI_USERNAME"], env["INPI_PASSWORD"]),
"username": env["INPI_USERNAME"],
"password": env["INPI_PASSWORD"],
}
log("Connexion INPI reussie")
try:
for dep in departements:
backfill_departement(conn, dep, auth)
finally:
conn.close()
if __name__ == "__main__":
main()
+98 -5
View File
@@ -115,6 +115,26 @@ NAF_CODES = [
'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
@@ -128,6 +148,7 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` (
`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,
@@ -154,8 +175,10 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` (
`ENT_DateImport` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ENT_DateMajInsee` datetime DEFAULT NULL,
`ENT_ReponseBruteJson` json DEFAULT NULL,
PRIMARY KEY (`ENT_Siren`)
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 = """
@@ -183,7 +206,7 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase3` (
DDL_COMMUNES = """
CREATE TABLE IF NOT EXISTS `ENT_Communes` (
`COM_CodeInsee` char(5) NOT NULL,
`COM_CodePostal` varchar(20) DEFAULT 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,
@@ -193,6 +216,25 @@ CREATE TABLE IF NOT EXISTS `ENT_Communes` (
) 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)
@@ -209,11 +251,51 @@ def http_get_json(url, retries=5):
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"}
)
return http_get_json(url)
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]"
@@ -254,6 +336,7 @@ def map_entreprise(r):
"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")),
@@ -329,7 +412,7 @@ def fetch_commune_entreprises(code_commune, seen):
# 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:
for code in codes_for_section(section):
code_params = dict(base, activite_principale=code)
code_total = probe_total(code_params)
if code_total == 0:
@@ -383,6 +466,8 @@ def ensure_tables(conn, 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):
@@ -431,8 +516,16 @@ def run_department(conn, dep):
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}")
f"{total_finances} lignes financieres upsertees dans {table3} (total {nb_total} -- "
f"ENT_Departements mise a jour)")
def main():