SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
8.6 KB · 211 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/base.py : classe de base des connecteurs — GET/POST throttlés,5#                      identification honnête + backend Scrapfly pour les6#                      sources derrière anti-bot / JavaScript lourd7#                      (patron Lou-Ka : connectors/base.py). Les deux premières8#                      sources sont des données ouvertes → GET direct suffit.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json13import os14import time15from pathlib import Path1617import requests1819SCRAPFLY_API = "https://api.scrapfly.io/scrape"202122def _load_env() -> None:23    """Charge .env à la racine du projet (clés Scrapfly/Firecrawl), sans dépendance."""24    env_path = Path(__file__).resolve().parents[2] / ".env"25    if not env_path.exists():26        return27    for line in env_path.read_text(encoding="utf-8").splitlines():28        line = line.strip()29        if not line or line.startswith("#") or "=" not in line:30            continue31        k, v = line.split("=", 1)32        os.environ.setdefault(k.strip(), v.strip())333435_load_env()3637from ..schema import Event3839# Identification honnête (nom + site + contact). Note : le WAF de40# donnees.montreal.ca renvoie 403 à tout User-Agent contenant « bot » —41# on s'identifie donc par le nom du produit, avec URL et courriel de contact.42USER_AGENT = "SortiKa/1.0 (+https://www.sorti-ka.com; contact@spboucher.ai)"434445class BaseConnector:46    """Un connecteur = un adaptateur propre à une source d'événements.4748    Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne49    la liste complète des événements actuellement publiés par la source.50    Le pipeline (ingest.py) s'occupe du diff avec la base de données.51    """5253    source_id: str = ""54    request_delay: float = 0.6           # politesse entre requêtes55    timeout: int = 605657    def __init__(self) -> None:58        self.session = requests.Session()59        self.session.headers["User-Agent"] = USER_AGENT60        self._last_request = 0.06162    def _throttle(self) -> None:63        wait = self.request_delay - (time.time() - self._last_request)64        if wait > 0:65            time.sleep(wait)6667    def get(self, url: str, **kw) -> requests.Response:68        """GET direct avec throttling poli."""69        self._throttle()70        resp = self.session.get(url, timeout=self.timeout, **kw)71        self._last_request = time.time()72        resp.raise_for_status()73        return resp7475    def get_json(self, url: str, **kw):76        return self.get(url, **kw).json()7778    def get_rendered(self, url: str, render_js: bool = True) -> str:79        """HTML rendu via Scrapfly (anti-bot / JavaScript lourd).8081        Nécessite SCRAPFLY_API_KEY dans l'environnement (.env). À réserver aux82        sources qui bloquent le GET direct — jamais pour les données ouvertes.83        """84        key = os.environ.get("SCRAPFLY_API_KEY")85        if not key:86            raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")87        self._throttle()88        resp = requests.get(SCRAPFLY_API, params={89            "key": key, "url": url, "country": "ca",90            "render_js": str(render_js).lower(), "asp": "true",91        }, timeout=120)92        self._last_request = time.time()93        resp.raise_for_status()94        return resp.json()["result"]["content"]9596    # -- cache disque des fiches détail (connecteurs incrémentaux) ------------97    # Patron Lou-Ka (cache des pages détail) : les sources dont chaque fiche98    # coûte une requête (Scrapfly ou volume) ne re-scrapent que le neuf.99    @property100    def _cache_path(self) -> "Path":101        return Path(__file__).resolve().parents[2] / "data" / f"{self.source_id}_cache.json"102103    def load_cache(self) -> dict:104        try:105            return json.loads(self._cache_path.read_text(encoding="utf-8"))106        except Exception:107            return {}108109    def save_cache(self, cache: dict) -> None:110        self._cache_path.parent.mkdir(parents=True, exist_ok=True)111        self._cache_path.write_text(json.dumps(cache, ensure_ascii=False),112                                    encoding="utf-8")113114    # -- fetch parallèle poli (enrichissements et backfills) -------------------115    def fetch_many(self, urls: list[str], worker, max_workers: int = 6) -> dict:116        """Applique `worker(url) -> valeur` sur chaque URL avec un pool de117        threads (chaque thread a sa propre session). Retourne {url: valeur} ;118        les exceptions donnent None. Concurrence modérée = politesse.119        """120        import threading121        from concurrent.futures import ThreadPoolExecutor122123        local = threading.local()124125        def _run(url):126            if not hasattr(local, "session"):127                local.session = requests.Session()128                local.session.headers["User-Agent"] = USER_AGENT129            try:130                return url, worker(url, local.session)131            except Exception:132                return url, None133134        out: dict = {}135        with ThreadPoolExecutor(max_workers=max_workers) as pool:136            for url, val in pool.map(_run, urls):137                out[url] = val138        return out139140    # -- interface ------------------------------------------------------------141    def fetch(self) -> list[Event]:  # pragma: no cover - interface142        raise NotImplementedError143144145# =============================================================================146# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.147# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage148# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne149# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir150# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).151# =============================================================================152if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):153    import requests as _ka_requests  # noqa: E402154    from . import _resilient as _kar  # noqa: E402155156    _ka_orig_get = BaseConnector.get157158    def _ka_full_url(url, kw):159        try:160            return _ka_requests.Request("GET", url,161                                        params=kw.get("params")).prepare().url162        except Exception:  # noqa: BLE001163            return url164165    def _ka_resilient_get(self, url, **kw):166        timeout = getattr(self, "timeout", 30)167        headers = kw.get("headers")168        try:169            return _ka_orig_get(self, url, **kw)170        except _ka_requests.HTTPError as exc:171            r = getattr(exc, "response", None)172            if r is not None and _kar.is_blocked(r):173                target = getattr(r, "url", None) or _ka_full_url(url, kw)174                better = _kar.escalate_if_blocked(175                    r, target, timeout=timeout, headers=headers)176                if better is not None and getattr(better, "status_code", 0) == 200:177                    return better178            raise179        except (_ka_requests.ConnectionError, _ka_requests.Timeout):180            better = _kar.escalate(_ka_full_url(url, kw),181                                   timeout=timeout, headers=headers)182            if better is not None and getattr(better, "status_code", 0) == 200:183                return better184            raise185186    def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):187        """Fetch anti-bot explicite : force la chaîne de secours au besoin.188189        Comme get() mais tente d'abord le direct puis escalade même sur 200-190        challenge, avec rendu JS optionnel. Renvoie une réponse compatible191        requests (.text/.content/.status_code/.json()...).192        """193        timeout = getattr(self, "timeout", 30)194        headers = kw.get("headers")195        try:196            resp = _ka_orig_get(self, url, **kw)197        except _ka_requests.HTTPError as exc:198            resp = getattr(exc, "response", None)199        except (_ka_requests.ConnectionError, _ka_requests.Timeout):200            resp = None201        target = _ka_full_url(url, kw)202        if resp is not None and getattr(resp, "url", None):203            target = resp.url204        return _kar.escalate_if_blocked(resp, target, timeout=timeout,205                                        country=country, render_js=render_js,206                                        headers=headers)207208    BaseConnector.get = _ka_resilient_get209    BaseConnector.get_resilient = _ka_get_resilient210    BaseConnector._KA_RESILIENT_WRAPPED = True211