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>
230 lines
8.5 KiB
Python
230 lines
8.5 KiB
Python
"""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
|