SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.5 KB · 203 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/immobilia.py : connecteur Immobilia Rimouski (immobiliarimouski.com)5#   Gestionnaire du centre-ville de Rimouski (ex-Immeubles MT, ~100 logements).6#   Site WordPress (Bedrock) + plugin « building-stack-sync » : l'inventaire7#   Building Stack est synchronisé dans le CPT WordPress `bss_property` et8#   rendu côté serveur — famille Building Stack (edifia, urban_services) mais9#   via le DOM WP plutôt que le portail hébergé.10#     - /logements/ : une carte par unité disponible (a.bss-property-item :11#       titre, adresse complète, type (4½/Loft…), sdb, prix, date de dispo) ;12#     - fiche /proprietes/<slug>/ : statut (« Disponible à partir du: … »),13#       bloc infos (prix « X$ par mois », type, sdb, pi², plan PDF),14#       description complète, commodités (feature-list), galerie d'images,15#       lien Google Maps (adresse seulement, pas de GPS).16#   Une annonce Lou-Ka = une unité. Fiches via self.detail() (cache BD,17#   invalidé quand la carte de la liste change), max max_details/sync.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://immobiliarimouski.com"30LIST_URL = f"{BASE}/logements/"313233def _clean(s: str) -> str:34    return re.sub(r"\s+", " ", s or "").strip()353637class ImmobiliaConnector(BaseConnector):38    source_id = "immobilia"39    request_delay = 0.640    max_details = 30     # garde-fou fiches propriété (vraies requêtes par sync)41    max_images = 154243    def fetch(self) -> list[Listing]:44        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")45        self._fetched = 046        listings: list[Listing] = []47        for card in soup.select("a.bss-property-item[href]"):48            try:49                lst = self._parse_card(card)50                if lst:51                    listings.append(lst)52            except Exception:53                continue54        return listings5556    # -- carte de la liste /logements/ ---------------------------------------------57    def _parse_card(self, card) -> Listing | None:58        url = card["href"]59        slug = url.rstrip("/").rsplit("/", 1)[-1]60        if not slug:61            return None6263        title_el = card.select_one(".property-title")64        title = _clean(title_el.get_text(" ", strip=True)) if title_el else slug65        addr_el = card.select_one(".address")66        address = _clean(addr_el.get_text(" ", strip=True)) if addr_el else ""67        # « 201 - 149 Rue de l'Évêché Ouest, Rimouski, QC, Canada »68        parts = [p.strip() for p in address.split(",") if p.strip()]69        city = ""70        for i, p in enumerate(parts):71            if p.upper() == "QC" and i >= 1:72                city = parts[i - 1]73                break7475        # type (4½, Loft…) et sdb : icônes structurées de la carte76        unit_type_raw, bath = "", ""77        for w in card.select(".features .info-wrapper"):78            val = _clean(w.select_one(".info-value").get_text(" ", strip=True)79                         if w.select_one(".info-value") else "")80            if w.select_one(".fa-building"):81                unit_type_raw = val82            elif w.select_one(".fa-bath"):83                bath = val8485        price_el = card.select_one(".price-box .price")86        price_txt = _clean(price_el.get_text(" ", strip=True)) if price_el else ""87        avail_el = card.select_one(".availability-tag")88        card_avail = _clean(avail_el.get_text(" ", strip=True)) if avail_el else ""8990        # fiche propriété (cache BD, invalidée quand la carte change)91        card_sig = f"{address}|{unit_type_raw}|{bath}|{price_txt}|{card_avail}"92        cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest()9394        def fetch_fn():95            if self._fetched >= self.max_details:96                raise RuntimeError("budget de fiches propriété atteint")97            self._fetched += 198            return self._fetch_property(url)99100        payload = self.detail(slug, cache_key, fetch_fn)101102        amenities = list(payload.get("features", []))103        if bath:104            amenities.insert(0, f"{bath} salle(s) de bain")105106        details: dict = {}107        if payload.get("floorplan"):108            details["floorplan"] = payload["floorplan"]109        if payload.get("maps_url"):110            details["maps_url"] = payload["maps_url"]111112        # prix : la carte affiche le montant nu (« 900 ») -> la fiche fait foi113        price_label = payload.get("price_label") or ""114        price = parse_price(price_label)115        if price is None and re.fullmatch(r"[\d\s  ]+", price_txt or ""):116            price = parse_price(price_txt + " $")117            price_label = price_label or f"{price_txt} $"118119        area = None120        try:121            v = float(payload.get("area") or 0)122            if 80 <= v <= 20000:123                area = v124        except (TypeError, ValueError):125            pass126127        return Listing(128            source=self.source_id,129            external_id=slug,130            url=url,131            title=title,132            address=address,133            sector="",134            city=city or "Rimouski",135            unit_type=normalize_unit_type(payload.get("unit_type")136                                          or unit_type_raw),137            price=price,138            price_label=price_label,139            availability=payload.get("status") or card_avail,140            area_sqft=area,141            description=payload.get("description", ""),142            amenities=amenities,143            details=details,144            images=payload.get("images", [])[: self.max_images],145        )146147    # -- fiche /proprietes/<slug>/ ----------------------------------------------------148    def _fetch_property(self, url: str) -> dict:149        """Payload JSON-sérialisable : infos structurées de la fiche."""150        soup = BeautifulSoup(self.get(url).text, "html.parser")151        payload: dict = {}152153        # statut (« Disponible à partir du: 15 août 2026 », « Disponible »)154        status = soup.select_one(".property-status")155        if status:156            payload["status"] = _clean(status.get_text(" ", strip=True))157158        # bloc « general-info » : prix / type / sdb / superficie / plan PDF159        for col in soup.select(".general-info .info-col"):160            val = _clean(col.get_text(" ", strip=True))161            if col.select_one(".fa-dollar-sign"):162                payload["price_label"] = val            # « 900$ par mois »163            elif col.select_one(".fa-building"):164                payload["unit_type"] = val              # « Loft », « 4 1/2 »165            elif col.select_one(".fa-ruler"):166                m = re.search(r"([\d\s  ]+)\s*pi", val)167                if m:168                    payload["area"] = re.sub(r"[\s  ]", "", m.group(1))169            if col.name == "a" and col.get("href", "").lower().endswith(".pdf"):170                payload["floorplan"] = col["href"]171172        # description complète (paragraphes et puces, ordre du DOM)173        desc = soup.select_one(".bss-property-container .description")174        if desc:175            payload["description"] = _clean(176                desc.get_text(" ", strip=True))[:2000]177178        # commodités (« Accès à internet inclus », « Réfrigérateur »…)179        features: list[str] = []180        for f in soup.select(".feature-list .feature"):181            t = _clean(f.get_text(" ", strip=True))182            if 2 <= len(t) <= 90 and t not in features:183                features.append(t)184        payload["features"] = features185186        # galerie (carrousel de la visionneuse, URLs absolues, dédupliquées)187        images: list[str] = []188        for img in soup.select(".simple-carousel-slide img[data-src]"):189            u = img["data-src"]190            if u.startswith("http") and u not in images:191                images.append(u)192        if not images:      # repli : image principale de la fiche193            m = re.search(r"data-bg-image=\"url\('([^']+)'\)", str(soup))194            if m:195                images.append(m.group(1))196        payload["images"] = images197198        # lien Google Maps (adresse complète avec code postal — pas de GPS)199        maps = soup.select_one('a.under-map-button[href^="http"]')200        if maps:201            payload["maps_url"] = maps["href"]202        return payload203