Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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