SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
7.3 KB · 171 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/base.py : classe de base des connecteurs + backends de fetch5#                      (requests direct, Firecrawl ou Scrapfly pour les sites6#                       JavaScript / derrière anti-bot)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              "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)")2122FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"23SCRAPFLY_API = "https://api.scrapfly.io/scrape"242526class BaseConnector:27    """Un connecteur = un adaptateur propre à un site d'agence.2829    Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne30    la liste complète des annonces actuellement affichées sur le site.31    Le pipeline (ingest.py) s'occupe du diff avec la base de données.32    """3334    source_id: str = ""35    request_delay: float = 0.6           # politesse entre requêtes36    timeout: int = 3037    use_detail_cache: bool = True        # cache BD des pages détail3839    def __init__(self) -> None:40        self.session = requests.Session()41        self.session.headers["User-Agent"] = USER_AGENT42        self._last_request = 0.043        self._detail_con = None4445    # -- backends -------------------------------------------------------------46    def get(self, url: str, **kw) -> requests.Response:47        """GET direct avec throttling poli."""48        wait = self.request_delay - (time.time() - self._last_request)49        if wait > 0:50            time.sleep(wait)51        resp = self.session.get(url, timeout=self.timeout, **kw)52        self._last_request = time.time()53        resp.raise_for_status()54        return resp5556    def post(self, url: str, **kw) -> requests.Response:57        """POST direct avec throttling poli (APIs de recherche internes)."""58        wait = self.request_delay - (time.time() - self._last_request)59        if wait > 0:60            time.sleep(wait)61        resp = self.session.post(url, timeout=self.timeout, **kw)62        self._last_request = time.time()63        resp.raise_for_status()64        return resp6566    def get_rendered(self, url: str) -> str:67        """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.6869        Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).70        À utiliser pour les sites SPA (Logisco, Locago, etc.).71        """72        key = os.environ.get("FIRECRAWL_API_KEY")73        if not key:74            raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")75        resp = requests.post(76            FIRECRAWL_API,77            json={"url": url, "formats": ["html"]},78            headers={"Authorization": f"Bearer {key}"},79            timeout=90,80        )81        resp.raise_for_status()82        data = resp.json()83        return (data.get("data") or {}).get("html", "")8485    def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,86                 rendering_wait: int = 0, country: str = "ca",87                 wait_for_selector: str | None = None,88                 js_scenario: list | str | None = None,89                 proxy_pool: str | None = None, headers: dict | None = None,90                 method: str = "GET", body: str | None = None) -> dict:91        """Appel Scrapfly complet — retourne le dict `result` (content, status_code…).9293        - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64)94          pour charger les listes virtualisées (BoldTrail/kvCORE, etc.).95        - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif).96        - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP.97        """98        import base6499        key = os.environ.get("SCRAPFLY_KEY")100        if not key:101            raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")102        params: dict = {"key": key, "url": url, "country": country}103        if asp:104            params["asp"] = "true"105        if render_js:106            params["render_js"] = "true"107        if rendering_wait:108            params["rendering_wait"] = rendering_wait109        if wait_for_selector:110            params["wait_for_selector"] = wait_for_selector111        if proxy_pool:112            params["proxy_pool"] = proxy_pool113        if js_scenario is not None:114            js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)115            params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()116        if headers:117            for k, v in headers.items():118                params[f"headers[{k}]"] = v119        wait = self.request_delay - (time.time() - self._last_request)120        if wait > 0:121            time.sleep(wait)122        if method.upper() == "POST":123            resp = requests.post(SCRAPFLY_API, params=params,124                                 data=(body or ""), timeout=180)125        else:126            resp = requests.get(SCRAPFLY_API, params=params, timeout=180)127        self._last_request = time.time()128        try:129            return resp.json().get("result") or {}130        except ValueError:131            return {}132133    def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,134                     rendering_wait: int = 0, country: str = "ca",135                     wait_for_selector: str | None = None,136                     js_scenario: list | str | None = None,137                     proxy_pool: str | None = None) -> str:138        """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne139        le HTML (result.content) ou "" en cas d'échec ASP."""140        return self.scrapfly(url, render_js=render_js, asp=asp,141                             rendering_wait=rendering_wait, country=country,142                             wait_for_selector=wait_for_selector,143                             js_scenario=js_scenario, proxy_pool=proxy_pool144                             ).get("content") or ""145146    def detail(self, external_id: str, key: str, fetch_fn) -> dict:147        """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si148        l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé.149150        Permet d'extraire les champs riches (description, inclusions, contact…)151        sans revisiter chaque page détail à chaque synchronisation.152        `fetch_fn` doit retourner un dict JSON-sérialisable.153        """154        if not self.use_detail_cache:155            return fetch_fn() or {}156        from .. import db157        if self._detail_con is None:158            self._detail_con = db.connect()159        cached = db.get_cached_detail(self._detail_con, self.source_id,160                                      str(external_id), key)161        if cached is not None:162            return cached163        payload = fetch_fn() or {}164        db.put_cached_detail(self._detail_con, self.source_id,165                             str(external_id), key, payload)166        return payload167168    # -- contrat --------------------------------------------------------------169    def fetch(self) -> list[Listing]:170        raise NotImplementedError171