Phase 5 : collecteurs officiels et run de veille — dry-run contre sources réelles
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9fc8f3f67
commit
feb5544599
@@ -0,0 +1,228 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user