"""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