diff --git a/.env.example b/.env.example
index b9e66f5..0ca9346 100644
--- a/.env.example
+++ b/.env.example
@@ -5,12 +5,22 @@
# ── API Légifrance (PISTE) ───────────────────────────────────────────────────
# 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é
# (repli sur le scraping léger AN / Sénat / Conseil constitutionnel).
LEGIFRANCE_CLIENT_ID=
LEGIFRANCE_CLIENT_SECRET=
# `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
+# Clé d'API PISTE. Facultative : l'API Légifrance se contente du jeton OAuth.
+LEGIFRANCE_API_KEY=
# ── Comportement du pipeline ─────────────────────────────────────────────────
# Chemin de la base SQLite (relatif à la racine du dépôt).
diff --git a/pipeline/collecteurs/__init__.py b/pipeline/collecteurs/__init__.py
new file mode 100644
index 0000000..3fb00fd
--- /dev/null
+++ b/pipeline/collecteurs/__init__.py
@@ -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"]
diff --git a/pipeline/collecteurs/base.py b/pipeline/collecteurs/base.py
new file mode 100644
index 0000000..0868a13
--- /dev/null
+++ b/pipeline/collecteurs/base.py
@@ -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
diff --git a/pipeline/collecteurs/conseil_constitutionnel.py b/pipeline/collecteurs/conseil_constitutionnel.py
new file mode 100644
index 0000000..33dfa27
--- /dev/null
+++ b/pipeline/collecteurs/conseil_constitutionnel.py
@@ -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
diff --git a/pipeline/collecteurs/http.py b/pipeline/collecteurs/http.py
new file mode 100644
index 0000000..ca00d3e
--- /dev/null
+++ b/pipeline/collecteurs/http.py
@@ -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
diff --git a/pipeline/collecteurs/legifrance.py b/pipeline/collecteurs/legifrance.py
new file mode 100644
index 0000000..35724e5
--- /dev/null
+++ b/pipeline/collecteurs/legifrance.py
@@ -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)
diff --git a/pipeline/collecteurs/senat.py b/pipeline/collecteurs/senat.py
new file mode 100644
index 0000000..134dd63
--- /dev/null
+++ b/pipeline/collecteurs/senat.py
@@ -0,0 +1,130 @@
+"""Collecteur du Sénat — liste chronologique des lois promulguées.
+
+C'est le repli principal lorsque l'API Légifrance est indisponible, et un
+contrôle croisé lorsqu'elle fonctionne. La page rend, pour chaque loi
+promulguée de la session, son numéro, sa date, son intitulé complet et le lien
+vers son dossier législatif.
+
+L'adresse indiquée au §5.1 du cahier des charges — `senat.fr/lois/index.html` —
+renvoie une erreur 404 depuis la refonte du site. L'équivalent fonctionnel est
+`senat.fr/dossiers-legislatifs/lois-promulguees.html`, vérifié le 25 juillet
+2026 : 51 lois y figuraient, dont les 27 de la fenêtre de veille.
+"""
+
+from __future__ import annotations
+
+import re
+
+from selectolax.parser import HTMLParser
+
+from pipeline.collecteurs.base import Collecteur, ResultatCollecte, TexteCollecte
+from pipeline.collecteurs.http import ClientHttp
+from pipeline.journal import logger
+from pipeline.modeles import Confiance, Evenement, Source, Statut, Tier, TypeEtape, TypeTexte
+from pipeline.parseurs.dates_fr import lire_date
+
+log = logger("collecteur.senat")
+
+BASE = "https://www.senat.fr"
+URL_LOIS_PROMULGUEES = f"{BASE}/dossiers-legislatifs/lois-promulguees.html"
+
+# « Loi n° 2026-650 du 23 juillet 2026 relative au renforcement… »
+# « Loi organique n° 2026-410 du 28 mai 2026 … »
+_INTITULE = re.compile(
+ r"Loi\s+(organique\s+)?n°\s*(\d{4}-\d{1,4})\s+du\s+"
+ r"(\d{1,2}(?:er)?\s+\w+\.?\s+\d{4})\s+(.*)",
+ re.IGNORECASE | re.DOTALL,
+)
+
+
+class CollecteurSenat(Collecteur):
+ nom = "senat"
+
+ def __init__(self, client: ClientHttp, *, annees: tuple[int, ...] = (2026,)) -> None:
+ self.client = client
+ self.annees = annees
+
+ def _collecter(self) -> ResultatCollecte:
+ resultat = ResultatCollecte(collecteur=self.nom)
+
+ reponse = self.client.get(URL_LOIS_PROMULGUEES)
+ if not reponse.a_reussi:
+ raise RuntimeError(f"HTTP {reponse.code} sur {URL_LOIS_PROMULGUEES}")
+
+ document = HTMLParser(reponse.texte)
+ vus: set[str] = set()
+
+ for ancre in document.css("a"):
+ libelle = " ".join(ancre.text(separator=" ", strip=True).split())
+ texte = self._lire_intitule(libelle, ancre.attributes.get("href"))
+ if texte is None or texte.numero_officiel in vus:
+ continue
+ vus.add(texte.numero_officiel)
+ resultat.textes.append(texte)
+
+ log.debug("lois promulguées relevées", nombre=len(resultat.textes))
+ return resultat
+
+ def _lire_intitule(self, libelle: str, href: str | None) -> TexteCollecte | None:
+ correspondance = _INTITULE.search(libelle)
+ if not correspondance:
+ return None
+
+ organique = bool(correspondance.group(1))
+ numero = correspondance.group(2)
+ if int(numero.split("-")[0]) not in self.annees:
+ return None
+
+ date_promulgation = lire_date(correspondance.group(3))
+ if date_promulgation is None:
+ return None
+
+ # Le libellé se termine par un état (« parue », « en cours »)
+ # qu'il ne faut pas confondre avec l'objet de la loi.
+ objet = re.split(r"\s+(?:parue|en cours)\b", correspondance.group(4))[0].strip(" .;")
+ titre = (
+ f"LOI {'organique ' if organique else ''}n° {numero} "
+ f"du {correspondance.group(3)} {objet}"
+ )
+
+ url = _absolu(href) or URL_LOIS_PROMULGUEES
+
+ return TexteCollecte(
+ titre=" ".join(titre.split()),
+ source_url=url,
+ collecteur=self.nom,
+ numero_officiel=numero,
+ type=TypeTexte.LOI_ORGANIQUE if organique else TypeTexte.LOI,
+ statut=Statut.PROMULGUEE,
+ date_promulgation=date_promulgation,
+ evenements=[
+ Evenement(
+ date_evenement=date_promulgation,
+ type_etape=TypeEtape.PROMULGATION,
+ description=f"Loi promulguée n° {numero}, relevée sur la liste du Sénat",
+ source_url=url,
+ )
+ ],
+ sources=[
+ Source(
+ url=url,
+ titre=" ".join(titre.split())[:280],
+ editeur="Sénat",
+ date_publication=date_promulgation,
+ tier=Tier.T1,
+ confiance=Confiance.HIGH,
+ marqueur=f"senat-{numero}",
+ fichier_origine="collecteur:senat",
+ )
+ ],
+ )
+
+
+def _absolu(href: str | None) -> str | None:
+ if not href:
+ return None
+ if href.startswith("http"):
+ return href
+ if href.startswith("/"):
+ return BASE + href
+ return None
diff --git a/pipeline/rapprochement.py b/pipeline/rapprochement.py
new file mode 100644
index 0000000..aa07728
--- /dev/null
+++ b/pipeline/rapprochement.py
@@ -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"
+ )
diff --git a/pipeline/update.py b/pipeline/update.py
new file mode 100644
index 0000000..7ea4c2d
--- /dev/null
+++ b/pipeline/update.py
@@ -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()
diff --git a/tests/fixtures/cc_affaires_dc.html b/tests/fixtures/cc_affaires_dc.html
new file mode 100644
index 0000000..29730db
--- /dev/null
+++ b/tests/fixtures/cc_affaires_dc.html
@@ -0,0 +1,163 @@
+
\ No newline at end of file
diff --git a/tests/fixtures/cc_decisions.html b/tests/fixtures/cc_decisions.html
new file mode 100644
index 0000000..704b9d4
--- /dev/null
+++ b/tests/fixtures/cc_decisions.html
@@ -0,0 +1,345 @@
+
+
+
+
+
+
+
+
+
Décision n° 2026-908 DC du 23 juillet 2026
+
+
+
Loi organique relative au renforcement des juridictions criminelles
+
+
+
+
+
+
+
+
+
+
+
Décision n° 2026-909 DC du 23 juillet 2026
+
+
+
Loi sur la justice criminelle et le respect des victimes
+
+
+
+
Non conformité partielle - réserve
+
+
+
+
+
+
+
+
+
Décision n° 2026-906 DC du 23 juillet 2026
+
+
+
Loi visant à renforcer la sécurité, la rétention administrative et la prévention des risques d’attentat
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/senat_lois_promulguees.html b/tests/fixtures/senat_lois_promulguees.html
new file mode 100644
index 0000000..406f39a
--- /dev/null
+++ b/tests/fixtures/senat_lois_promulguees.html
@@ -0,0 +1,32 @@
+Loi n° 2026-650 du 23 juillet 2026 relative au renforcement des juridictions criminelles
+ parue au JO n°171 du 24 juillet 2026Loi n° 2026-651 du 23 juillet 2026 sur la justice criminelle et le respect des victimes
+ parue au JO n°171 du 24 juillet 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-534 du 25 juin 2026 relative à la lutte contre les fraudes sociales et fiscales
+ parue au JO n°148 du 26 juin 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-441 du 4 juin 2026 portant pérennisation du contrat de professionnalisation expérimental
+ parue au JO n°130 du 5 juin 2026Loi 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 2026Loi n° 2026-403 du 26 mai 2026 de simplification de la vie économique
+ parue au JO n°122 du 27 mai 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi 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 2026Loi n° 2026-103 du 19 février 2026 de finances pour 2026
+ parue au JO n°43 du 20 février 2026Loi 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
\ No newline at end of file
diff --git a/tests/test_acceptation.py b/tests/test_acceptation.py
index f5cd6d4..0eca545 100644
--- a/tests/test_acceptation.py
+++ b/tests/test_acceptation.py
@@ -212,7 +212,10 @@ def test_une_fiche_a_timeline_et_sources(base_complete) -> None:
def test_les_deductions_sont_signalees(base_complete) -> None:
"""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"
diff --git a/tests/test_collecteurs.py b/tests/test_collecteurs.py
new file mode 100644
index 0000000..c909da1
--- /dev/null
+++ b/tests/test_collecteurs.py
@@ -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
diff --git a/tests/test_update.py b/tests/test_update.py
new file mode 100644
index 0000000..72712c9
--- /dev/null
+++ b/tests/test_update.py
@@ -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