Files
veye-lalwa/pipeline/collecteurs/conseil_constitutionnel.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

230 lines
8.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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