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
+14
View File
@@ -0,0 +1,14 @@
"""Collecteurs des sources officielles.
Chaque collecteur est isolé : il déclare ce qu'il sait faire, tente sa collecte,
et rend un `ResultatCollecte` — jamais une exception. Un collecteur en panne ne
doit pas empêcher les autres de tourner ni faire échouer le run.
Tous passent par le client HTTP commun (`http.py`), qui impose un user-agent
honnête avec adresse de contact, un délai minimal entre deux requêtes vers un
même hôte, et un cache disque. Aucun collecteur n'ouvre de connexion en direct.
"""
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
__all__ = ["Collecteur", "ResultatCollecte", "TexteCollecte"]
+108
View File
@@ -0,0 +1,108 @@
"""Contrat commun à tous les collecteurs."""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date
from pipeline.journal import logger
from pipeline.modeles import DecisionCC, Evenement, Source, Statut, TypeTexte
log = logger("collecteur")
@dataclass(slots=True)
class TexteCollecte:
"""Un texte observé sur une source officielle.
Volontairement plus pauvre qu'un `Texte` : un collecteur rapporte ce qu'il
voit, il ne classe pas. La classification et la cotation Guadeloupe sont
faites ensuite, et signalées comme automatiques.
"""
titre: str
source_url: str
collecteur: str
numero_officiel: str | None = None
type: TypeTexte | None = None
statut: Statut | None = None
date_promulgation: date | None = None
date_adoption: date | None = None
date_publication_jo: date | None = None
identifiant_externe: str | None = None # cid Légifrance, dossier AN…
resume: str | None = None
evenements: list[Evenement] = field(default_factory=list)
decisions_cc: list[DecisionCC] = field(default_factory=list)
sources: list[Source] = field(default_factory=list)
@dataclass(slots=True)
class ResultatCollecte:
"""Ce qu'un collecteur rapporte d'un passage."""
collecteur: str
textes: list[TexteCollecte] = field(default_factory=list)
decisions_cc: list[DecisionCC] = field(default_factory=list)
erreurs: list[str] = field(default_factory=list)
duree_s: float = 0.0
ignore: bool = False
motif_ignore: str | None = None
@property
def a_reussi(self) -> bool:
return not self.erreurs and not self.ignore
class Collecteur(ABC):
"""Un collecteur de source officielle.
Les implémentations ne lèvent jamais : `collecter()` capture ses propres
erreurs et les range dans le résultat. C'est ce qui permet au run complet de
se poursuivre quand une source est indisponible.
"""
nom: str = "inconnu"
def est_disponible(self) -> tuple[bool, str | None]:
"""Indique si le collecteur peut travailler, et sinon pourquoi.
Sert à documenter les dégradations : un collecteur privé de clé d'API
doit le dire clairement plutôt que d'échouer silencieusement.
"""
return True, None
@abstractmethod
def _collecter(self) -> ResultatCollecte:
"""Collecte effective, susceptible de lever."""
def collecter(self) -> ResultatCollecte:
depart = time.monotonic()
disponible, motif = self.est_disponible()
if not disponible:
log.info("collecteur ignoré", collecteur=self.nom, motif=motif)
return ResultatCollecte(
collecteur=self.nom, ignore=True, motif_ignore=motif,
duree_s=time.monotonic() - depart,
)
try:
resultat = self._collecter()
except Exception as erreur: # noqa: BLE001 — un collecteur ne fait pas tomber le run
log.warning("collecteur en échec", collecteur=self.nom, erreur=str(erreur))
resultat = ResultatCollecte(collecteur=self.nom, erreurs=[str(erreur)])
resultat.duree_s = time.monotonic() - depart
log.info(
"collecte terminée",
collecteur=self.nom,
textes=len(resultat.textes),
decisions=len(resultat.decisions_cc),
erreurs=len(resultat.erreurs),
duree_s=round(resultat.duree_s, 2),
)
return resultat
@@ -0,0 +1,229 @@
"""Collecteur du Conseil constitutionnel.
Deux pages, deux usages :
- **Affaires en instance**, filtrées sur les saisines DC par le paramètre
`?id=32246` — sans lui, la page ne rend que des questions prioritaires de
constitutionnalité et aucune affaire DC. C'est là que se lit le sort des
textes adoptés mais non promulgués.
- **Décisions rendues**, pour détecter le passage d'une affaire en instance à
une décision, et donc le déblocage d'une promulgation.
Vérifié le 25 juillet 2026 : la page des affaires en instance rendait les sept
affaires DC attendues par le corpus, et une de plus que lui — l'affaire
n° 2026-907 DC sur la programmation militaire, que le rapport mentionnait sans
numéro. C'est précisément ce qu'un pipeline de veille doit rattraper.
"""
from __future__ import annotations
import re
from datetime import date
from selectolax.parser import HTMLParser, Node
from pipeline.collecteurs.base import Collecteur, ResultatCollecte
from pipeline.collecteurs.http import ClientHttp
from pipeline.journal import logger
from pipeline.modeles import DecisionCC, ResultatCC
from pipeline.parseurs.dates_fr import lire_date, sans_accents
log = logger("collecteur.conseil_constitutionnel")
BASE = "https://www.conseil-constitutionnel.fr"
URL_AFFAIRES_DC = f"{BASE}/decisions/affaires-en-instances?id=32246"
URL_DECISIONS = f"{BASE}/decisions"
_AFFAIRE = re.compile(r"\b(\d{4}-\d{1,4})\s*DC\b")
_DECISION_TITRE = re.compile(
r"D[ée]cision\s+n°\s*(\d{4}-\d{1,4})\s*DC\s+du\s+([^-–—]+?)\s{2,}(.*)", re.IGNORECASE
)
# Formulations employées par le Conseil dans ses intitulés de décision, testées
# du plus spécifique au plus général. L'ordre compte : « non conformité
# partielle » contient « conformité », et « conformité » ne contient pas
# « conforme » — c'est bien « Conformité » et « Conformité - réserve » que la
# page publie, pas « conforme ».
_RESULTATS = (
("non conformite partielle", ResultatCC.NON_CONFORMITE_PARTIELLE),
("non conformite totale", ResultatCC.NON_CONFORMITE_TOTALE),
("non conformite", ResultatCC.NON_CONFORMITE_TOTALE),
("non conforme", ResultatCC.NON_CONFORMITE_TOTALE),
("conformite - reserve", ResultatCC.CONFORME_AVEC_RESERVES),
("conformite reserve", ResultatCC.CONFORME_AVEC_RESERVES),
("conformite avec reserve", ResultatCC.CONFORME_AVEC_RESERVES),
("conforme avec reserve", ResultatCC.CONFORME_AVEC_RESERVES),
("conformite", ResultatCC.CONFORME),
("conforme", ResultatCC.CONFORME),
)
# Le tableau des affaires DC n'a pas les mêmes colonnes que celui des QPC :
# les positions ne sont donc pas fiables, seuls les en-têtes le sont.
_COLONNES = {
"affaire": ("affaire",),
"disposition": ("disposition",),
"saisissants": ("auteur de la saisine", "auteur"),
"date_saisine": ("date de la saisine",),
"date_decision": ("date de lecture de la decision", "decision rendue le"),
}
class CollecteurConseilConstitutionnel(Collecteur):
nom = "conseil_constitutionnel"
def __init__(self, client: ClientHttp) -> None:
self.client = client
def _collecter(self) -> ResultatCollecte:
resultat = ResultatCollecte(collecteur=self.nom)
try:
resultat.decisions_cc.extend(self.affaires_en_instance())
except Exception as erreur: # noqa: BLE001
resultat.erreurs.append(f"affaires en instance : {erreur}")
try:
rendues = self.decisions_rendues()
except Exception as erreur: # noqa: BLE001
resultat.erreurs.append(f"décisions rendues : {erreur}")
rendues = []
# Une décision rendue prime sur l'inscription au rôle : si une affaire
# figure dans les deux listes, c'est qu'elle vient d'être tranchée.
par_numero = {d.numero_affaire: d for d in resultat.decisions_cc}
for decision in rendues:
par_numero[decision.numero_affaire] = decision
resultat.decisions_cc = list(par_numero.values())
return resultat
# ── Affaires en instance ─────────────────────────────────────────────────
def affaires_en_instance(self) -> list[DecisionCC]:
reponse = self.client.get(URL_AFFAIRES_DC)
if not reponse.a_reussi:
raise RuntimeError(f"HTTP {reponse.code}")
document = HTMLParser(reponse.texte)
tableau = document.css_first("table")
if tableau is None:
raise RuntimeError("tableau des affaires introuvable")
entetes = _index_des_colonnes(tableau)
affaires: list[DecisionCC] = []
for ligne in tableau.css("tbody tr"):
if affaire := _lire_ligne_instance(ligne, entetes):
affaires.append(affaire)
log.debug("affaires DC en instance", nombre=len(affaires))
return affaires
# ── Décisions rendues ────────────────────────────────────────────────────
def decisions_rendues(self) -> list[DecisionCC]:
reponse = self.client.get(URL_DECISIONS)
if not reponse.a_reussi:
raise RuntimeError(f"HTTP {reponse.code}")
document = HTMLParser(reponse.texte)
decisions: list[DecisionCC] = []
for article in document.css("article"):
texte = " ".join(article.text(separator=" ", strip=True).split())
if " DC " not in texte and " DC " not in texte:
continue
if decision := _lire_decision_rendue(texte, _lien(article)):
decisions.append(decision)
log.debug("décisions DC rendues", nombre=len(decisions))
return decisions
def _index_des_colonnes(tableau: Node) -> dict[str, int]:
"""Associe chaque rôle de colonne à sa position, d'après les en-têtes."""
entetes = [
sans_accents(th.text(separator=" ", strip=True)).lower()
for th in tableau.css("th")
]
index: dict[str, int] = {}
for role, variantes in _COLONNES.items():
for position, entete in enumerate(entetes):
if any(entete.startswith(variante) for variante in variantes):
index[role] = position
break
return index
def _lire_ligne_instance(ligne: Node, colonnes: dict[str, int]) -> DecisionCC | None:
cellules = [" ".join(c.text(separator=" ", strip=True).split()) for c in ligne.css("td")]
if not cellules:
return None
def cellule(role: str) -> str:
position = colonnes.get(role)
return cellules[position] if position is not None and position < len(cellules) else ""
correspondance = _AFFAIRE.search(cellule("affaire") or cellules[0])
if not correspondance:
return None
date_decision = lire_date(cellule("date_decision"))
saisissants = cellule("saisissants")
intitule = cellule("disposition")
return DecisionCC(
numero_affaire=f"{correspondance.group(1)} DC",
date_saisine=lire_date(cellule("date_saisine")),
date_decision=date_decision,
resultat=ResultatCC.EN_INSTANCE if date_decision is None else None,
saisissants=saisissants[:300] or None,
resume=intitule[:500] or None,
url=URL_AFFAIRES_DC,
)
def _lire_decision_rendue(texte: str, lien: str | None) -> DecisionCC | None:
correspondance = _AFFAIRE.search(texte)
if not correspondance:
return None
numero = correspondance.group(1)
apres = texte[correspondance.end() :]
date_decision = _date_apres_du(apres)
resultat = _resultat_du_texte(apres)
return DecisionCC(
numero_affaire=f"{numero} DC",
date_decision=date_decision,
resultat=resultat,
resume=" ".join(apres.split())[:500] or None,
url=lien,
)
_APRES_DU = re.compile(r"\bdu\s+(\d{1,2}(?:er)?\s+\w+\s+\d{4})", re.IGNORECASE)
def _date_apres_du(fragment: str) -> date | None:
if trouve := _APRES_DU.search(fragment):
return lire_date(trouve.group(1))
return lire_date(fragment)
def _resultat_du_texte(fragment: str) -> ResultatCC | None:
plat = sans_accents(fragment).lower()
for forme, resultat in _RESULTATS:
if forme in plat:
return resultat
return None
def _lien(article: Node) -> str | None:
ancre = article.css_first("a")
if ancre is None:
return None
href = ancre.attributes.get("href") or ""
if href.startswith("http"):
return href
if href.startswith("/"):
return BASE + href
return None
+218
View File
@@ -0,0 +1,218 @@
"""Client HTTP partagé par les collecteurs.
Trois obligations, non négociables vis-à-vis de services publics gratuits :
1. **Se présenter.** Le user-agent porte le nom du projet et une adresse de
contact, configurables dans `.env`. Un scraper anonyme est un scraper qu'on
bloque, à raison.
2. **Ne pas marteler.** Un délai minimal est respecté entre deux requêtes vers
un même hôte, et un cache disque évite de redemander ce qu'on a déjà.
3. **Ne pas insister bêtement.** Trois tentatives au maximum, avec attente
croissante, et seulement sur les erreurs qui peuvent se résoudre d'elles-mêmes
(temporisations, 5xx, 429).
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import httpx
from pipeline import chemins
from pipeline.journal import logger
log = logger("http")
CODES_A_REESSAYER = frozenset({408, 425, 429, 500, 502, 503, 504})
@dataclass(slots=True)
class Reponse:
"""Réponse HTTP, éventuellement servie par le cache."""
url: str
code: int
texte: str
depuis_le_cache: bool = False
@property
def a_reussi(self) -> bool:
return 200 <= self.code < 300
def json(self) -> Any:
return json.loads(self.texte)
class ClientHttp:
"""Client à cache disque et débit maîtrisé."""
def __init__(
self,
*,
cache: Path | None = None,
duree_cache_h: float | None = None,
delai_minimal_s: float | None = None,
user_agent: str | None = None,
hors_ligne: bool = False,
) -> None:
self.cache = cache or chemins.CACHE_HTTP
self.duree_cache = timedelta(
hours=duree_cache_h
if duree_cache_h is not None
else float(os.environ.get("VEILLE_CACHE_TTL_H", "6"))
)
self.delai_minimal = (
delai_minimal_s
if delai_minimal_s is not None
else float(os.environ.get("VEILLE_DELAI_REQUETES", "1.0"))
)
self.user_agent = user_agent or os.environ.get(
"VEILLE_USER_AGENT", "veille-legislative-971/1.0"
)
# Mode hors ligne : seul le cache répond. Indispensable pour que les
# tests ne dépendent jamais du réseau.
self.hors_ligne = hors_ligne
self._dernier_appel: dict[str, float] = {}
self._client = httpx.Client(
timeout=httpx.Timeout(30.0, connect=15.0),
follow_redirects=True,
headers={"User-Agent": self.user_agent, "Accept-Language": "fr-FR,fr;q=0.9"},
)
# ── Cycle de vie ─────────────────────────────────────────────────────────
def fermer(self) -> None:
self._client.close()
def __enter__(self) -> ClientHttp:
return self
def __exit__(self, *_) -> None:
self.fermer()
# ── Requêtes ─────────────────────────────────────────────────────────────
def get(self, url: str, **kwargs) -> Reponse:
return self._requeter("GET", url, **kwargs)
def post(self, url: str, **kwargs) -> Reponse:
return self._requeter("POST", url, **kwargs)
def _requeter(
self,
methode: str,
url: str,
*,
json_corps: Any = None,
donnees: dict | None = None,
entetes: dict | None = None,
utiliser_cache: bool = True,
tentatives: int = 3,
) -> Reponse:
cle = _cle_de_cache(methode, url, json_corps, donnees)
if utiliser_cache and (en_cache := self._lire_cache(cle)):
return Reponse(url=url, code=200, texte=en_cache, depuis_le_cache=True)
if self.hors_ligne:
return Reponse(url=url, code=0, texte="", depuis_le_cache=False)
derniere_erreur: str | None = None
for tentative in range(1, tentatives + 1):
self._respecter_le_debit(url)
try:
reponse = self._client.request(
methode, url, json=json_corps, data=donnees, headers=entetes
)
except httpx.HTTPError as erreur:
derniere_erreur = f"{type(erreur).__name__}: {erreur}"
log.debug("requête en échec", url=url, tentative=tentative, erreur=derniere_erreur)
time.sleep(min(2**tentative, 8))
continue
if reponse.status_code in CODES_A_REESSAYER and tentative < tentatives:
attente = _attente_conseillee(reponse) or min(2**tentative, 8)
log.debug(
"réponse à réessayer",
url=url,
code=reponse.status_code,
attente_s=attente,
)
time.sleep(attente)
continue
if 200 <= reponse.status_code < 300 and utiliser_cache:
self._ecrire_cache(cle, reponse.text)
return Reponse(url=url, code=reponse.status_code, texte=reponse.text)
raise httpx.HTTPError(derniere_erreur or f"échec après {tentatives} tentatives : {url}")
# ── Débit ────────────────────────────────────────────────────────────────
def _respecter_le_debit(self, url: str) -> None:
hote = httpx.URL(url).host or ""
precedent = self._dernier_appel.get(hote)
if precedent is not None:
attente = self.delai_minimal - (time.monotonic() - precedent)
if attente > 0:
time.sleep(attente)
self._dernier_appel[hote] = time.monotonic()
# ── Cache ────────────────────────────────────────────────────────────────
def _chemin_cache(self, cle: str) -> Path:
return self.cache / cle[:2] / f"{cle}.txt"
def _lire_cache(self, cle: str) -> str | None:
fichier = self._chemin_cache(cle)
if not fichier.exists():
return None
age = datetime.now(UTC) - datetime.fromtimestamp(fichier.stat().st_mtime, UTC)
if age > self.duree_cache:
return None
return fichier.read_text(encoding="utf-8")
def _ecrire_cache(self, cle: str, contenu: str) -> None:
fichier = self._chemin_cache(cle)
fichier.parent.mkdir(parents=True, exist_ok=True)
fichier.write_text(contenu, encoding="utf-8")
def vider_le_cache(self) -> int:
"""Supprime les entrées périmées. Retourne le nombre de fichiers effacés."""
efface = 0
if not self.cache.exists():
return 0
limite = datetime.now(UTC) - self.duree_cache
for fichier in self.cache.rglob("*.txt"):
if datetime.fromtimestamp(fichier.stat().st_mtime, UTC) < limite:
fichier.unlink()
efface += 1
return efface
def _cle_de_cache(methode: str, url: str, json_corps: Any, donnees: dict | None) -> str:
empreinte = hashlib.sha256()
empreinte.update(methode.encode())
empreinte.update(url.encode())
if json_corps is not None:
empreinte.update(json.dumps(json_corps, sort_keys=True, ensure_ascii=False).encode())
if donnees:
empreinte.update(json.dumps(donnees, sort_keys=True, ensure_ascii=False).encode())
return empreinte.hexdigest()
def _attente_conseillee(reponse: httpx.Response) -> float | None:
"""Respecte l'en-tête `Retry-After` quand le serveur en envoie un."""
valeur = reponse.headers.get("Retry-After")
if not valeur:
return None
try:
return min(float(valeur), 30.0)
except ValueError:
return None
+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)
+130
View File
@@ -0,0 +1,130 @@
"""Collecteur du Sénat — liste chronologique des lois promulguées.
C'est le repli principal lorsque l'API Légifrance est indisponible, et un
contrôle croisé lorsqu'elle fonctionne. La page rend, pour chaque loi
promulguée de la session, son numéro, sa date, son intitulé complet et le lien
vers son dossier législatif.
L'adresse indiquée au §5.1 du cahier des charges — `senat.fr/lois/index.html` —
renvoie une erreur 404 depuis la refonte du site. L'équivalent fonctionnel est
`senat.fr/dossiers-legislatifs/lois-promulguees.html`, vérifié le 25 juillet
2026 : 51 lois y figuraient, dont les 27 de la fenêtre de veille.
"""
from __future__ import annotations
import re
from selectolax.parser import HTMLParser
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.senat")
BASE = "https://www.senat.fr"
URL_LOIS_PROMULGUEES = f"{BASE}/dossiers-legislatifs/lois-promulguees.html"
# « Loi n° 2026-650 du 23 juillet 2026 relative au renforcement… »
# « Loi organique n° 2026-410 du 28 mai 2026 … »
_INTITULE = re.compile(
r"Loi\s+(organique\s+)?n°\s*(\d{4}-\d{1,4})\s+du\s+"
r"(\d{1,2}(?:er)?\s+\w+\.?\s+\d{4})\s+(.*)",
re.IGNORECASE | re.DOTALL,
)
class CollecteurSenat(Collecteur):
nom = "senat"
def __init__(self, client: ClientHttp, *, annees: tuple[int, ...] = (2026,)) -> None:
self.client = client
self.annees = annees
def _collecter(self) -> ResultatCollecte:
resultat = ResultatCollecte(collecteur=self.nom)
reponse = self.client.get(URL_LOIS_PROMULGUEES)
if not reponse.a_reussi:
raise RuntimeError(f"HTTP {reponse.code} sur {URL_LOIS_PROMULGUEES}")
document = HTMLParser(reponse.texte)
vus: set[str] = set()
for ancre in document.css("a"):
libelle = " ".join(ancre.text(separator=" ", strip=True).split())
texte = self._lire_intitule(libelle, ancre.attributes.get("href"))
if texte is None or texte.numero_officiel in vus:
continue
vus.add(texte.numero_officiel)
resultat.textes.append(texte)
log.debug("lois promulguées relevées", nombre=len(resultat.textes))
return resultat
def _lire_intitule(self, libelle: str, href: str | None) -> TexteCollecte | None:
correspondance = _INTITULE.search(libelle)
if not correspondance:
return None
organique = bool(correspondance.group(1))
numero = correspondance.group(2)
if int(numero.split("-")[0]) not in self.annees:
return None
date_promulgation = lire_date(correspondance.group(3))
if date_promulgation is None:
return None
# Le libellé se termine par un état (« parue », « en cours »)
# qu'il ne faut pas confondre avec l'objet de la loi.
objet = re.split(r"\s+(?:parue|en cours)\b", correspondance.group(4))[0].strip(" .;")
titre = (
f"LOI {'organique ' if organique else ''}{numero} "
f"du {correspondance.group(3)} {objet}"
)
url = _absolu(href) or URL_LOIS_PROMULGUEES
return TexteCollecte(
titre=" ".join(titre.split()),
source_url=url,
collecteur=self.nom,
numero_officiel=numero,
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
statut=Statut.PROMULGUEE,
date_promulgation=date_promulgation,
evenements=[
Evenement(
date_evenement=date_promulgation,
type_etape=TypeEtape.PROMULGATION,
description=f"Loi promulguée n° {numero}, relevée sur la liste du Sénat",
source_url=url,
)
],
sources=[
Source(
url=url,
titre=" ".join(titre.split())[:280],
editeur="Sénat",
date_publication=date_promulgation,
tier=Tier.T1,
confiance=Confiance.HIGH,
marqueur=f"senat-{numero}",
fichier_origine="collecteur:senat",
)
],
)
def _absolu(href: str | None) -> str | None:
if not href:
return None
if href.startswith("http"):
return href
if href.startswith("/"):
return BASE + href
return None