Files
Cyber MawonajandClaude Opus 5 feb5544599 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>
2026-07-25 21:48:56 -04:00

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