Files
veye-lalwa/pipeline/collecteurs/senat.py
T
Cyber MawonajandClaude Opus 5 feb5544599 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>
2026-07-25 21:48:56 -04:00

131 lines
4.6 KiB
Python

"""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