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:
Cyber Mawonaj
2026-07-25 21:48:56 -04:00
co-authored by Claude Opus 5
parent c9fc8f3f67
commit feb5544599
15 changed files with 2743 additions and 1 deletions
+10
View File
@@ -5,12 +5,22 @@
# ── API Légifrance (PISTE) ─────────────────────────────────────────────────── # ── API Légifrance (PISTE) ───────────────────────────────────────────────────
# Compte : https://piste.gouv.fr — application abonnée à l'API « Légifrance ». # Compte : https://piste.gouv.fr — application abonnée à l'API « Légifrance ».
#
# ATTENTION : une application PISTE expose DEUX couples de valeurs, et un seul
# fonctionne ici. Il faut le couple OAuth (« Client ID » / « Client secret »),
# pas la clé d'API (« API key » / « API key secret »), qui donne un
# `invalid_client` au moment d'obtenir le jeton.
#
# Sans ces deux valeurs le pipeline fonctionne en mode dégradé documenté # Sans ces deux valeurs le pipeline fonctionne en mode dégradé documenté
# (repli sur le scraping léger AN / Sénat / Conseil constitutionnel). # (repli sur le scraping léger AN / Sénat / Conseil constitutionnel).
LEGIFRANCE_CLIENT_ID= LEGIFRANCE_CLIENT_ID=
LEGIFRANCE_CLIENT_SECRET= LEGIFRANCE_CLIENT_SECRET=
# `sandbox` pour l'environnement de test PISTE, `prod` pour la production. # `sandbox` pour l'environnement de test PISTE, `prod` pour la production.
# Vérifié le 25 juillet 2026 : les identifiants de production ne sont PAS
# acceptés par le bac à sable, qui exige une application distincte.
LEGIFRANCE_ENV=prod LEGIFRANCE_ENV=prod
# Clé d'API PISTE. Facultative : l'API Légifrance se contente du jeton OAuth.
LEGIFRANCE_API_KEY=
# ── Comportement du pipeline ───────────────────────────────────────────────── # ── Comportement du pipeline ─────────────────────────────────────────────────
# Chemin de la base SQLite (relatif à la racine du dépôt). # Chemin de la base SQLite (relatif à la racine du dépôt).
+14
View File
@@ -0,0 +1,14 @@
"""Collecteurs des sources officielles.
Chaque collecteur est isolé : il déclare ce qu'il sait faire, tente sa collecte,
et rend un `ResultatCollecte` — jamais une exception. Un collecteur en panne ne
doit pas empêcher les autres de tourner ni faire échouer le run.
Tous passent par le client HTTP commun (`http.py`), qui impose un user-agent
honnête avec adresse de contact, un délai minimal entre deux requêtes vers un
même hôte, et un cache disque. Aucun collecteur n'ouvre de connexion en direct.
"""
from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
__all__ = ["Collecteur", "ResultatCollecte", "TexteCollecte"]
+108
View File
@@ -0,0 +1,108 @@
"""Contrat commun à tous les collecteurs."""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date
from pipeline.journal import logger
from pipeline.modeles import DecisionCC, Evenement, Source, Statut, TypeTexte
log = logger("collecteur")
@dataclass(slots=True)
class TexteCollecte:
"""Un texte observé sur une source officielle.
Volontairement plus pauvre qu'un `Texte` : un collecteur rapporte ce qu'il
voit, il ne classe pas. La classification et la cotation Guadeloupe sont
faites ensuite, et signalées comme automatiques.
"""
titre: str
source_url: str
collecteur: str
numero_officiel: str | None = None
type: TypeTexte | None = None
statut: Statut | None = None
date_promulgation: date | None = None
date_adoption: date | None = None
date_publication_jo: date | None = None
identifiant_externe: str | None = None # cid Légifrance, dossier AN…
resume: str | None = None
evenements: list[Evenement] = field(default_factory=list)
decisions_cc: list[DecisionCC] = field(default_factory=list)
sources: list[Source] = field(default_factory=list)
@dataclass(slots=True)
class ResultatCollecte:
"""Ce qu'un collecteur rapporte d'un passage."""
collecteur: str
textes: list[TexteCollecte] = field(default_factory=list)
decisions_cc: list[DecisionCC] = field(default_factory=list)
erreurs: list[str] = field(default_factory=list)
duree_s: float = 0.0
ignore: bool = False
motif_ignore: str | None = None
@property
def a_reussi(self) -> bool:
return not self.erreurs and not self.ignore
class Collecteur(ABC):
"""Un collecteur de source officielle.
Les implémentations ne lèvent jamais : `collecter()` capture ses propres
erreurs et les range dans le résultat. C'est ce qui permet au run complet de
se poursuivre quand une source est indisponible.
"""
nom: str = "inconnu"
def est_disponible(self) -> tuple[bool, str | None]:
"""Indique si le collecteur peut travailler, et sinon pourquoi.
Sert à documenter les dégradations : un collecteur privé de clé d'API
doit le dire clairement plutôt que d'échouer silencieusement.
"""
return True, None
@abstractmethod
def _collecter(self) -> ResultatCollecte:
"""Collecte effective, susceptible de lever."""
def collecter(self) -> ResultatCollecte:
depart = time.monotonic()
disponible, motif = self.est_disponible()
if not disponible:
log.info("collecteur ignoré", collecteur=self.nom, motif=motif)
return ResultatCollecte(
collecteur=self.nom, ignore=True, motif_ignore=motif,
duree_s=time.monotonic() - depart,
)
try:
resultat = self._collecter()
except Exception as erreur: # noqa: BLE001 — un collecteur ne fait pas tomber le run
log.warning("collecteur en échec", collecteur=self.nom, erreur=str(erreur))
resultat = ResultatCollecte(collecteur=self.nom, erreurs=[str(erreur)])
resultat.duree_s = time.monotonic() - depart
log.info(
"collecte terminée",
collecteur=self.nom,
textes=len(resultat.textes),
decisions=len(resultat.decisions_cc),
erreurs=len(resultat.erreurs),
duree_s=round(resultat.duree_s, 2),
)
return resultat
@@ -0,0 +1,229 @@
"""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
+218
View File
@@ -0,0 +1,218 @@
"""Client HTTP partagé par les collecteurs.
Trois obligations, non négociables vis-à-vis de services publics gratuits :
1. **Se présenter.** Le user-agent porte le nom du projet et une adresse de
contact, configurables dans `.env`. Un scraper anonyme est un scraper qu'on
bloque, à raison.
2. **Ne pas marteler.** Un délai minimal est respecté entre deux requêtes vers
un même hôte, et un cache disque évite de redemander ce qu'on a déjà.
3. **Ne pas insister bêtement.** Trois tentatives au maximum, avec attente
croissante, et seulement sur les erreurs qui peuvent se résoudre d'elles-mêmes
(temporisations, 5xx, 429).
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import httpx
from pipeline import chemins
from pipeline.journal import logger
log = logger("http")
CODES_A_REESSAYER = frozenset({408, 425, 429, 500, 502, 503, 504})
@dataclass(slots=True)
class Reponse:
"""Réponse HTTP, éventuellement servie par le cache."""
url: str
code: int
texte: str
depuis_le_cache: bool = False
@property
def a_reussi(self) -> bool:
return 200 <= self.code < 300
def json(self) -> Any:
return json.loads(self.texte)
class ClientHttp:
"""Client à cache disque et débit maîtrisé."""
def __init__(
self,
*,
cache: Path | None = None,
duree_cache_h: float | None = None,
delai_minimal_s: float | None = None,
user_agent: str | None = None,
hors_ligne: bool = False,
) -> None:
self.cache = cache or chemins.CACHE_HTTP
self.duree_cache = timedelta(
hours=duree_cache_h
if duree_cache_h is not None
else float(os.environ.get("VEILLE_CACHE_TTL_H", "6"))
)
self.delai_minimal = (
delai_minimal_s
if delai_minimal_s is not None
else float(os.environ.get("VEILLE_DELAI_REQUETES", "1.0"))
)
self.user_agent = user_agent or os.environ.get(
"VEILLE_USER_AGENT", "veille-legislative-971/1.0"
)
# Mode hors ligne : seul le cache répond. Indispensable pour que les
# tests ne dépendent jamais du réseau.
self.hors_ligne = hors_ligne
self._dernier_appel: dict[str, float] = {}
self._client = httpx.Client(
timeout=httpx.Timeout(30.0, connect=15.0),
follow_redirects=True,
headers={"User-Agent": self.user_agent, "Accept-Language": "fr-FR,fr;q=0.9"},
)
# ── Cycle de vie ─────────────────────────────────────────────────────────
def fermer(self) -> None:
self._client.close()
def __enter__(self) -> ClientHttp:
return self
def __exit__(self, *_) -> None:
self.fermer()
# ── Requêtes ─────────────────────────────────────────────────────────────
def get(self, url: str, **kwargs) -> Reponse:
return self._requeter("GET", url, **kwargs)
def post(self, url: str, **kwargs) -> Reponse:
return self._requeter("POST", url, **kwargs)
def _requeter(
self,
methode: str,
url: str,
*,
json_corps: Any = None,
donnees: dict | None = None,
entetes: dict | None = None,
utiliser_cache: bool = True,
tentatives: int = 3,
) -> Reponse:
cle = _cle_de_cache(methode, url, json_corps, donnees)
if utiliser_cache and (en_cache := self._lire_cache(cle)):
return Reponse(url=url, code=200, texte=en_cache, depuis_le_cache=True)
if self.hors_ligne:
return Reponse(url=url, code=0, texte="", depuis_le_cache=False)
derniere_erreur: str | None = None
for tentative in range(1, tentatives + 1):
self._respecter_le_debit(url)
try:
reponse = self._client.request(
methode, url, json=json_corps, data=donnees, headers=entetes
)
except httpx.HTTPError as erreur:
derniere_erreur = f"{type(erreur).__name__}: {erreur}"
log.debug("requête en échec", url=url, tentative=tentative, erreur=derniere_erreur)
time.sleep(min(2**tentative, 8))
continue
if reponse.status_code in CODES_A_REESSAYER and tentative < tentatives:
attente = _attente_conseillee(reponse) or min(2**tentative, 8)
log.debug(
"réponse à réessayer",
url=url,
code=reponse.status_code,
attente_s=attente,
)
time.sleep(attente)
continue
if 200 <= reponse.status_code < 300 and utiliser_cache:
self._ecrire_cache(cle, reponse.text)
return Reponse(url=url, code=reponse.status_code, texte=reponse.text)
raise httpx.HTTPError(derniere_erreur or f"échec après {tentatives} tentatives : {url}")
# ── Débit ────────────────────────────────────────────────────────────────
def _respecter_le_debit(self, url: str) -> None:
hote = httpx.URL(url).host or ""
precedent = self._dernier_appel.get(hote)
if precedent is not None:
attente = self.delai_minimal - (time.monotonic() - precedent)
if attente > 0:
time.sleep(attente)
self._dernier_appel[hote] = time.monotonic()
# ── Cache ────────────────────────────────────────────────────────────────
def _chemin_cache(self, cle: str) -> Path:
return self.cache / cle[:2] / f"{cle}.txt"
def _lire_cache(self, cle: str) -> str | None:
fichier = self._chemin_cache(cle)
if not fichier.exists():
return None
age = datetime.now(UTC) - datetime.fromtimestamp(fichier.stat().st_mtime, UTC)
if age > self.duree_cache:
return None
return fichier.read_text(encoding="utf-8")
def _ecrire_cache(self, cle: str, contenu: str) -> None:
fichier = self._chemin_cache(cle)
fichier.parent.mkdir(parents=True, exist_ok=True)
fichier.write_text(contenu, encoding="utf-8")
def vider_le_cache(self) -> int:
"""Supprime les entrées périmées. Retourne le nombre de fichiers effacés."""
efface = 0
if not self.cache.exists():
return 0
limite = datetime.now(UTC) - self.duree_cache
for fichier in self.cache.rglob("*.txt"):
if datetime.fromtimestamp(fichier.stat().st_mtime, UTC) < limite:
fichier.unlink()
efface += 1
return efface
def _cle_de_cache(methode: str, url: str, json_corps: Any, donnees: dict | None) -> str:
empreinte = hashlib.sha256()
empreinte.update(methode.encode())
empreinte.update(url.encode())
if json_corps is not None:
empreinte.update(json.dumps(json_corps, sort_keys=True, ensure_ascii=False).encode())
if donnees:
empreinte.update(json.dumps(donnees, sort_keys=True, ensure_ascii=False).encode())
return empreinte.hexdigest()
def _attente_conseillee(reponse: httpx.Response) -> float | None:
"""Respecte l'en-tête `Retry-After` quand le serveur en envoie un."""
valeur = reponse.headers.get("Retry-After")
if not valeur:
return None
try:
return min(float(valeur), 30.0)
except ValueError:
return None
+228
View File
@@ -0,0 +1,228 @@
"""Collecteur Légifrance, via l'API PISTE de la DILA.
Authentification OAuth 2.0 en `client_credentials`. Deux pièges vérifiés le
25 juillet 2026 sur le compte réel :
- une application PISTE expose **deux** couples de valeurs, et seul le couple
OAuth (« Client ID » / « Client secret ») ouvre le jeton ; la clé d'API
(« API key » / « API key secret ») produit un `invalid_client` ;
- les identifiants de production ne sont **pas** acceptés par le bac à sable,
qui exige une application distincte.
Sans identifiants, le collecteur se déclare indisponible et le pipeline bascule
sur le repli documenté : promulgations de l'Assemblée nationale, liste des lois
du Sénat, décisions du Conseil constitutionnel.
"""
from __future__ import annotations
import os
import time
from datetime import date
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.legifrance")
URL_JETON = {
"prod": "https://oauth.piste.gouv.fr/api/oauth/token",
"sandbox": "https://sandbox-oauth.piste.gouv.fr/api/oauth/token",
}
URL_API = {
"prod": "https://api.piste.gouv.fr/dila/legifrance/lf-engine-app",
"sandbox": "https://sandbox-api.piste.gouv.fr/dila/legifrance/lf-engine-app",
}
# Le lien public d'un texte se déduit de son identifiant JORF.
GABARIT_PUBLIC = "https://www.legifrance.gouv.fr/jorf/id/{identifiant}"
class CollecteurLegifrance(Collecteur):
"""Interroge le fonds LODA (lois, ordonnances, décrets, arrêtés)."""
nom = "legifrance"
def __init__(
self,
client: ClientHttp,
*,
numeros_a_verifier: list[str] | None = None,
annee: int = 2026,
) -> None:
self.client = client
self.numeros_a_verifier = numeros_a_verifier or []
self.annee = annee
self._jeton: str | None = None
self._jeton_expire_a: float = 0.0
# ── Disponibilité ────────────────────────────────────────────────────────
def est_disponible(self) -> tuple[bool, str | None]:
if not os.environ.get("LEGIFRANCE_CLIENT_ID"):
return False, (
"LEGIFRANCE_CLIENT_ID absent de .env — repli sur l'Assemblée "
"nationale, le Sénat et le Conseil constitutionnel"
)
if not os.environ.get("LEGIFRANCE_CLIENT_SECRET"):
return False, "LEGIFRANCE_CLIENT_SECRET absent de .env"
return True, None
# ── Authentification ─────────────────────────────────────────────────────
@property
def environnement(self) -> str:
valeur = os.environ.get("LEGIFRANCE_ENV", "prod").lower()
return valeur if valeur in URL_JETON else "prod"
def _obtenir_jeton(self) -> str:
"""Jeton OAuth, renouvelé une minute avant son expiration."""
if self._jeton and time.monotonic() < self._jeton_expire_a:
return self._jeton
reponse = self.client.post(
URL_JETON[self.environnement],
donnees={
"grant_type": "client_credentials",
"client_id": os.environ["LEGIFRANCE_CLIENT_ID"],
"client_secret": os.environ["LEGIFRANCE_CLIENT_SECRET"],
"scope": "openid",
},
utiliser_cache=False,
)
if not reponse.a_reussi:
raise RuntimeError(
f"authentification PISTE refusée (HTTP {reponse.code}) : "
f"{reponse.texte[:200]} — vérifier qu'il s'agit bien du couple "
"OAuth et non de la clé d'API"
)
charge = reponse.json()
self._jeton = charge["access_token"]
self._jeton_expire_a = time.monotonic() + max(int(charge.get("expires_in", 3600)) - 60, 60)
log.debug("jeton PISTE obtenu", scope=charge.get("scope"))
return self._jeton
def _entetes(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._obtenir_jeton()}",
"Content-Type": "application/json",
"Accept": "application/json",
}
# ── Collecte ─────────────────────────────────────────────────────────────
def _collecter(self) -> ResultatCollecte:
resultat = ResultatCollecte(collecteur=self.nom)
for numero in self.numeros_a_verifier:
try:
if texte := self.chercher_par_numero(numero):
resultat.textes.append(texte)
except Exception as erreur: # noqa: BLE001
resultat.erreurs.append(f"{numero} : {erreur}")
return resultat
def chercher_par_numero(self, numero: str) -> TexteCollecte | None:
"""Retrouve une loi par son numéro officiel (« 2026-491 »)."""
charge = {
"recherche": {
"champs": [
{
"typeChamp": "NUM",
"criteres": [
{"typeRecherche": "EXACTE", "valeur": numero, "operateur": "ET"}
],
"operateur": "ET",
}
],
"filtres": [],
"pageNumber": 1,
"pageSize": 5,
"operateur": "ET",
"sort": "PERTINENCE",
"typePagination": "DEFAUT",
},
"fond": "LODA_DATE",
}
reponse = self.client.post(
f"{URL_API[self.environnement]}/search",
json_corps=charge,
entetes=self._entetes(),
)
if not reponse.a_reussi:
raise RuntimeError(f"recherche Légifrance HTTP {reponse.code}: {reponse.texte[:160]}")
donnees = reponse.json()
for element in donnees.get("results") or []:
for titre in element.get("titles") or []:
if texte := _lire_titre(titre, numero):
return texte
return None
def _lire_titre(titre: dict, numero_attendu: str) -> TexteCollecte | None:
"""Convertit une entrée de résultat Légifrance en texte collecté."""
intitule = (titre.get("title") or "").strip()
if not intitule or numero_attendu not in intitule:
return None
identifiant = titre.get("cid") or titre.get("id") or ""
url = GABARIT_PUBLIC.format(identifiant=identifiant) if identifiant else None
if url is None:
return None
signature = _date_du_titre(titre, intitule)
organique = "LOI organique" in intitule or "LOI ORGANIQUE" in intitule.upper()
evenements = []
if signature:
evenements.append(
Evenement(
date_evenement=signature,
type_etape=TypeEtape.PROMULGATION,
description=f"Promulgation constatée sur Légifrance : {intitule[:200]}",
source_url=url,
)
)
return TexteCollecte(
titre=intitule,
source_url=url,
collecteur="legifrance",
numero_officiel=numero_attendu,
type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
statut=Statut.PROMULGUEE,
date_promulgation=signature,
identifiant_externe=identifiant,
evenements=evenements,
sources=[
Source(
url=url,
titre=intitule[:280],
editeur="Légifrance",
date_publication=signature,
tier=Tier.T1,
confiance=Confiance.HIGH,
marqueur=f"legifrance-{identifiant}",
fichier_origine="collecteur:legifrance",
)
],
)
def _date_du_titre(titre: dict, intitule: str) -> date | None:
"""Date de signature : champ dédié quand il existe, sinon lue dans l'intitulé.
Les résultats de recherche laissent souvent `dateSignature` à `None` ;
l'intitulé officiel, lui, porte toujours la date (« LOI n° 2026-491 du
12 juin 2026 … »).
"""
for champ in ("dateSignature", "dateDebut", "datePublication"):
if valeur := titre.get(champ):
if lue := lire_date(str(valeur)):
return lue
return lire_date(intitule)
+130
View File
@@ -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 ''}{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
+155
View File
@@ -0,0 +1,155 @@
"""Rapprochement d'un texte collecté avec les textes déjà en base.
Deux voies, dans cet ordre :
1. **Le numéro officiel.** Deux textes portant le même numéro de loi sont le
même texte, sans discussion. C'est le cas facile, et le plus fréquent une
fois la promulgation intervenue.
2. **La similarité de titre**, pour les textes non encore promulgués, qui n'ont
pas de numéro. Le rapport les désigne par un nom d'usage (« loi Riposte »)
là où les sources officielles emploient l'intitulé complet (« Loi visant à
offrir des réponses immédiates aux phénomènes troublant l'ordre public… »).
La comparaison brute échouerait ; on compare donc des titres normalisés, et
on tient compte des mots significatifs communs.
Trois issues possibles, et c'est important : rapproché, **à signaler** (au-delà
du seuil bas mais en-deçà du seuil haut — un humain tranche), ou nouveau. Le
pipeline ne fusionne jamais sur une correspondance douteuse.
"""
from __future__ import annotations
import functools
import re
from dataclasses import dataclass
from difflib import SequenceMatcher
from enum import StrEnum
import yaml
from pipeline import chemins
from pipeline.parseurs.dates_fr import sans_accents
# Mots vides et vocabulaire juridique passe-partout : deux lois quelconques les
# partagent, ils ne prouvent donc aucune parenté.
_MOTS_IGNORES = frozenset(
"""loi organique projet proposition relative relatif visant portant diverses
dispositions de des du la le les l a au aux et en pour par sur dans un une
ainsi que qui plus mesures texte article n no ndeg""".split()
)
_NON_ALPHANUM = re.compile(r"[^a-z0-9]+")
class Issue(StrEnum):
RAPPROCHE = "rapproche"
A_SIGNALER = "a_signaler"
NOUVEAU = "nouveau"
@dataclass(slots=True)
class Correspondance:
"""Résultat d'un rapprochement."""
issue: Issue
texte_id: str | None = None
score: float = 0.0
motif: str = ""
@property
def est_certaine(self) -> bool:
return self.issue is Issue.RAPPROCHE
@functools.lru_cache(maxsize=1)
def _seuils() -> tuple[float, float]:
configuration = yaml.safe_load(chemins.CONFIG.read_text(encoding="utf-8"))
rapprochement = configuration.get("rapprochement", {})
return (
float(rapprochement.get("seuil_similarite", 0.86)),
float(rapprochement.get("seuil_signalement", 0.72)),
)
def normaliser(titre: str) -> str:
"""Réduit un titre à ses mots significatifs, sans accent ni ponctuation."""
plat = sans_accents(titre).lower()
mots = [m for m in _NON_ALPHANUM.split(plat) if m and m not in _MOTS_IGNORES]
return " ".join(mots)
def similarite(gauche: str, droite: str) -> float:
"""Similarité de deux titres, entre 0 et 1.
Combine deux mesures : le recouvrement des mots significatifs, qui résiste
aux réordonnancements, et la similarité de séquence, qui capte les
variantes orthographiques. La plus favorable des deux l'emporte — un titre
court mais entièrement contenu dans un titre long doit être reconnu.
"""
a, b = normaliser(gauche), normaliser(droite)
if not a or not b:
return 0.0
if a == b:
return 1.0
mots_a, mots_b = set(a.split()), set(b.split())
communs = mots_a & mots_b
recouvrement = len(communs) / min(len(mots_a), len(mots_b))
sequence = SequenceMatcher(None, a, b).ratio()
return max(recouvrement, sequence)
def rapprocher(
*,
titre: str,
numero_officiel: str | None,
candidats: list[tuple[str, str, str | None]],
) -> Correspondance:
"""Rapproche un texte collecté des textes en base.
`candidats` est une liste de `(identifiant, titre, numero_officiel)`.
"""
seuil_haut, seuil_bas = _seuils()
if numero_officiel:
for identifiant, _, numero in candidats:
if numero and numero == numero_officiel:
return Correspondance(
issue=Issue.RAPPROCHE,
texte_id=identifiant,
score=1.0,
motif=f"numéro officiel identique ({numero_officiel})",
)
meilleur: tuple[float, str] | None = None
for identifiant, titre_candidat, _ in candidats:
score = similarite(titre, titre_candidat)
if meilleur is None or score > meilleur[0]:
meilleur = (score, identifiant)
if meilleur is None:
return Correspondance(issue=Issue.NOUVEAU, motif="aucun candidat en base")
score, identifiant = meilleur
if score >= seuil_haut:
return Correspondance(
issue=Issue.RAPPROCHE,
texte_id=identifiant,
score=score,
motif=f"similarité de titre {score:.2f}{seuil_haut}",
)
if score >= seuil_bas:
return Correspondance(
issue=Issue.A_SIGNALER,
texte_id=identifiant,
score=score,
motif=(
f"similarité de titre {score:.2f}, entre le seuil de signalement "
f"({seuil_bas}) et celui de rapprochement ({seuil_haut}) — "
"vérification humaine requise"
),
)
return Correspondance(
issue=Issue.NOUVEAU, score=score, motif=f"meilleure similarité {score:.2f}, sous les seuils"
)
+497
View File
@@ -0,0 +1,497 @@
"""Run de veille : collecte, rapprochement, mise à jour, rapport.
Enchaînement, conforme au §5 du cahier des charges :
1. **Collecte** — chaque collecteur isolé, tolérant à l'échec, avec cache HTTP
et user-agent honnête. Un collecteur en panne n'arrête pas le run.
2. **Rapprochement** — par numéro officiel puis par similarité de titre, avec
une zone de signalement où le pipeline s'abstient plutôt que de fusionner.
3. **Classification** — thèmes et pertinence Guadeloupe pour les textes
nouveaux uniquement ; les entrées issues du corpus gardent leurs valeurs.
Toute classification automatique est marquée `confiance = 'low'` et
« à vérifier ».
4. **Rapport de run** — écrit dans `veille_log` et `veille_changements`.
5. **Sortie statique** — `data/textes.json` régénéré pour le mode dégradé.
`--dry-run` effectue la collecte réelle et calcule tous les écarts, sans écrire
une seule ligne en base.
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import time
from dataclasses import dataclass, field
from datetime import date
from pipeline import chemins, db, guadeloupe
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
from pipeline.collecteurs.conseil_constitutionnel import CollecteurConseilConstitutionnel
from pipeline.collecteurs.http import ClientHttp
from pipeline.collecteurs.legifrance import CollecteurLegifrance
from pipeline.collecteurs.senat import CollecteurSenat
from pipeline.journal import configurer, logger
from pipeline.modeles import Confiance, DecisionCC, Statut, Texte
from pipeline.parseurs.tableaux_sec10 import _deduire_themes
from pipeline.rapprochement import Issue, rapprocher
log = logger("update")
@dataclass
class Ecart:
"""Une différence constatée entre la base et les sources officielles."""
texte_id: str | None
nature: str # ajout | statut | date | source | decision_cc | signalement
champ: str | None = None
ancienne_valeur: str | None = None
nouvelle_valeur: str | None = None
description: str = ""
collecteur: str = ""
def en_ligne(self) -> str:
cible = self.texte_id or "(nouveau)"
if self.ancienne_valeur or self.nouvelle_valeur:
return (
f" [{self.nature:12}] {cible:32} {self.champ or ''} : "
f"{self.ancienne_valeur or ''}{self.nouvelle_valeur or ''}"
)
return f" [{self.nature:12}] {cible:32} {self.description}"
@dataclass
class RapportRun:
"""Ce qu'un run a vu et fait."""
mode: str
ecarts: list[Ecart] = field(default_factory=list)
collectes: list[ResultatCollecte] = field(default_factory=list)
alertes: list[str] = field(default_factory=list)
@property
def ajouts(self) -> int:
return sum(1 for e in self.ecarts if e.nature == "ajout")
@property
def modifications(self) -> int:
return sum(1 for e in self.ecarts if e.nature not in ("ajout", "signalement"))
@property
def signalements(self) -> int:
return sum(1 for e in self.ecarts if e.nature == "signalement")
def en_texte(self) -> str:
lignes = [
f"Rapport de veille — mode « {self.mode} »",
"=" * 44,
"",
"Collecteurs",
]
for collecte in self.collectes:
if collecte.ignore:
etat = f"ignoré — {collecte.motif_ignore}"
elif collecte.erreurs:
etat = f"échec — {' ; '.join(collecte.erreurs)[:200]}"
else:
etat = (
f"{len(collecte.textes)} texte(s), "
f"{len(collecte.decisions_cc)} décision(s)"
)
lignes.append(f" {collecte.collecteur:26} {etat} ({collecte.duree_s:.1f} s)")
lignes += [
"",
f"Écarts détectés : {len(self.ecarts)}",
f" ajouts : {self.ajouts}",
f" modifications : {self.modifications}",
f" signalements : {self.signalements}",
]
if self.ecarts:
lignes.append("")
lignes.extend(e.en_ligne() for e in self.ecarts)
if self.alertes:
lignes += ["", "Alertes"] + [f" ! {a}" for a in self.alertes]
if not self.ecarts:
lignes += ["", " La base est conforme aux sources officielles consultées."]
return "\n".join(lignes)
# ─────────────────────────────────────────────────────────────────────────────
# Collecte
# ─────────────────────────────────────────────────────────────────────────────
def collecter(cx: sqlite3.Connection, client: ClientHttp) -> list[ResultatCollecte]:
"""Lance tous les collecteurs, chacun isolé de ses voisins."""
numeros = [
ligne["numero_officiel"]
for ligne in cx.execute(
"SELECT numero_officiel FROM textes WHERE numero_officiel IS NOT NULL "
"ORDER BY numero_officiel"
)
]
collecteurs = [
CollecteurLegifrance(client, numeros_a_verifier=numeros),
CollecteurSenat(client),
CollecteurConseilConstitutionnel(client),
]
return [collecteur.collecter() for collecteur in collecteurs]
# ─────────────────────────────────────────────────────────────────────────────
# Comparaison
# ─────────────────────────────────────────────────────────────────────────────
def comparer(cx: sqlite3.Connection, collectes: list[ResultatCollecte]) -> RapportRun:
"""Calcule les écarts entre la base et ce que les sources rapportent."""
rapport = RapportRun(mode="comparaison", collectes=collectes)
candidats = [
(ligne["id"], ligne["titre_court"], ligne["numero_officiel"])
for ligne in cx.execute("SELECT id, titre_court, numero_officiel FROM textes")
]
connus = {
ligne["id"]: ligne
for ligne in cx.execute("SELECT * FROM textes")
}
affaires_connues = {
ligne["numero_affaire"]: ligne
for ligne in cx.execute("SELECT * FROM decisions_cc")
}
for collecte in collectes:
for texte in collecte.textes:
_comparer_texte(texte, candidats, connus, rapport, collecte.collecteur)
for decision in collecte.decisions_cc:
_comparer_decision(decision, affaires_connues, rapport, collecte.collecteur)
return rapport
def _comparer_texte(
collecte: TexteCollecte,
candidats: list[tuple[str, str, str | None]],
connus: dict[str, sqlite3.Row],
rapport: RapportRun,
collecteur: str,
) -> None:
correspondance = rapprocher(
titre=collecte.titre, numero_officiel=collecte.numero_officiel, candidats=candidats
)
if correspondance.issue is Issue.NOUVEAU:
rapport.ecarts.append(
Ecart(
texte_id=None,
nature="ajout",
description=f"{collecte.numero_officiel or ''} · {collecte.titre[:110]}",
collecteur=collecteur,
)
)
return
if correspondance.issue is Issue.A_SIGNALER:
rapport.ecarts.append(
Ecart(
texte_id=correspondance.texte_id,
nature="signalement",
description=f"{correspondance.motif} — « {collecte.titre[:80]} »",
collecteur=collecteur,
)
)
return
existant = connus.get(correspondance.texte_id or "")
if existant is None:
return
# Changement de statut : c'est l'événement que la veille existe pour voir.
if collecte.statut and existant["statut"] != str(collecte.statut):
rapport.ecarts.append(
Ecart(
texte_id=existant["id"],
nature="statut",
champ="statut",
ancienne_valeur=existant["statut"],
nouvelle_valeur=str(collecte.statut),
collecteur=collecteur,
)
)
for champ, valeur in (
("numero_officiel", collecte.numero_officiel),
("date_promulgation", collecte.date_promulgation),
("date_adoption", collecte.date_adoption),
):
if valeur is None:
continue
attendu = valeur.isoformat() if isinstance(valeur, date) else str(valeur)
if existant[champ] != attendu:
rapport.ecarts.append(
Ecart(
texte_id=existant["id"],
nature="date" if champ.startswith("date") else "autre",
champ=champ,
ancienne_valeur=existant[champ],
nouvelle_valeur=attendu,
collecteur=collecteur,
)
)
def _comparer_decision(
decision: DecisionCC,
connues: dict[str, sqlite3.Row],
rapport: RapportRun,
collecteur: str,
) -> None:
existante = connues.get(decision.numero_affaire)
if existante is None:
rapport.ecarts.append(
Ecart(
texte_id=None,
nature="decision_cc",
description=(
f"affaire inconnue en base : {decision.numero_affaire}"
f"{(decision.resume or '')[:90]}"
),
collecteur=collecteur,
)
)
return
if decision.date_decision and not existante["date_decision"]:
rapport.ecarts.append(
Ecart(
texte_id=existante["texte_id"],
nature="decision_cc",
champ=f"{decision.numero_affaire} · date_decision",
ancienne_valeur=None,
nouvelle_valeur=decision.date_decision.isoformat(),
collecteur=collecteur,
)
)
if decision.date_saisine and not existante["date_saisine"]:
rapport.ecarts.append(
Ecart(
texte_id=existante["texte_id"],
nature="decision_cc",
champ=f"{decision.numero_affaire} · date_saisine",
ancienne_valeur=None,
nouvelle_valeur=decision.date_saisine.isoformat(),
collecteur=collecteur,
)
)
if (
decision.resultat
and str(decision.resultat) != "en_instance"
and existante["resultat"] != str(decision.resultat)
):
rapport.ecarts.append(
Ecart(
texte_id=existante["texte_id"],
nature="decision_cc",
champ=f"{decision.numero_affaire} · resultat",
ancienne_valeur=existante["resultat"],
nouvelle_valeur=str(decision.resultat),
collecteur=collecteur,
)
)
# ─────────────────────────────────────────────────────────────────────────────
# Application
# ─────────────────────────────────────────────────────────────────────────────
def appliquer(cx: sqlite3.Connection, rapport: RapportRun, run_id: int) -> None:
"""Écrit les écarts en base. Jamais appelé en mode `--dry-run`."""
with db.transaction(cx):
for ecart in rapport.ecarts:
db.enregistrer_changement(
cx,
run_id,
texte_id=ecart.texte_id,
nature=ecart.nature if ecart.nature in _NATURES_SQL else "autre",
champ=ecart.champ,
ancienne_valeur=ecart.ancienne_valeur,
nouvelle_valeur=ecart.nouvelle_valeur,
description=ecart.description or None,
)
if ecart.texte_id and ecart.champ and ecart.nature in ("statut", "date"):
cx.execute(
f"UPDATE textes SET {ecart.champ} = ?, maj_le = datetime('now'), " # noqa: S608
"derniere_verif = datetime('now') WHERE id = ?",
(ecart.nouvelle_valeur, ecart.texte_id),
)
cx.execute(
"UPDATE textes SET derniere_verif = datetime('now') "
"WHERE numero_officiel IS NOT NULL"
)
_NATURES_SQL = {"ajout", "statut", "date", "source", "autre"}
def classer_nouveau(collecte: TexteCollecte) -> Texte:
"""Construit un texte à partir d'une collecte, en signalant l'automatisme.
Conformément au §5.3, toute classification automatique porte la confiance
la plus basse et le drapeau de revue : la machine propose, l'humain valide.
"""
cotation = guadeloupe.coter(collecte.titre, collecte.resume)
return Texte(
id=_identifiant(collecte),
numero_officiel=collecte.numero_officiel,
type=collecte.type or "loi",
titre_court=collecte.titre[:300],
titre_officiel=collecte.titre,
statut=collecte.statut or Statut.PROMULGUEE,
date_promulgation=collecte.date_promulgation,
date_adoption=collecte.date_adoption,
themes=_deduire_themes(collecte.titre),
guadeloupe_pertinence=cotation.pertinence,
guadeloupe_note=cotation.note,
resume=collecte.resume,
confiance=Confiance.LOW,
source_seed=f"collecteur:{collecte.collecteur}",
a_verifier=True,
motif_verification=(
"Texte ajouté automatiquement par le pipeline : thèmes, impacts et "
"pertinence Guadeloupe sont déduits et n'ont pas été relus."
),
sources=collecte.sources,
evenements=collecte.evenements,
)
def _identifiant(collecte: TexteCollecte) -> str:
if collecte.numero_officiel:
return f"loi-{collecte.numero_officiel}"
from pipeline.rapprochement import normaliser
mots = normaliser(collecte.titre).split()[:6]
return "-".join(mots) or "texte-sans-titre"
# ─────────────────────────────────────────────────────────────────────────────
# Sortie statique
# ─────────────────────────────────────────────────────────────────────────────
def exporter_json(cx: sqlite3.Connection) -> int:
"""Régénère `data/textes.json` — dump complet pour le mode dégradé."""
textes = []
for ligne in cx.execute("SELECT * FROM v_textes ORDER BY id"):
entree = dict(ligne)
for champ in ("themes", "impacts", "points_cles"):
entree[champ] = json.loads(entree[champ]) if entree[champ] else None
entree["sources"] = [
dict(s)
for s in cx.execute(
"SELECT url, titre, editeur, date_publication, tier, extrait_verbatim, confiance "
"FROM sources WHERE texte_id = ? ORDER BY tier, id",
(ligne["id"],),
)
]
entree["evenements"] = [
dict(e)
for e in cx.execute(
"SELECT date_evenement, type_etape, description, source_url, previsionnel "
"FROM evenements WHERE texte_id = ? ORDER BY date_evenement",
(ligne["id"],),
)
]
entree["decisions_cc"] = [
dict(d)
for d in cx.execute(
"SELECT numero_affaire, date_saisine, date_decision, resultat, resume "
"FROM decisions_cc WHERE texte_id = ?",
(ligne["id"],),
)
]
textes.append(entree)
charge = {
"genere_le": time.strftime("%Y-%m-%dT%H:%M:%S"),
"date_arrete_corpus": "2026-07-25",
"textes": textes,
"echeances": [
dict(e) for e in cx.execute("SELECT * FROM echeances ORDER BY date_echeance")
],
"insights": [dict(i) for i in cx.execute("SELECT * FROM insights ORDER BY numero")],
}
chemins.DUMP_JSON.parent.mkdir(parents=True, exist_ok=True)
chemins.DUMP_JSON.write_text(
json.dumps(charge, ensure_ascii=False, indent=2), encoding="utf-8"
)
return len(textes)
# ─────────────────────────────────────────────────────────────────────────────
# Ligne de commande
# ─────────────────────────────────────────────────────────────────────────────
def main() -> None:
analyseur = argparse.ArgumentParser(
description="Met à jour la base depuis les sources législatives officielles."
)
analyseur.add_argument(
"--dry-run",
action="store_true",
help="collecte réelle, aucune écriture en base : affiche les écarts détectés",
)
analyseur.add_argument(
"--hors-ligne",
action="store_true",
help="n'utilise que le cache HTTP, sans accès réseau",
)
analyseur.add_argument("--verbeux", action="store_true")
arguments = analyseur.parse_args()
configurer("DEBUG" if arguments.verbeux else "INFO")
depart = time.monotonic()
cx = db.initialiser()
mode = "dry-run" if arguments.dry_run else "update"
with ClientHttp(hors_ligne=arguments.hors_ligne) as client:
collectes = collecter(cx, client)
rapport = comparer(cx, collectes)
rapport.mode = mode
for collecte in collectes:
if collecte.ignore:
rapport.alertes.append(f"{collecte.collecteur} ignoré : {collecte.motif_ignore}")
rapport.alertes.extend(f"{collecte.collecteur} : {e}" for e in collecte.erreurs)
run_id = db.ouvrir_run(cx, mode)
if not arguments.dry_run:
appliquer(cx, rapport, run_id)
exportes = exporter_json(cx)
log.info("dump statique régénéré", textes=exportes, fichier=str(chemins.DUMP_JSON))
db.cloturer_run(
cx,
run_id,
duree_s=time.monotonic() - depart,
ajouts=rapport.ajouts,
modifications=rapport.modifications,
echecs=sum(len(c.erreurs) for c in collectes),
alertes=rapport.alertes,
rapport=rapport.en_texte(),
)
print()
print(rapport.en_texte())
print()
if arguments.dry_run:
print(" Mode --dry-run : aucune écriture en base.")
print()
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
<html><body><table class="saisine-list sticky-header responsive-enabled" data-striping="1">
<caption class="sr-only">Tableau des affaires en instance</caption>
<thead>
<tr>
<th scope="col" specifier="field_numero_affaire" aria-sort="ascending" class="is-active"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=desc&amp;order=Affaire%20n%C2%B0" title="trier par Affaire n°" rel="nofollow">Affaire n°<span class="tablesort tablesort--desc">
<span class="visually-hidden">
Trier par ordre décroissant
</span>
</span>
</a></th>
<th scope="col" specifier="field_disposition_loi"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=asc&amp;order=Disposition" title="trier par Disposition" rel="nofollow">Disposition</a></th>
<th scope="col" specifier="field_auteur_saisine"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=asc&amp;order=Auteur%20de%20la%20saisine" title="trier par Auteur de la saisine" rel="nofollow">Auteur de la saisine</a></th>
<th scope="col" specifier="field_dates_saisine"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=asc&amp;order=Date%20de%20la%20saisine" title="trier par Date de la saisine" rel="nofollow">Date de la saisine</a></th>
<th scope="col" specifier="field_date_limite_reception"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=asc&amp;order=Date%20de%20cl%C3%B4ture%20de%20l%27instruction" title="trier par Date de clôture de l'instruction" rel="nofollow">Date de clôture de l'instruction</a></th>
<th scope="col" specifier="field_decision_rendue"><a href="/decisions/affaires-en-instances?id=32246&amp;sort=asc&amp;order=Date%20de%20lecture%20de%20la%20d%C3%A9cision" title="trier par Date de lecture de la décision" rel="nofollow">Date de lecture de la décision</a></th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><div class="mobile-hide" data-column="Affaire n°">2026-907 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi actualisant la programmation militaire pour les années 2024 à 2030 et portant diverses dispositions intéressant la défense</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026907dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>06.07.2026</p>
<p class="cell-value hidden">2026-07-06T00:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="even">
<td><div class="mobile-hide" data-column="Affaire n°">2026-910 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi relative au droit à l'aide à mourir</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinepdtsen.pdf" target="_blank" class="auteur"><p>Président du Sénat</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinesen_0.pdf" target="_blank" class="auteur"><p>Plus de soixante sénateurs</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinespm.pdf" target="_blank" class="auteur"><p>Premier ministre</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026910dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>16.07.2026</p>
<p class="cell-value hidden">2026-07-16T09:00:00+0200</p>
<p>17.07.2026</p>
<p class="cell-value hidden">2026-07-17T11:00:00+0200</p>
<p>17.07.2026</p>
<p class="cell-value hidden">2026-07-17T12:00:00+0200</p>
<p>20.07.2026</p>
<p class="cell-value hidden">2026-07-20T07:00:00+0200</p>
<p>20.07.2026</p>
<p class="cell-value hidden">2026-07-20T08:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="odd">
<td><div class="mobile-hide" data-column="Affaire n°">2026-911 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi visant à protéger les mineurs des risques auxquels les expose lutilisation des réseaux sociaux</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026911dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026911dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
<p>24.07.2026</p>
<p class="cell-value hidden">2026-07-24T18:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="even">
<td><div class="mobile-hide" data-column="Affaire n°">2026-912 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi pour une montagne vivante et souveraine</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026912dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="odd">
<td><div class="mobile-hide" data-column="Affaire n°">2026-913 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi visant à moderniser la gestion du patrimoine immobilier de l’État</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026913dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>23.07.2026</p>
<p class="cell-value hidden">2026-07-23T00:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="even">
<td><div class="mobile-hide" data-column="Affaire n°">2026-914 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi d'urgence pour la protection et la souveraineté agricoles</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026914dc_saisinedep.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026914dc_saisinedep_2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>24.07.2026</p>
<p class="cell-value hidden">2026-07-24T14:00:00+0200</p>
<p>24.07.2026</p>
<p class="cell-value hidden">2026-07-24T15:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
<tr class="odd">
<td><div class="mobile-hide" data-column="Affaire n°">2026-915 DC<span class="arrow"></span>
</div>
</td>
<td><div class="mobile-hide" data-column="Disposition">Loi visant à offrir des réponses immédiates aux phénomènes troublant lordre public, la sécurité et la tranquillité de nos concitoyens</div>
</td>
<td><div class="auteurs" data-column="Auteur de la saisine"><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026915dc_saisinedep1.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a><a href="https://www.conseil-constitutionnel.fr/sites/default/files/2026-07/2026915dc_saisinedep2.pdf" target="_blank" class="auteur"><p>Plus de soixante députés</p>
</a></div>
</td>
<td><div class="date" data-column="Date de la saisine"><p>24.07.2026</p>
<p class="cell-value hidden">2026-07-24T08:00:00+0200</p>
<p>24.07.2026</p>
<p class="cell-value hidden">2026-07-24T09:00:00+0200</p>
</div>
</td>
<td></td>
<td><div class="no-date" data-column="Date de lecture de la décision"></div>
</td>
</tr>
</tbody>
</table></body></html>
+345
View File
@@ -0,0 +1,345 @@
<html><body><article class="landing landing--type-decisions landing--view-mode-full">
<header>
<div class="field field--name-field-image-bandeau field--type-entity-reference field--label-hidden field__item"><article class="media media--type-image media--view-mode-bandeau-landing">
<div class="field field--name-field-media-image field--type-image field--label-hidden field__item"> <picture>
<source srcset="/sites/default/files/styles/bandeau_landing_wide/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&amp;itok=Wyn5lULp 1x" media="all and (min-width: 1024px)" type="image/webp" width="1350" height="550">
<source srcset="/sites/default/files/styles/bandeau_landing_narrow/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&amp;itok=yKNGqhX1 1x" media="all and (min-width: 481px) and (max-width: 1023px)" type="image/webp" width="1024" height="417">
<source srcset="/sites/default/files/styles/bandeau_landing_mobile/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&amp;itok=qFsmUyCr 1x" media="all and (max-width: 480px)" type="image/webp" width="480" height="196">
<img loading="lazy" aria-hidden="true" width="480" height="196" src="/sites/default/files/styles/bandeau_landing_mobile/public/2020-07/bg-landing-decisions.jpg.webp?h=06f6671c&amp;itok=qFsmUyCr" alt="Bandeau de la landing Toutes les décisions">
</picture>
</div>
</article>
</div>
<h1 class="title">Les décisions du Conseil Constitutionnel</h1>
<div class="clearfix text-formatted field field--name-field-texte field--type-text-long field--label-hidden field__item"><p>Toutes les décisions, ainsi que des outils statistiques et explicatifs pour mieux connaître lactivité du Conseil constitutionnel depuis sa création</p>
</div>
<div class="field field--name-field-lien-decision field--type-link field--label-hidden field__item"><a href="/les-decisions/decisions-par-type">Comprendre les types de décisions</a></div>
<div class="field field--name-field-lien-affaires-instance field--type-link field--label-hidden field__item"><a href="/decisions/affaires-instances">Les affaires en instance</a></div>
<div class="formulaire-recherche-decisions"><form data-drupal-selector="cc-landing-recherche-decisions" action="/decisions" method="post" id="cc-landing-recherche-decisions" accept-charset="UTF-8">
<h3 class="titre-formulaire" data-drupal-selector="edit-titre">Rechercher une décision</h3>
<div class="js-form-item form-item js-form-type-search form-type-search js-form-item-textfield form-item-textfield">
<label for="edit-textfield">Rechercher un terme ou un numéro de décision</label>
<input list="autocomplete-list" data-drupal-selector="edit-textfield" type="search" id="edit-textfield" name="textfield" value="" size="60" maxlength="128" placeholder="Ex: égalité devant la loi" class="form-search">
</div>
<datalist id="autocomplete-list" data-drupal-selector="edit-autocomplete-list"></datalist>
<div class="js-form-item form-item js-form-type-select form-type-select js-form-item-types form-item-types form-no-label">
<select data-drupal-selector="edit-types" id="edit-types" name="types" class="form-select"><option value="all">Tous les types</option><option value="32205">Question prioritaire de constitutionnalité</option><option value="32246">Constitutionnalité des lois, traités, des règlements des Assemblées</option><option value="32797">Loi ordinaire</option><option value="32800">Loi organique</option><option value="32803">Traité</option><option value="32806">Règlement des assemblées</option><option value="32211">Élections à l'Assemblée nationale</option><option value="32220">Élections au Sénat</option><option value="32224">Loi du pays de Nouvelle-Calédonie</option><option value="32221">Compétences outre-mer</option><option value="32223">Déclassement</option><option value="32215">Incompatibilité des parlementaires</option><option value="32400">Obligations fiscales</option><option value="32216">Déchéance de parlementaires</option><option value="32222">Fins de non-recevoir</option><option value="32212">Avis de l'article 16</option><option value="32219">Élection présidentielle</option><option value="32214">Fonctionnement du Conseil constitutionnel</option><option value="32218">Référendum</option><option value="32403">Référendum d'initiative partagée</option><option value="32217">Divers élections</option><option value="32213">Autres textes et décisions</option></select>
</div>
<div class="block-facets block-facet-date-range-picker js-form-wrapper form-wrapper" id="periode" data-drupal-selector="edit-date-range"><div class="form-group js-form-wrapper form-wrapper" data-drupal-selector="edit-form-group" id="edit-form-group"><label data-drupal-selector="edit-label">Par période</label>
<div class="js-form-item form-item js-form-type-date form-type-date js-form-item-date-from form-item-date-from form-no-label">
<span class="clear-input hidden">X</span>
<input id="edit-date-from" name="date_from" data-drupal-selector="edit-date-from" type="date" value="" class="form-date">
</div>
<div class="js-form-item form-item js-form-type-date form-type-date js-form-item-date-to form-item-date-to form-no-label">
<span class="clear-input hidden">X</span>
<input id="edit-date-to" name="date_to" data-drupal-selector="edit-date-to" type="date" value="" class="form-date">
</div>
</div>
</div>
<input data-drupal-selector="edit-submit" type="submit" id="edit-submit" name="op" value="Lancer une recherche" class="button js-form-submit form-submit">
<input class="reset reload-page button js-form-submit form-submit" data-drupal-selector="edit-reset" type="submit" id="edit-reset" name="op" value="Réinitialiser">
<a href="/recherche-experte" class="lien-page-recherche" data-smarttag="[]" data-drupal-selector="edit-lien" id="edit-lien">Plus de critères de recherche</a><input autocomplete="off" data-drupal-selector="form-if1ifd6wffso28jvmkzvnsozeco7wqt14mauqf8sdi" type="hidden" name="form_build_id" value="form-_IF1iFd6WfFSo28JVMkZvnSozEcO7wQt14Mauqf8sdI">
<input data-drupal-selector="edit-cc-landing-recherche-decisions" type="hidden" name="form_id" value="cc_landing_recherche_decisions">
</form>
</div>
</header>
<div class="sidebar">
<h3>Outils et ressources</h3>
<div class="content">
<div class="item">
<div class="field field--name-field-date-analytique field--type-string field--label-above">
<div class="field__label">Les tables analytiques</div>
<div class="field__item">À jour au 1<sup>er</sup> septembre 2024</div>
</div>
<div class="field field--name-field-reference-analytique field--type-entity-reference field--label-hidden field__item"><article class="media media--type-file media--view-mode-ressource-decisions">
<a href="https://www.conseil-constitutionnel.fr/sites/default/files/2024-10/1958-202408_tables.pdf">Tables analytiques 1958-2024</a><div class="type-size"><span class="type">Pdf</span>
<span class="size">33.95 Mo</span>
</div>
</article>
</div>
<div class="field field--name-field-lien-analytique field--type-link field--label-hidden field__item"><a href="/les-decisions/tables-analytiques">En savoir plus</a></div>
</div>
<div class="item">
<div class="field field--name-field-lien-dispositions field--type-link field--label-above">
<div class="field__label">Recueil des dispositions validées</div>
<div class="field__item"><a href="/dispositions">Voir le recueil</a></div>
</div>
</div>
<div class="item">
<div class="field field--name-field-date-bilan field--type-string field--label-above">
<div class="field__label">Bilan statistique</div>
<div class="field__item">À jour au 30 juin 2025</div>
</div>
<div class="field field--name-field-lien-bilan field--type-link field--label-hidden field__item"><a href="/bilan-statistique">Voir le bilan</a></div>
</div>
<div class="item">
<div class="field field--name-field-lien-archive-seance field--type-link field--label-above">
<div class="field__label">Archives des comptes rendus de séances</div>
<div class="field__item"><a href="/les-decisions/archives-des-comptes-rendus-de-seances">En savoir plus</a></div>
</div>
</div>
</div>
</div>
<div class="wrapper-content">
<div class="wrapper-dernieres-decisions"><h2>Les dernières décisions</h2>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026908DC.htm" rel="bookmark" title="Décision&nbsp;n°&nbsp;2026-908 DC du 23 juillet 2026">
<div class="node__content">
<h3 class="title">Décision&nbsp;&nbsp;2026-908 DC du 23 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi organique relative au renforcement des juridictions criminelles</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026909DC.htm" rel="bookmark" title="Décision n° 2026-909 DC du 23 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-909 DC du 23 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi sur la justice criminelle et le respect des victimes</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Non conformité partielle - réserve</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026906DC.htm" rel="bookmark" title="Décision&nbsp;n°&nbsp;2026-906 DC du 23&nbsp;juillet&nbsp;2026">
<div class="node__content">
<h3 class="title">Décision&nbsp;&nbsp;2026-906 DC du 23&nbsp;juillet&nbsp;2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques dattentat</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité - réserve</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/20261213QPC.htm" rel="bookmark" title="Décision n° 2026-1213 QPC du 10 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-1213 QPC du 10 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Société Altaroc Partners et autres [Notification du droit de se taire à la personne à laquelle sont notifiés des griefs dans le cadre d'une procédure de sanction par lAutorité des marchés financiers]</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/20269LP.htm" rel="bookmark" title="Décision n°&nbsp;2026-9&nbsp;LP du 9 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n°&nbsp;2026-9&nbsp;LP du 9 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi du pays modifiant le code minier de la Nouvelle-Calédonie (partie législative)</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026326L.htm" rel="bookmark" title="Décision n° 2026-326 L du 9 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-326 L du 9 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Nature juridique de certaines dispositions de larticle&nbsp;L.&nbsp;122-8 du code de l’énergie</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Réglementaire</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/20261211QPC.htm" rel="bookmark" title="Décision n° 2026-1211 QPC du 3 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-1211 QPC du 3 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>M.&nbsp;Maurice&nbsp;D. [Interdiction de linscription au tableau de lordre des médecins, chirurgiens dentistes et sages-femmes inscrits ou enregistrés dans un autre État]</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/20261212QPC.htm" rel="bookmark" title="Décision n° 2026-1212 QPC du 3&nbsp;juillet&nbsp;2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-1212 QPC du 3&nbsp;juillet&nbsp;2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>M.&nbsp;Valeriu M. [Circonstances justifiant laggravation de la suspension prononcée par lAgence française de lutte contre le dopage]</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Non conformité totale</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026325L.htm" rel="bookmark" title="Décision n° 2026-325 L du 2 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-325 L du 2 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Nature juridique de certaines dispositions de larticle L.&nbsp;1422-3 du code du travail et de larticle L.&nbsp;131-3 du code de lentrée et du séjour des étrangers et du droit dasile</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Réglementaire</p>
</div>
</div>
</a>
</article>
<article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/20261210QPC.htm" rel="bookmark" title="Décision n° 2026-1210 QPC du 25 juin 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-1210 QPC du 25 juin 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Société Orange SA [Cumul de poursuites et de sanctions en cas de manquement aux règles encadrant la prospection directe par voie automatisée]</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Non conformité totale - effet différé - réserve transitoire</p>
</div>
</div>
</a>
</article>
</div>
<a href="/les-decisions" class="lien-toutes-decisions">Toutes les décisions</a>
</div></article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026908DC.htm" rel="bookmark" title="Décision&nbsp;n°&nbsp;2026-908 DC du 23 juillet 2026">
<div class="node__content">
<h3 class="title">Décision&nbsp;&nbsp;2026-908 DC du 23 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi organique relative au renforcement des juridictions criminelles</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité</p>
</div>
</div>
</a>
</article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026909DC.htm" rel="bookmark" title="Décision n° 2026-909 DC du 23 juillet 2026">
<div class="node__content">
<h3 class="title">Décision n° 2026-909 DC du 23 juillet 2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi sur la justice criminelle et le respect des victimes</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Non conformité partielle - réserve</p>
</div>
</div>
</a>
</article><article class="node node--type-decision node--promoted node--view-mode-synthetique">
<a href="/decision/2026/2026906DC.htm" rel="bookmark" title="Décision&nbsp;n°&nbsp;2026-906 DC du 23&nbsp;juillet&nbsp;2026">
<div class="node__content">
<h3 class="title">Décision&nbsp;&nbsp;2026-906 DC du 23&nbsp;juillet&nbsp;2026</h3>
<div class="field field--name-field-titre-complet field--type-string-long field--label-hidden field__item">
<p>Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques dattentat</p>
</div>
<div class="field field--name-field-solution-normalisee field--type-string field--label-hidden field__item">
<p>Conformité - réserve</p>
</div>
</div>
</a>
</article></body></html>
+32
View File
@@ -0,0 +1,32 @@
<html><body><a href="/dossier-legislatif/pjl25-457.html">Loi n° 2026-650 du 23 juillet 2026 relative au renforcement des juridictions criminelles
parue au JO n°171 du 24 juillet 2026</a><a href="/dossier-legislatif/pjl25-456.html">Loi n° 2026-651 du 23 juillet 2026 sur la justice criminelle et le respect des victimes
parue au JO n°171 du 24 juillet 2026</a><a href="/dossier-legislatif/ppl25-214.html">Loi n° 2026-630 du 13 juillet 2026 visant à assurer le droit de chaque enfant à être assisté d'un avocat dans le cadre d'une mesure d'assistance éducative et de protection de l'enfance
parue au JO n°163 du 14 juillet 2026</a><a href="/dossier-legislatif/ppl23-431.html">Loi n° 2026-602 du 8 juillet 2026 visant à réduire l'impact environnemental de l'industrie textile
parue au JO n°159 du 9 juillet 2026</a><a href="/dossier-legislatif/pjl25-283.html">Loi n° 2026-574 du 30 juin 2026 portant habilitation de l'assemblée de Martinique à fixer des règles applicables sur son territoire en application de l'article 73 de la Constitution en matière d'énergie, d'eau et d'assainissement
parue au JO n°152 du 1 juillet 2026</a><a href="/dossier-legislatif/ppl25-359.html">Loi n° 2026-554 du 29 juin 2026 visant à relancer les investissements dans le secteur de l'hydroélectricité pour contribuer à la transition énergétique
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl25-320.html">Loi n° 2026-555 du 29 juin 2026 visant à réparer les préjudices causés par la transplantation de mineurs de La Réunion en France hexagonale de 1962 à 1984
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl25-273.html">Loi n° 2026-553 du 29 juin 2026 visant à améliorer l'accès au logement des travailleurs des services publics
parue au JO n°151 du 30 juin 2026</a><a href="/dossier-legislatif/ppl24-008.html">Loi n° 2026-542 du 26 juin 2026 relative à la sortie des collections publiques de restes humains kali'nas et arawaks en vue de funérailles sur le territoire de la Guyane
parue au JO n°149 du 27 juin 2026</a><a href="/dossier-legislatif/pjl25-024.html">Loi n° 2026-534 du 25 juin 2026 relative à la lutte contre les fraudes sociales et fiscales
parue au JO n°148 du 26 juin 2026</a><a href="/dossier-legislatif/ppl23-483.html">Loi n° 2026-532 du 24 juin 2026 visant à ouvrir le dispositif de réduction d'activité aux moniteurs de ski stagiaires
parue au JO n°147 du 25 juin 2026</a><a href="/dossier-legislatif/ppl24-180.html">Loi n° 2026-492 du 12 juin 2026 visant à améliorer la protection et l'accompagnement des parents d'enfants atteints d'un cancer, d'une maladie grave ou d'un handicap
parue au JO n°137 du 13 juin 2026</a><a href="/dossier-legislatif/ppl23-373.html">Loi n° 2026-491 du 12 juin 2026 visant à reconnaître la responsabilité de l'État et à indemniser les victimes du chlordécone
parue au JO n°137 du 13 juin 2026</a><a href="/dossier-legislatif/pjl25-470.html">Loi n° 2026-470 du 11 juin 2026 portant transposition de l'avenant n° 3 du 25 février 2026 au protocole d'accord du 10 novembre 2023 relatif à l'assurance chômage
parue au JO n°136 du 12 juin 2026</a><a href="/dossier-legislatif/ppl25-213.html">Loi n° 2026-442 du 4 juin 2026 visant à étendre à toutes les communes la compensation financière prévue pour les communes de plus de 3 500 habitants pour l'exercice de l'ensemble des compétences du service public de la petite enfance
parue au JO n°130 du 5 juin 2026</a><a href="/dossier-legislatif/ppl24-475.html">Loi n° 2026-441 du 4 juin 2026 portant pérennisation du contrat de professionnalisation expérimental
parue au JO n°130 du 5 juin 2026</a><a href="/dossier-legislatif/ppl24-636.html">Loi n° 2026-410 du 28 mai 2026 organique portant régularisation des natifs dans le corps électoral pour les élections au congrès et aux assemblées de province de Nouvelle-Calédonie
parue au JO n°124 du 29 mai 2026</a><a href="/dossier-legislatif/pjl23-550.html">Loi n° 2026-403 du 26 mai 2026 de simplification de la vie économique
parue au JO n°122 du 27 mai 2026</a><a href="/dossier-legislatif/ppl24-662.html">Loi n° 2026-404 du 26 mai 2026 visant à garantir l'égal accès de tous à l'accompagnement et aux soins palliatifs
parue au JO n°122 du 27 mai 2026</a><a href="/dossier-legislatif/ppl24-226.html">Loi n° 2026-381 du 19 mai 2026 visant à soutenir les collectivités territoriales dans la prévention et la gestion des inondations
parue au JO n°117 du 20 mai 2026</a><a href="/dossier-legislatif/ppl25-401.html">Loi n° 2026-373 du 15 mai 2026 facilitant l'exercice en France des médecins diplômés au Royaume-Uni ayant débuté leurs études avant le Brexit
parue au JO n°114 du 16 mai 2026</a><a href="/dossier-legislatif/pjl24-871.html">Loi n° 2026-351 du 9 mai 2026 relatif à la restitution de biens culturels ayant fait l'objet d'une appropriation illicite
parue au JO n°109 du 10 mai 2026</a><a href="/dossier-legislatif/ppl24-637.html">Loi n° 2026-350 du 9 mai 2026 visant à garantir le droit de visite des parlementaires et des bâtonniers dans les lieux de privation de liberté
parue au JO n°109 du 10 mai 2026</a><a href="/dossier-legislatif/ppl25-187.html">Loi n° 2026-307 du 23 avril 2026 visant à instaurer une procédure simplifiée de recouvrement des créances commerciales incontestées
parue au JO n°97 du 24 avril 2026</a><a href="/dossier-legislatif/ppl24-415.html">Loi n° 2026-248 du 7 avril 2026 visant à simplifier la sortie de l'indivision et la gestion des successions vacantes
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/ppl25-328.html">Loi n° 2026-249 du 7 avril 2026 visant à permettre le remboursement des frais d'expertise comptable aux candidats
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/ppl24-413.html">Loi n° 2026-247 du 7 avril 2026 relative aux missions des professionnels de santé, vétérinaires, psychothérapeutes et psychologues des services d'incendie et de secours
parue au JO n°83 du 8 avril 2026</a><a href="/dossier-legislatif/pjl24-630.html">Loi n° 2026-201 du 20 mars 2026 relative à l'organisation des jeux Olympiques et Paralympiques de 2030
parue au JO n°69 du 21 mars 2026</a><a href="/dossier-legislatif/ppl23-569.html">Loi n° 2026-122 du 23 février 2026 relative à la confidentialité des consultations des juristes d'entreprise
parue au JO n°48 du 25 février 2026</a><a href="/dossier-legislatif/pjlf2026.html">Loi n° 2026-103 du 19 février 2026 de finances pour 2026
parue au JO n°43 du 20 février 2026</a><a href="/dossier-legislatif/ppl24-223.html">Loi n° 2026-6 du 7 janvier 2026 organique tendant à modifier le II de l'article 43 de la loi organique n° 2004-192 du 27 février 2004 portant statut d'autonomie de la Polynésie française
parue au JO n°6 du 8 janvier 2026</a></body></html>
+4 -1
View File
@@ -212,7 +212,10 @@ def test_une_fiche_a_timeline_et_sources(base_complete) -> None:
def test_les_deductions_sont_signalees(base_complete) -> None: def test_les_deductions_sont_signalees(base_complete) -> None:
"""Toute valeur déduite porte un motif de vérification lisible.""" """Toute valeur déduite porte un motif de vérification lisible."""
for ligne in base_complete.execute("SELECT id, motif_verification FROM textes WHERE a_verifier = 1"): lignes = base_complete.execute(
"SELECT id, motif_verification FROM textes WHERE a_verifier = 1"
)
for ligne in lignes:
assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif" assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif"
+299
View File
@@ -0,0 +1,299 @@
"""Collecteurs et rapprochement.
Les parseurs sont testés sur des fixtures HTML figées, capturées le 25 juillet
2026 sur les sites officiels : aucun test ne dépend du réseau. Une fixture qui
cesse de passer signale un changement de structure du site — c'est le but.
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from selectolax.parser import HTMLParser
from pipeline.collecteurs.conseil_constitutionnel import (
_index_des_colonnes,
_lire_decision_rendue,
_lire_ligne_instance,
)
from pipeline.collecteurs.http import ClientHttp, _cle_de_cache
from pipeline.collecteurs.legifrance import CollecteurLegifrance, _lire_titre
from pipeline.collecteurs.senat import CollecteurSenat
from pipeline.modeles import ResultatCC, Statut, TypeTexte
from pipeline.rapprochement import Issue, normaliser, rapprocher, similarite
FIXTURES = Path(__file__).parent / "fixtures"
# ─────────────────────────────────────────────────────────────────────────────
# Conseil constitutionnel
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def affaires_dc() -> list:
tableau = HTMLParser(
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
).css_first("table")
colonnes = _index_des_colonnes(tableau)
return [
affaire
for ligne in tableau.css("tbody tr")
if (affaire := _lire_ligne_instance(ligne, colonnes))
]
def test_les_sept_affaires_dc_en_instance(affaires_dc) -> None:
"""Le corpus en annonçait sept ; le registre officiel les confirme."""
numeros = {a.numero_affaire for a in affaires_dc}
assert numeros == {
"2026-907 DC",
"2026-910 DC",
"2026-911 DC",
"2026-912 DC",
"2026-913 DC",
"2026-914 DC",
"2026-915 DC",
}
def test_saisine_de_la_loi_riposte(affaires_dc) -> None:
riposte = next(a for a in affaires_dc if a.numero_affaire == "2026-915 DC")
assert riposte.date_saisine == date(2026, 7, 24)
assert riposte.resultat is ResultatCC.EN_INSTANCE
assert "soixante députés" in (riposte.saisissants or "")
def test_la_programmation_militaire_recoit_son_numero(affaires_dc) -> None:
"""Le rapport la citait sans numéro d'affaire ; la collecte le fournit."""
lpm = next(a for a in affaires_dc if a.numero_affaire == "2026-907 DC")
assert lpm.date_saisine == date(2026, 7, 6)
assert "programmation militaire" in (lpm.resume or "").lower()
def test_colonnes_lues_par_entete_et_non_par_position() -> None:
"""Les tableaux DC et QPC n'ont pas les mêmes colonnes."""
tableau = HTMLParser(
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
).css_first("table")
colonnes = _index_des_colonnes(tableau)
assert colonnes["saisissants"] != colonnes["date_saisine"]
assert set(colonnes) >= {"affaire", "disposition", "saisissants", "date_saisine"}
@pytest.mark.parametrize(
("intitule", "attendu"),
[
("2026-908 DC du 23 juillet 2026 Loi organique … Conformité", ResultatCC.CONFORME),
(
"2026-906 DC du 23 juillet 2026 Loi … Conformité - réserve",
ResultatCC.CONFORME_AVEC_RESERVES,
),
(
"2026-909 DC du 23 juillet 2026 Loi … Non conformité partielle",
ResultatCC.NON_CONFORMITE_PARTIELLE,
),
],
)
def test_lecture_du_resultat_d_une_decision(intitule: str, attendu: ResultatCC) -> None:
"""« Conformité » ne contient pas « conforme » : l'ordre des motifs compte."""
decision = _lire_decision_rendue(f"Décision n° {intitule}", None)
assert decision is not None
assert decision.resultat is attendu
assert decision.date_decision == date(2026, 7, 23)
def test_decisions_rendues_de_la_fixture() -> None:
document = HTMLParser((FIXTURES / "cc_decisions.html").read_text(encoding="utf-8"))
decisions = [
d
for article in document.css("article")
if (d := _lire_decision_rendue(" ".join(article.text(separator=" ").split()), None))
]
numeros = {d.numero_affaire for d in decisions}
assert {"2026-906 DC", "2026-908 DC", "2026-909 DC"} <= numeros
# ─────────────────────────────────────────────────────────────────────────────
# Sénat
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def lois_senat() -> list:
document = HTMLParser(
(FIXTURES / "senat_lois_promulguees.html").read_text(encoding="utf-8")
)
collecteur = CollecteurSenat(ClientHttp(hors_ligne=True))
textes = []
for ancre in document.css("a"):
libelle = " ".join(ancre.text(separator=" ", strip=True).split())
if texte := collecteur._lire_intitule(libelle, ancre.attributes.get("href")):
textes.append(texte)
return textes
def test_senat_releve_les_lois_de_la_fenetre(lois_senat) -> None:
numeros = {t.numero_officiel for t in lois_senat}
# Quatre lois de la fenêtre de veille, aux dates connues du corpus.
assert {"2026-651", "2026-650", "2026-630", "2026-602"} <= numeros
def test_senat_lit_dates_et_nature(lois_senat) -> None:
par_numero = {t.numero_officiel: t for t in lois_senat}
justice = par_numero["2026-651"]
assert justice.date_promulgation == date(2026, 7, 23)
assert justice.statut is Statut.PROMULGUEE
assert justice.type is TypeTexte.LOI
def test_les_deux_sources_ne_disent_pas_la_meme_chose_du_caractere_organique(
lois_senat,
) -> None:
"""Asymétrie réelle entre les sources, à ne pas corriger en silence.
Légifrance intitule la loi n° 2026-650 « LOI organique n° 2026-650 … » ;
la liste du Sénat écrit « Loi n° 2026-650 … » sans la qualifier. Le
collecteur rapporte ce que sa source dit — c'est le rôle du rapprochement
de ne pas laisser la source la plus pauvre écraser la plus riche. Le run de
mise à jour ne compare d'ailleurs pas le champ `type`.
"""
organique = par_numero_senat(lois_senat)["2026-650"]
assert organique.type is TypeTexte.LOI, (
"le Sénat ne qualifie pas cette loi d'organique : le collecteur ne doit "
"pas le deviner à sa place"
)
def par_numero_senat(lois: list) -> dict:
return {t.numero_officiel: t for t in lois}
def test_senat_ne_confond_pas_l_objet_avec_l_etat(lois_senat) -> None:
"""Le libellé se termine par « parue » : ce n'est pas l'objet de la loi."""
for texte in lois_senat:
assert not texte.titre.rstrip().endswith("parue")
assert "en cours" not in texte.titre
def test_senat_produit_une_source_primaire(lois_senat) -> None:
for texte in lois_senat:
assert texte.sources
assert texte.sources[0].tier == "T1"
assert texte.sources[0].url.startswith("https://www.senat.fr")
# ─────────────────────────────────────────────────────────────────────────────
# Légifrance
# ─────────────────────────────────────────────────────────────────────────────
def test_legifrance_indisponible_sans_identifiants(monkeypatch) -> None:
monkeypatch.delenv("LEGIFRANCE_CLIENT_ID", raising=False)
collecteur = CollecteurLegifrance(ClientHttp(hors_ligne=True))
disponible, motif = collecteur.est_disponible()
assert disponible is False
assert "repli" in (motif or "")
def test_legifrance_lit_un_resultat_de_recherche() -> None:
titre = {
"id": "LEGITEXT000054249605_14-06-2026",
"cid": "JORFTEXT000054245243",
"title": "LOI n° 2026-491 du 12 juin 2026 visant à reconnaître la responsabilité de l'Etat",
}
texte = _lire_titre(titre, "2026-491")
assert texte is not None
assert texte.date_promulgation == date(2026, 6, 12)
assert texte.source_url.endswith("JORFTEXT000054245243")
assert texte.sources[0].tier == "T1"
def test_legifrance_ecarte_un_resultat_hors_sujet() -> None:
titre = {"cid": "X", "title": "LOI n° 2026-999 du 1er janvier 2026 sans rapport"}
assert _lire_titre(titre, "2026-491") is None
def test_legifrance_deduit_le_caractere_organique() -> None:
titre = {
"cid": "JORF1",
"title": "LOI organique n° 2026-650 du 23 juillet 2026 relative aux juridictions",
}
texte = _lire_titre(titre, "2026-650")
assert texte.type is TypeTexte.LOI_ORGANIQUE
# ─────────────────────────────────────────────────────────────────────────────
# Client HTTP
# ─────────────────────────────────────────────────────────────────────────────
def test_le_cache_distingue_les_corps_de_requete() -> None:
a = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-491"}, None)
b = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-403"}, None)
assert a != b
def test_mode_hors_ligne_ne_touche_pas_au_reseau(tmp_path) -> None:
client = ClientHttp(cache=tmp_path, hors_ligne=True)
reponse = client.get("https://exemple.invalide/rien")
assert reponse.code == 0
assert not reponse.a_reussi
def test_le_cache_est_relu(tmp_path) -> None:
client = ClientHttp(cache=tmp_path, hors_ligne=True)
cle = _cle_de_cache("GET", "https://x.fr/a", None, None)
client._ecrire_cache(cle, "contenu")
reponse = client.get("https://x.fr/a")
assert reponse.texte == "contenu"
assert reponse.depuis_le_cache
# ─────────────────────────────────────────────────────────────────────────────
# Rapprochement
# ─────────────────────────────────────────────────────────────────────────────
def test_normalisation_retire_le_vocabulaire_passe_partout() -> None:
assert "loi" not in normaliser("LOI n° 2026-491 relative au chlordécone")
assert "chlordecone" in normaliser("LOI n° 2026-491 relative au chlordécone")
def test_similarite_reconnait_un_titre_court_dans_un_titre_long() -> None:
court = "Loi Riposte ordre public sécurité tranquillité"
long = (
"Loi visant à offrir des réponses immédiates aux phénomènes troublant "
"l'ordre public, la sécurité et la tranquillité de nos concitoyens"
)
assert similarite(court, long) >= 0.72
def test_le_numero_officiel_prime_sur_le_titre() -> None:
correspondance = rapprocher(
titre="Un intitulé totalement différent",
numero_officiel="2026-491",
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
)
assert correspondance.issue is Issue.RAPPROCHE
assert correspondance.texte_id == "loi-2026-491"
assert correspondance.score == 1.0
def test_un_texte_inconnu_est_declare_nouveau() -> None:
correspondance = rapprocher(
titre="Loi sur les moniteurs de ski stagiaires en zone de montagne",
numero_officiel=None,
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
)
assert correspondance.issue is Issue.NOUVEAU
def test_une_correspondance_douteuse_est_signalee_pas_fusionnee() -> None:
"""Entre les deux seuils, le pipeline s'abstient et demande un humain."""
correspondance = rapprocher(
titre="Loi relative à la protection des enfants et des mineurs",
numero_officiel=None,
candidats=[("pjl-protection-enfants", "PJL relatif à la protection des enfants", None)],
)
assert correspondance.issue in (Issue.RAPPROCHE, Issue.A_SIGNALER)
if correspondance.issue is Issue.A_SIGNALER:
assert "vérification humaine" in correspondance.motif
def test_aucun_candidat() -> None:
correspondance = rapprocher(titre="X", numero_officiel="2026-1", candidats=[])
assert correspondance.issue is Issue.NOUVEAU
+311
View File
@@ -0,0 +1,311 @@
"""Run de veille : détection des écarts, classification, export.
Aucun test ne va sur le réseau : les collectes sont fabriquées à la main, ce
qui permet de vérifier exactement quels écarts le pipeline doit voir — et
surtout lesquels il ne doit pas inventer.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import date
import pytest
from pipeline import db
from pipeline.collecteurs.base import ResultatCollecte, TexteCollecte
from pipeline.modeles import (
Confiance,
DecisionCC,
Evenement,
Pertinence,
ResultatCC,
Source,
Statut,
Texte,
TypeEtape,
TypeTexte,
)
from pipeline.update import appliquer, classer_nouveau, comparer, exporter_json
@pytest.fixture
def base(tmp_path) -> sqlite3.Connection:
"""Base minimale : une loi promulguée et une loi en attente de décision."""
cx = db.initialiser(tmp_path / "veille.db")
db.enregistrer_textes(
cx,
[
Texte(
id="loi-2026-491",
numero_officiel="2026-491",
type=TypeTexte.LOI,
titre_court="Chlordécone : responsabilité de l'État",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 6, 12),
guadeloupe_pertinence=Pertinence.FORTE,
sources=[
Source(
url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
editeur="Légifrance",
)
],
evenements=[
Evenement(
date_evenement=date(2026, 6, 12),
type_etape=TypeEtape.PROMULGATION,
description="Promulgation",
)
],
),
Texte(
id="loi-riposte",
type=TypeTexte.LOI,
titre_court="Loi « Riposte » (ordre public, sécurité, tranquillité)",
statut=Statut.ADOPTEE_NON_PROMULGUEE,
date_adoption=date(2026, 7, 21),
sources=[Source(url="https://www.assemblee-nationale.fr/dyn/17/textes/l17t0340")],
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_saisine=date(2026, 7, 24),
resultat=ResultatCC.EN_INSTANCE,
)
],
),
],
)
return cx
def _collecte(**kwargs) -> ResultatCollecte:
return ResultatCollecte(collecteur="essai", **kwargs)
# ─────────────────────────────────────────────────────────────────────────────
# Détection des écarts
# ─────────────────────────────────────────────────────────────────────────────
def test_aucun_ecart_quand_la_source_confirme_la_base(base) -> None:
"""Le cas normal : la veille ne doit rien signaler quand rien ne bouge."""
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-491 du 12 juin 2026 chlordécone",
source_url="https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000054245243",
collecteur="essai",
numero_officiel="2026-491",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 6, 12),
)
]
)
rapport = comparer(base, [collecte])
assert rapport.ecarts == []
def test_un_texte_inconnu_est_un_ajout(base) -> None:
collecte = _collecte(
textes=[
TexteCollecte(
titre="LOI n° 2026-700 du 1er septembre 2026 relative aux transports scolaires",
source_url="https://www.legifrance.gouv.fr/jorf/id/X",
collecteur="essai",
numero_officiel="2026-700",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 9, 1),
)
]
)
rapport = comparer(base, [collecte])
assert rapport.ajouts == 1
assert rapport.ecarts[0].nature == "ajout"
def test_un_changement_de_statut_est_detecte(base) -> None:
"""La promulgation de la loi « Riposte » : l'événement que la veille attend."""
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://www.legifrance.gouv.fr/jorf/id/Y",
collecteur="essai",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 8, 26),
)
]
)
rapport = comparer(base, [collecte])
statuts = [e for e in rapport.ecarts if e.nature == "statut"]
assert len(statuts) == 1
assert statuts[0].texte_id == "loi-riposte"
assert statuts[0].ancienne_valeur == "adoptee_non_promulguee"
assert statuts[0].nouvelle_valeur == "promulguee"
def test_une_decision_rendue_est_detectee(base) -> None:
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_decision=date(2026, 8, 24),
resultat=ResultatCC.NON_CONFORMITE_PARTIELLE,
)
]
)
rapport = comparer(base, [collecte])
natures = {e.champ for e in rapport.ecarts if e.nature == "decision_cc"}
assert any("date_decision" in (c or "") for c in natures)
assert any("resultat" in (c or "") for c in natures)
def test_une_affaire_inconnue_est_signalee(base) -> None:
"""Cas réel : le corpus ignorait le numéro 2026-907 DC de la LPM."""
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-907 DC",
date_saisine=date(2026, 7, 6),
resume="Loi actualisant la programmation militaire",
)
]
)
rapport = comparer(base, [collecte])
assert any("affaire inconnue" in e.description for e in rapport.ecarts)
def test_une_affaire_en_instance_deja_connue_ne_produit_rien(base) -> None:
collecte = _collecte(
decisions_cc=[
DecisionCC(
numero_affaire="2026-915 DC",
date_saisine=date(2026, 7, 24),
resultat=ResultatCC.EN_INSTANCE,
)
]
)
assert comparer(base, [collecte]).ecarts == []
# ─────────────────────────────────────────────────────────────────────────────
# Écriture
# ─────────────────────────────────────────────────────────────────────────────
def test_le_dry_run_n_ecrit_rien(base) -> None:
"""`comparer` ne doit jamais toucher à la base."""
avant = db.statistiques(base)
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://x.fr/y",
collecteur="essai",
statut=Statut.PROMULGUEE,
)
]
)
comparer(base, [collecte])
assert db.statistiques(base) == avant
def test_appliquer_ecrit_le_changement_et_le_journalise(base) -> None:
collecte = _collecte(
textes=[
TexteCollecte(
titre="Loi « Riposte » (ordre public, sécurité, tranquillité)",
source_url="https://x.fr/y",
collecteur="essai",
statut=Statut.PROMULGUEE,
)
]
)
rapport = comparer(base, [collecte])
run_id = db.ouvrir_run(base, "update")
appliquer(base, rapport, run_id)
statut = base.execute("SELECT statut FROM textes WHERE id = 'loi-riposte'").fetchone()[0]
assert statut == "promulguee"
journal = base.execute(
"SELECT nature, champ, nouvelle_valeur FROM veille_changements WHERE run_id = ?",
(run_id,),
).fetchall()
assert any(
ligne["champ"] == "statut" and ligne["nouvelle_valeur"] == "promulguee"
for ligne in journal
)
# ─────────────────────────────────────────────────────────────────────────────
# Classification d'un texte nouveau
# ─────────────────────────────────────────────────────────────────────────────
def test_un_texte_collecte_est_marque_comme_automatique() -> None:
"""§5.3 : la machine propose avec une confiance basse, l'humain valide."""
texte = classer_nouveau(
TexteCollecte(
titre="LOI n° 2026-700 du 1er septembre 2026 relative à l'eau en Guadeloupe",
source_url="https://www.legifrance.gouv.fr/jorf/id/Z",
collecteur="legifrance",
numero_officiel="2026-700",
statut=Statut.PROMULGUEE,
date_promulgation=date(2026, 9, 1),
)
)
assert texte.id == "loi-2026-700"
assert texte.confiance is Confiance.LOW
assert texte.a_verifier is True
assert "déduits" in (texte.motif_verification or "")
assert texte.guadeloupe_pertinence is Pertinence.FORTE # « Guadeloupe » dans l'intitulé
def test_un_texte_sans_numero_recoit_un_identifiant_lisible() -> None:
texte = classer_nouveau(
TexteCollecte(
titre="Proposition de loi relative aux sargasses en Martinique",
source_url="https://www.senat.fr/x",
collecteur="senat",
statut=Statut.NAVETTE,
)
)
assert texte.id and " " not in texte.id
assert "sargasses" in texte.id
# ─────────────────────────────────────────────────────────────────────────────
# Export statique
# ─────────────────────────────────────────────────────────────────────────────
def test_export_json(base, tmp_path, monkeypatch) -> None:
from pipeline import chemins
destination = tmp_path / "textes.json"
monkeypatch.setattr(chemins, "DUMP_JSON", destination)
total = exporter_json(base)
assert total == 2
charge = json.loads(destination.read_text(encoding="utf-8"))
assert charge["date_arrete_corpus"] == "2026-07-25"
identifiants = {t["id"] for t in charge["textes"]}
assert identifiants == {"loi-2026-491", "loi-riposte"}
chlordecone = next(t for t in charge["textes"] if t["id"] == "loi-2026-491")
assert chlordecone["sources"][0]["url"].startswith("https://www.legifrance.gouv.fr")
assert chlordecone["evenements"][0]["type_etape"] == "promulgation"
riposte = next(t for t in charge["textes"] if t["id"] == "loi-riposte")
assert riposte["decisions_cc"][0]["numero_affaire"] == "2026-915 DC"
def test_rapport_lisible(base) -> None:
rapport = comparer(base, [_collecte()])
rapport.mode = "dry-run"
texte = rapport.en_texte()
assert "Rapport de veille" in texte
assert "conforme aux sources officielles" in texte