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>
109 lines
3.5 KiB
Python
109 lines
3.5 KiB
Python
"""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
|