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
|
||||
Reference in New Issue
Block a user