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,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 ''}n° {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
|
||||
Reference in New Issue
Block a user