SPB Git

spb/rent-ka

Public
10.8 KB · 240 lines python
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
Files · f2c4182817795a943b83ef8afc5f7a266e5277e7
Menu

Theme