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
|