SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 KB · 197 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_laprise.py : connecteur Gestion Laprise5#   (gestionlaprise.com/immobilier — Baie-Comeau et Haute-Côte-Nord, ~706#   appartements, location 31 jours et plus). WordPress + thème Inspiry Real7#   Places (plugin inspiry-real-estate), tout rendu serveur. Archive8#   /logement-statut/a-louer/ (paginée) : cartes `article.property-listing-simple`9#   — titre, adresse complète, prix (« $850 Par mois »), meta Bedrooms/10#   Bathrooms/Type (1½–6½)/Status. Fiche /propriete/<immeuble>/<unité>/ (via11#   cache BD) : description, caractéristiques, GPS (propertyMapData) et galerie12#   pleine taille (envira-gallery) — le carrousel « propriétés similaires »13#   (owl-carousel) est ignoré. Unités hôtelières (statut « Hôtel ») et locaux14#   commerciaux exclus. Pas de robots.txt (= tout permis).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://gestionlaprise.com/immobilier"28LIST_URL = f"{BASE}/logement-statut/a-louer/"29MAX_PAGES = 83031_MAP_DATA_RE = re.compile(r"propertyMapData\s*=\s*(\{.*?\});", re.S)323334def _clean_price_label(label: str) -> str:35    """'$1,050 Par mois' -> '$1050 Par mois' compatible parse_price."""36    return re.sub(r"(\d),(\d{3})", r"\1\2", label)373839class GestionLapriseConnector(BaseConnector):40    source_id = "gestion_laprise"41    request_delay = 0.642    max_details = 30     # garde-fou fiches détail (vraies requêtes par sync)4344    def fetch(self) -> list[Listing]:45        self._fetched = 046        listings: dict[str, Listing] = {}47        for page in range(1, MAX_PAGES + 1):48            url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"49            try:50                html = self.get(url).text51            except Exception:52                break                      # /page/N/ inexistante -> 404, fin53            soup = BeautifulSoup(html, "html.parser")54            cards = soup.select("article.property-listing-simple")55            if not cards:56                break57            for card in cards:58                try:59                    self._parse_card(card, listings)60                except Exception:61                    continue62        return list(listings.values())6364    # -- carte (archive Inspiry Real Places) -------------------------------------65    def _parse_card(self, card, listings: dict[str, Listing]) -> None:66        link = card.select_one('h3.entry-title a[href*="/propriete/"]')67        if not link:68            return69        url = link["href"]70        title = link.get_text(strip=True)71        m = re.search(r"/propriete/([^/?#]+)/([^/?#]+)", url)72        if not m:73            return74        ext_id = f"{m.group(1)}--{m.group(2)}"75        if ext_id in listings:76            return7778        # meta structurée : Bedrooms / Bathrooms / Type / Status79        meta: dict[str, str] = {}80        for item in card.select(".property-meta .meta-item"):81            label = item.select_one(".meta-item-label")82            value = item.select_one(".meta-item-value")83            if label and value:84                meta[label.get_text(strip=True).lower()] = value.get_text(strip=True)85        unit_label = meta.get("type", "")86        status = meta.get("status", "")8788        # exclusions : unités hôtelières (nuitée) et locaux commerciaux89        if re.search(r"h[oô]tel", f"{status} {title}", re.I):90            return91        if re.search(r"local|commercial|bureau", unit_label, re.I):92            return9394        addr_el = card.select_one("p.property-address")95        address = re.sub(r"\s+", " ",96                         addr_el.get_text(" ", strip=True)) if addr_el else ""97        # ville : « 112 Avenue le Gardeur, Baie-Comeau, QC G4Z 1H8, Canada »98        city = ""99        parts = [p.strip() for p in address.split(",")]100        if len(parts) >= 3:101            city = parts[-3]102103        price_el = card.select_one(".price-wrapper .price")104        postfix_el = card.select_one(".price-wrapper .postfix-text")105        price_label = price_el.get_text(strip=True) if price_el else ""106        if price_label and postfix_el and postfix_el.get_text(strip=True):107            price_label += f" {postfix_el.get_text(strip=True)}"108109        amenities: list[str] = []110        if meta.get("bedrooms"):111            amenities.append(f"{meta['bedrooms']} chambre(s)")112        if meta.get("bathrooms"):113            amenities.append(f"{meta['bathrooms']} salle(s) de bain")114115        images: list[str] = []116        thumb = card.select_one(".property-thumbnail img[src]")117        if thumb:118            images.append(thumb["src"])119120        lst = Listing(121            source=self.source_id,122            external_id=ext_id,123            url=url,124            title=title,125            address=address,126            city=city,127            unit_type=normalize_unit_type(unit_label),128            price=parse_price(_clean_price_label(price_label)),129            price_label=price_label,130            availability=status,           # « À Louer » (statut brut du site)131            amenities=amenities,132            images=images,133        )134135        key = hashlib.sha1(136            f"{title}|{price_label}|{status}|{unit_label}".encode("utf-8")137        ).hexdigest()138        try:139            payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u))140            self._apply_detail(lst, payload)141        except Exception:142            pass143        listings[ext_id] = lst144145    # -- fiche détail (/propriete/<immeuble>/<unité>/) ------------------------------146    def _fetch_detail(self, url: str) -> dict:147        """Description, caractéristiques, GPS et galerie envira pleine taille."""148        if self._fetched >= self.max_details:149            raise RuntimeError("budget de fiches détail atteint")150        self._fetched += 1151        html = self.get(url).text152        soup = BeautifulSoup(html, "html.parser")153        out: dict = {}154155        desc_el = soup.select_one(".entry-content")156        if desc_el:157            txt = desc_el.get_text("\n", strip=True)158            txt = re.sub(r"^Description\n?", "", txt)159            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]160161        out["features"] = [162            re.sub(r"\s+", " ", li.get_text(" ", strip=True))163            for li in soup.select(".property-features li")164            if li.get_text(strip=True)][:20]165166        # GPS : propertyMapData = {"lat":"49.23...","lang":"-68.15..."}167        m = _MAP_DATA_RE.search(html)168        if m:169            try:170                data = json.loads(m.group(1))171                out["lat"] = float(data.get("lat"))172                out["lng"] = float(data.get("lang"))173            except (TypeError, ValueError):174                pass175176        # galerie envira : liens vers les images pleine taille (« -scaled »)177        images: list[str] = []178        for a in soup.select('.envira-gallery-wrap a[href*="/uploads/"]'):179            u = a["href"]180            if u.startswith("http") and u not in images:181                images.append(u)182        out["images"] = images[:25]183        return out184185    def _apply_detail(self, lst: Listing, d: dict) -> None:186        """Reporte le payload (frais ou en cache) sur l'annonce."""187        if not d:188            return189        if d.get("description"):190            lst.description = d["description"]191        if d.get("features"):192            lst.amenities = list(dict.fromkeys(lst.amenities + d["features"]))193        if d.get("lat") is not None and d.get("lng") is not None:194            lst.lat, lst.lng = d["lat"], d["lng"]195        if d.get("images") and len(d["images"]) > len(lst.images):196            lst.images = d["images"]197