Collecteurs, chacun isolé et tolérant à l'échec : - legifrance : OAuth PISTE validé en production. Deux pièges vérifiés sur le compte réel — seul le couple « Client ID / Client secret » ouvre un jeton (la clé d'API donne invalid_client), et les identifiants de production ne passent pas en bac à sable. - senat : liste chronologique des lois promulguées. L'URL du §5.1 (senat.fr/lois/index.html) est morte ; l'équivalent fonctionnel est /dossiers-legislatifs/lois-promulguees.html — 31 lois 2026 relevées. - conseil_constitutionnel : sans le paramètre ?id=32246 la page des affaires en instance ne rend que des QPC et aucune affaire DC. Les colonnes DC et QPC diffèrent : lecture par en-tête, jamais par position. Rapprochement à trois issues — numéro officiel, similarité de titre, et une zone de signalement où le pipeline s'abstient plutôt que de fusionner. make update-dry contre les sources réelles : aucun faux positif sur les 52 textes du corpus, et trois apports que le rapport n'avait pas — le numéro d'affaire 2026-907 DC de la programmation militaire, la date de saisine du 16 juillet pour l'aide à mourir, les décisions du 23 juillet sur 908 et 909. Une asymétrie de sources est documentée par un test plutôt que corrigée en douce : le Sénat ne qualifie pas la loi 2026-650 d'organique, Légifrance si. Le collecteur rapporte ce que sa source dit ; le champ n'est pas comparé. Fixtures HTML figées : aucun test ne touche au réseau. 156 tests, 91 % de couverture (chaque module au-dessus du seuil de 70 %). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
8.5 KiB
Python
229 lines
8.5 KiB
Python
"""Collecteur Légifrance, via l'API PISTE de la DILA.
|
|
|
|
Authentification OAuth 2.0 en `client_credentials`. Deux pièges vérifiés le
|
|
25 juillet 2026 sur le compte réel :
|
|
|
|
- une application PISTE expose **deux** couples de valeurs, et seul le couple
|
|
OAuth (« Client ID » / « Client secret ») ouvre le jeton ; la clé d'API
|
|
(« API key » / « API key secret ») produit un `invalid_client` ;
|
|
- les identifiants de production ne sont **pas** acceptés par le bac à sable,
|
|
qui exige une application distincte.
|
|
|
|
Sans identifiants, le collecteur se déclare indisponible et le pipeline bascule
|
|
sur le repli documenté : promulgations de l'Assemblée nationale, liste des lois
|
|
du Sénat, décisions du Conseil constitutionnel.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from datetime import date
|
|
|
|
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
|
|
from pipeline.collecteurs.http import ClientHttp
|
|
from pipeline.journal import logger
|
|
from pipeline.modeles import Confiance, Evenement, Source, Statut, Tier, TypeEtape, TypeTexte
|
|
from pipeline.parseurs.dates_fr import lire_date
|
|
|
|
log = logger("collecteur.legifrance")
|
|
|
|
URL_JETON = {
|
|
"prod": "https://oauth.piste.gouv.fr/api/oauth/token",
|
|
"sandbox": "https://sandbox-oauth.piste.gouv.fr/api/oauth/token",
|
|
}
|
|
URL_API = {
|
|
"prod": "https://api.piste.gouv.fr/dila/legifrance/lf-engine-app",
|
|
"sandbox": "https://sandbox-api.piste.gouv.fr/dila/legifrance/lf-engine-app",
|
|
}
|
|
|
|
# Le lien public d'un texte se déduit de son identifiant JORF.
|
|
GABARIT_PUBLIC = "https://www.legifrance.gouv.fr/jorf/id/{identifiant}"
|
|
|
|
|
|
class CollecteurLegifrance(Collecteur):
|
|
"""Interroge le fonds LODA (lois, ordonnances, décrets, arrêtés)."""
|
|
|
|
nom = "legifrance"
|
|
|
|
def __init__(
|
|
self,
|
|
client: ClientHttp,
|
|
*,
|
|
numeros_a_verifier: list[str] | None = None,
|
|
annee: int = 2026,
|
|
) -> None:
|
|
self.client = client
|
|
self.numeros_a_verifier = numeros_a_verifier or []
|
|
self.annee = annee
|
|
self._jeton: str | None = None
|
|
self._jeton_expire_a: float = 0.0
|
|
|
|
# ── Disponibilité ────────────────────────────────────────────────────────
|
|
def est_disponible(self) -> tuple[bool, str | None]:
|
|
if not os.environ.get("LEGIFRANCE_CLIENT_ID"):
|
|
return False, (
|
|
"LEGIFRANCE_CLIENT_ID absent de .env — repli sur l'Assemblée "
|
|
"nationale, le Sénat et le Conseil constitutionnel"
|
|
)
|
|
if not os.environ.get("LEGIFRANCE_CLIENT_SECRET"):
|
|
return False, "LEGIFRANCE_CLIENT_SECRET absent de .env"
|
|
return True, None
|
|
|
|
# ── Authentification ─────────────────────────────────────────────────────
|
|
@property
|
|
def environnement(self) -> str:
|
|
valeur = os.environ.get("LEGIFRANCE_ENV", "prod").lower()
|
|
return valeur if valeur in URL_JETON else "prod"
|
|
|
|
def _obtenir_jeton(self) -> str:
|
|
"""Jeton OAuth, renouvelé une minute avant son expiration."""
|
|
if self._jeton and time.monotonic() < self._jeton_expire_a:
|
|
return self._jeton
|
|
|
|
reponse = self.client.post(
|
|
URL_JETON[self.environnement],
|
|
donnees={
|
|
"grant_type": "client_credentials",
|
|
"client_id": os.environ["LEGIFRANCE_CLIENT_ID"],
|
|
"client_secret": os.environ["LEGIFRANCE_CLIENT_SECRET"],
|
|
"scope": "openid",
|
|
},
|
|
utiliser_cache=False,
|
|
)
|
|
if not reponse.a_reussi:
|
|
raise RuntimeError(
|
|
f"authentification PISTE refusée (HTTP {reponse.code}) : "
|
|
f"{reponse.texte[:200]} — vérifier qu'il s'agit bien du couple "
|
|
"OAuth et non de la clé d'API"
|
|
)
|
|
|
|
charge = reponse.json()
|
|
self._jeton = charge["access_token"]
|
|
self._jeton_expire_a = time.monotonic() + max(int(charge.get("expires_in", 3600)) - 60, 60)
|
|
log.debug("jeton PISTE obtenu", scope=charge.get("scope"))
|
|
return self._jeton
|
|
|
|
def _entetes(self) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {self._obtenir_jeton()}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
# ── Collecte ─────────────────────────────────────────────────────────────
|
|
def _collecter(self) -> ResultatCollecte:
|
|
resultat = ResultatCollecte(collecteur=self.nom)
|
|
|
|
for numero in self.numeros_a_verifier:
|
|
try:
|
|
if texte := self.chercher_par_numero(numero):
|
|
resultat.textes.append(texte)
|
|
except Exception as erreur: # noqa: BLE001
|
|
resultat.erreurs.append(f"{numero} : {erreur}")
|
|
|
|
return resultat
|
|
|
|
def chercher_par_numero(self, numero: str) -> TexteCollecte | None:
|
|
"""Retrouve une loi par son numéro officiel (« 2026-491 »)."""
|
|
charge = {
|
|
"recherche": {
|
|
"champs": [
|
|
{
|
|
"typeChamp": "NUM",
|
|
"criteres": [
|
|
{"typeRecherche": "EXACTE", "valeur": numero, "operateur": "ET"}
|
|
],
|
|
"operateur": "ET",
|
|
}
|
|
],
|
|
"filtres": [],
|
|
"pageNumber": 1,
|
|
"pageSize": 5,
|
|
"operateur": "ET",
|
|
"sort": "PERTINENCE",
|
|
"typePagination": "DEFAUT",
|
|
},
|
|
"fond": "LODA_DATE",
|
|
}
|
|
|
|
reponse = self.client.post(
|
|
f"{URL_API[self.environnement]}/search",
|
|
json_corps=charge,
|
|
entetes=self._entetes(),
|
|
)
|
|
if not reponse.a_reussi:
|
|
raise RuntimeError(f"recherche Légifrance HTTP {reponse.code}: {reponse.texte[:160]}")
|
|
|
|
donnees = reponse.json()
|
|
for element in donnees.get("results") or []:
|
|
for titre in element.get("titles") or []:
|
|
if texte := _lire_titre(titre, numero):
|
|
return texte
|
|
|
|
return None
|
|
|
|
|
|
def _lire_titre(titre: dict, numero_attendu: str) -> TexteCollecte | None:
|
|
"""Convertit une entrée de résultat Légifrance en texte collecté."""
|
|
intitule = (titre.get("title") or "").strip()
|
|
if not intitule or numero_attendu not in intitule:
|
|
return None
|
|
|
|
identifiant = titre.get("cid") or titre.get("id") or ""
|
|
url = GABARIT_PUBLIC.format(identifiant=identifiant) if identifiant else None
|
|
if url is None:
|
|
return None
|
|
|
|
signature = _date_du_titre(titre, intitule)
|
|
organique = "LOI organique" in intitule or "LOI ORGANIQUE" in intitule.upper()
|
|
|
|
evenements = []
|
|
if signature:
|
|
evenements.append(
|
|
Evenement(
|
|
date_evenement=signature,
|
|
type_etape=TypeEtape.PROMULGATION,
|
|
description=f"Promulgation constatée sur Légifrance : {intitule[:200]}",
|
|
source_url=url,
|
|
)
|
|
)
|
|
|
|
return TexteCollecte(
|
|
titre=intitule,
|
|
source_url=url,
|
|
collecteur="legifrance",
|
|
numero_officiel=numero_attendu,
|
|
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
|
|
statut=Statut.PROMULGUEE,
|
|
date_promulgation=signature,
|
|
identifiant_externe=identifiant,
|
|
evenements=evenements,
|
|
sources=[
|
|
Source(
|
|
url=url,
|
|
titre=intitule[:280],
|
|
editeur="Légifrance",
|
|
date_publication=signature,
|
|
tier=Tier.T1,
|
|
confiance=Confiance.HIGH,
|
|
marqueur=f"legifrance-{identifiant}",
|
|
fichier_origine="collecteur:legifrance",
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def _date_du_titre(titre: dict, intitule: str) -> date | None:
|
|
"""Date de signature : champ dédié quand il existe, sinon lue dans l'intitulé.
|
|
|
|
Les résultats de recherche laissent souvent `dateSignature` à `None` ;
|
|
l'intitulé officiel, lui, porte toujours la date (« LOI n° 2026-491 du
|
|
12 juin 2026 … »).
|
|
"""
|
|
for champ in ("dateSignature", "dateDebut", "datePublication"):
|
|
if valeur := titre.get(champ):
|
|
if lue := lire_date(str(valeur)):
|
|
return lue
|
|
return lire_date(intitule)
|