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:
co-authored by
Claude Opus 5
parent
c9fc8f3f67
commit
feb5544599
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user