SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.2 KB · 157 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/gestion_fauvel.py : connecteur Gestion Fauvel (gestionfauvel.com)5#   WordPress + Elementor + JetEngine. La page /logements-a-louer/ expose une6#   grille .jet-listing-grid__item : data-post-id (id stable), data-url,7#   en-têtes (disponibilité, prix, titre) et terme JetEngine = ville réelle.8#   Fiches détail (cache BD) : adresse civique, description complète (unités9#   dispo + inclusions) et galerie photos (carrousel Elementor).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://gestionfauvel.com"22LIST_URL = f"{BASE}/logements-a-louer/"2324# adresse civique : « 555, rue des Écoles, app. 105 Drummondville »25_ADDR_RE = re.compile(26    r"^\d+[\s,]+.*\b(rue|boul(?:evard|\.)?|avenue|av\.|chemin|carr[ée]|place|"27    r"c[ôo]te|mont[ée]e|rang)\b", re.I)28_IMG_EXT_RE = re.compile(r"\.(?:jpe?g|png|webp)$", re.I)29_UNIT_RE = re.compile(r"^(?:\d½\+?|Studio|Loft|Maison)$")303132class GestionFauvelConnector(BaseConnector):33    source_id = "gestion_fauvel"34    request_delay = 0.635    max_details = 25   # garde-fou fiches détail (vraies requêtes)3637    def fetch(self) -> list[Listing]:38        listings: dict[str, Listing] = {}39        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")40        for card in soup.select(".jet-listing-grid__item"):41            try:42                self._parse_card(card, listings)43            except Exception:44                continue4546        # fiches détail (cache BD) : adresse, description, photos47        self._fetched = 048        for lst in listings.values():49            card_key = hashlib.sha1(50                f"{lst.title}|{lst.price_label}|{lst.availability}"51                .encode("utf-8")).hexdigest()52            try:53                payload = self.detail(lst.external_id, card_key,54                                      lambda u=lst.url: self._fetch_detail(u))55            except Exception:56                continue57            self._apply_detail(lst, payload)58        return list(listings.values())5960    # -- carte JetEngine ------------------------------------------------------61    def _parse_card(self, card, listings: dict[str, Listing]) -> None:62        ext_id = card.get("data-post-id", "")63        overlay = card.select_one(".jet-engine-listing-overlay-wrap[data-url]")64        url = overlay.get("data-url") if overlay else ""65        if not ext_id or not url or ext_id in listings:66            return6768        heads = [h.get_text(" ", strip=True)69                 for h in card.select(".elementor-heading-title")70                 if h.get_text(strip=True)]71        if not heads:72            return73        title = re.sub(r"\s+", " ", heads[-1])          # le titre ferme la carte74        price_label = next((h for h in heads[:-1] if "$" in h), "")75        avail_parts = [h for h in heads[:-1] if h != price_label]76        availability = " ".join(avail_parts).strip()7778        # exclusions : immeubles complets, volet commercial79        if re.search(r"complet", availability + " " + title, re.I):80            return81        if re.search(r"commercial|bureau|local|entrep[ôo]t|stationnement",82                     title, re.I):83            return8485        terms = card.select_one(".jet-listing-dynamic-terms")86        city = terms.get_text(" ", strip=True) if terms else ""8788        unit_type = normalize_unit_type(title)89        if not _UNIT_RE.fullmatch(unit_type or ""):90            unit_type = ""      # titre sans format d'unité (ex. « Condos locatifs »)9192        images = []93        img = card.select_one("img[src]")94        if img and img["src"].startswith("http"):95            images = [img["src"]]9697        listings[str(ext_id)] = Listing(98            source=self.source_id,99            external_id=str(ext_id),100            url=url,101            title=title,102            city=city,103            unit_type=unit_type,104            price=parse_price(price_label),105            price_label=price_label,106            availability=availability,107            images=images,108        )109110    # -- fiche détail (Elementor) ----------------------------------------------111    def _fetch_detail(self, url: str) -> dict:112        """Adresse civique (en-tête h3), description (bloc après le h2113        « Description ») et galerie photos (liens pleine taille du carrousel)."""114        if self._fetched >= self.max_details:115            raise RuntimeError("budget de fiches détail atteint")116        self._fetched += 1117        soup = BeautifulSoup(self.get(url).text, "html.parser")118        out: dict = {}119120        for h3 in soup.select("h3.elementor-heading-title"):121            txt = h3.get_text(" ", strip=True)122            if _ADDR_RE.match(txt):123                out["address"] = txt124                break125126        desc_h2 = next((h for h in soup.select("h2")127                        if h.get_text(strip=True).lower() == "description"), None)128        if desc_h2:129            parts: list[str] = []130            for el in desc_h2.find_all_next(["p", "li", "h2"]):131                if el.name == "h2":132                    break133                t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))134                if t and t not in parts:135                    parts.append(t)136            if parts:137                out["description"] = "\n".join(parts)[:2000]138139        images: list[str] = []140        for a in soup.select(".elementor-widget-image-carousel a[href]"):141            u = a["href"]142            if u.startswith("http") and _IMG_EXT_RE.search(u) and u not in images:143                images.append(u)144        out["images"] = images[:30]145        return out146147    def _apply_detail(self, lst: Listing, d: dict) -> None:148        """Reporte le payload (frais/cache) sur l'annonce."""149        if not d:150            return151        if d.get("address"):152            lst.address = d["address"]153        if d.get("description"):154            lst.description = d["description"]155        if d.get("images"):156            lst.images = list(dict.fromkeys(d["images"] + lst.images))[:30]157