1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/base.py : classe de base des connecteurs + backends de fetch5# (requests direct, ou Scrapfly pour les sites JavaScript6# / derrière anti-bot — Firecrawl retiré le 2026-08-27)7# -----------------------------------------------------------------------------8from __future__ import annotations910import json11import os12import time1314import requests1516from ..schema import Listing1718USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "19 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "20 "RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)")2122SCRAPFLY_API = "https://api.scrapfly.io/scrape"232425class BaseConnector:26 """Un connecteur = un adaptateur propre à un site d'agence.2728 Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne29 la liste complète des annonces actuellement affichées sur le site.30 Le pipeline (ingest.py) s'occupe du diff avec la base de données.31 """3233 source_id: str = ""34 request_delay: float = 0.6 # politesse entre requêtes35 timeout: int = 3036 use_detail_cache: bool = True # cache BD des pages détail37 disabled: bool = False # True = source hors-ligne : le module est38 # conservé mais exclu du registre (connectors/__init__.py) et donc de l'ingestion3940 def __init__(self) -> None:41 self.session = requests.Session()42 self.session.headers["User-Agent"] = USER_AGENT43 self._last_request = 0.044 self._detail_con = None4546 # -- backends -------------------------------------------------------------47 def get(self, url: str, **kw) -> requests.Response:48 """GET direct avec throttling poli."""49 wait = self.request_delay - (time.time() - self._last_request)50 if wait > 0:51 time.sleep(wait)52 resp = self.session.get(url, timeout=self.timeout, **kw)53 self._last_request = time.time()54 resp.raise_for_status()55 return resp5657 def post(self, url: str, **kw) -> requests.Response:58 """POST direct avec throttling poli (APIs de recherche internes)."""59 wait = self.request_delay - (time.time() - self._last_request)60 if wait > 0:61 time.sleep(wait)62 resp = self.session.post(url, timeout=self.timeout, **kw)63 self._last_request = time.time()64 resp.raise_for_status()65 return resp6667 def get_rendered(self, url: str, wait_ms: int = 0) -> str:68 """Récupère le HTML rendu (JavaScript exécuté) via Scrapfly.6970 Migré de Firecrawl le 2026-08-27 (même contrat : `wait_ms` équivaut à71 l'ancien `waitFor` Firecrawl). Les fixtures (record/replay) remplacent72 cette méthode par son nom — la clé de rejeu reste l'URL cible.73 Retry court sur contenu vide (l'équivalent des 502 transitoires74 Firecrawl vus 2026-08-21/27), puis échec HONNÊTE : mieux vaut faire75 échouer le sync que dépublier l'inventaire sur une page vide.76 """77 for attempt in range(3):78 html = self.get_scrapfly(url, render_js=True, asp=True,79 rendering_wait=wait_ms)80 if html:81 return html82 if attempt < 2:83 time.sleep(5 * (attempt + 1))84 raise RuntimeError(f"Scrapfly : rendu vide après 3 tentatives — {url}")8586 def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,87 rendering_wait: int = 0, country: str = "ca",88 wait_for_selector: str | None = None,89 js_scenario: list | str | None = None,90 proxy_pool: str | None = None, headers: dict | None = None,91 method: str = "GET", body: str | None = None) -> dict:92 """Appel Scrapfly complet — retourne le dict `result` (content, status_code…).9394 - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64)95 pour charger les listes virtualisées (BoldTrail/kvCORE, etc.).96 - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif).97 - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP.98 """99 import base64100 key = os.environ.get("SCRAPFLY_KEY")101 if not key:102 raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")103 params: dict = {"key": key, "url": url, "country": country}104 if asp:105 params["asp"] = "true"106 if render_js:107 params["render_js"] = "true"108 if rendering_wait:109 params["rendering_wait"] = rendering_wait110 if wait_for_selector:111 params["wait_for_selector"] = wait_for_selector112 if proxy_pool:113 params["proxy_pool"] = proxy_pool114 if js_scenario is not None:115 js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)116 params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()117 if headers:118 for k, v in headers.items():119 params[f"headers[{k}]"] = v120 wait = self.request_delay - (time.time() - self._last_request)121 if wait > 0:122 time.sleep(wait)123 if method.upper() == "POST":124 resp = requests.post(SCRAPFLY_API, params=params,125 data=(body or ""), timeout=180)126 else:127 resp = requests.get(SCRAPFLY_API, params=params, timeout=180)128 self._last_request = time.time()129 try:130 return resp.json().get("result") or {}131 except ValueError:132 return {}133134 def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,135 rendering_wait: int = 0, country: str = "ca",136 wait_for_selector: str | None = None,137 js_scenario: list | str | None = None,138 proxy_pool: str | None = None) -> str:139 """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne140 le HTML (result.content) ou "" en cas d'échec ASP."""141 return self.scrapfly(url, render_js=render_js, asp=asp,142 rendering_wait=rendering_wait, country=country,143 wait_for_selector=wait_for_selector,144 js_scenario=js_scenario, proxy_pool=proxy_pool145 ).get("content") or ""146147 def detail(self, external_id: str, key: str, fetch_fn) -> dict:148 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si149 l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.150151 Permet d'extraire les champs riches (description, inclusions, contact…)152 sans revisiter chaque page détail à chaque synchronisation.153 `fetch_fn` doit retourner un dict JSON-sérialisable.154 """155 if not self.use_detail_cache:156 return fetch_fn() or {}157 from .. import db158 if self._detail_con is None:159 self._detail_con = db.connect()160 cached = db.get_cached_detail(self._detail_con, self.source_id,161 str(external_id), key)162 if cached is not None:163 return cached164 payload = fetch_fn() or {}165 db.put_cached_detail(self._detail_con, self.source_id,166 str(external_id), key, payload)167 return payload168169 # -- contrat --------------------------------------------------------------170 def fetch(self) -> list[Listing]:171 raise NotImplementedError172173174# =============================================================================175# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.176# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage177# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne178# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir179# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).180# =============================================================================181if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):182 import requests as _ka_requests # noqa: E402183 from . import _resilient as _kar # noqa: E402184185 _ka_orig_get = BaseConnector.get186187 def _ka_full_url(url, kw):188 try:189 return _ka_requests.Request("GET", url,190 params=kw.get("params")).prepare().url191 except Exception: # noqa: BLE001192 return url193194 def _ka_resilient_get(self, url, **kw):195 timeout = getattr(self, "timeout", 30)196 headers = kw.get("headers")197 try:198 return _ka_orig_get(self, url, **kw)199 except _ka_requests.HTTPError as exc:200 r = getattr(exc, "response", None)201 if r is not None and _kar.is_blocked(r):202 target = getattr(r, "url", None) or _ka_full_url(url, kw)203 better = _kar.escalate_if_blocked(204 r, target, timeout=timeout, headers=headers)205 if better is not None and getattr(better, "status_code", 0) == 200:206 return better207 raise208 except (_ka_requests.ConnectionError, _ka_requests.Timeout):209 better = _kar.escalate(_ka_full_url(url, kw),210 timeout=timeout, headers=headers)211 if better is not None and getattr(better, "status_code", 0) == 200:212 return better213 raise214215 def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):216 """Fetch anti-bot explicite : force la chaîne de secours au besoin.217218 Comme get() mais tente d'abord le direct puis escalade même sur 200-219 challenge, avec rendu JS optionnel. Renvoie une réponse compatible220 requests (.text/.content/.status_code/.json()...).221 """222 timeout = getattr(self, "timeout", 30)223 headers = kw.get("headers")224 try:225 resp = _ka_orig_get(self, url, **kw)226 except _ka_requests.HTTPError as exc:227 resp = getattr(exc, "response", None)228 except (_ka_requests.ConnectionError, _ka_requests.Timeout):229 resp = None230 target = _ka_full_url(url, kw)231 if resp is not None and getattr(resp, "url", None):232 target = resp.url233 return _kar.escalate_if_blocked(resp, target, timeout=timeout,234 country=country, render_js=render_js,235 headers=headers)236237 BaseConnector.get = _ka_resilient_get238 BaseConnector.get_resilient = _ka_get_resilient239 BaseConnector._KA_RESILIENT_WRAPPED = True240