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:
Cyber Mawonaj
2026-07-25 21:48:56 -04:00
co-authored by Claude Opus 5
parent c9fc8f3f67
commit feb5544599
15 changed files with 2743 additions and 1 deletions
+228
View File
@@ -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)