SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.9 KB · 215 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/sibelanger.py : connecteur Société immobilière Bélanger5#   (sibelanger.com) — page /appartements-a-louer/ : cartes d'unités avec6#   prix, « Disponible dès… », secteur, commodités et carrousel de photos.7#   Pages détail (cache BD self.detail) : adresse complète avec code postal,8#   superficie (« Plan de l'unité … pi2 »), description riche, commodités9#   de l'appartement et de l'immeuble, galerie complète.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, infer_city, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://sibelanger.com"22LIST_URL = f"{BASE}/appartements-a-louer/"2324IMG_RE = re.compile(25    r"https://sibelanger\.com/app/uploads/[^\"'\\\s\)]+"26    r"\.(?:jpg|jpeg|png|webp)", re.I)27IMG_NOISE_RE = re.compile(r"logo|favicon|icon|sib_ico", re.I)282930class SibelangerConnector(BaseConnector):31    source_id = "sibelanger"32    request_delay = 0.633    max_details = 60             # garde-fou3435    def fetch(self) -> list[Listing]:36        html = self.get(LIST_URL).text37        soup = BeautifulSoup(html, "html.parser")3839        listings: dict[str, Listing] = {}40        for card in soup.select("div.listing__thumbnail"):41            try:42                lst = self._parse_card(card)43            except Exception:44                continue45            if lst and lst.external_id not in listings:46                listings[lst.external_id] = lst4748        # Pages détail (cache BD) : adresse complète, superficie, description,49        # commodités, galerie complète50        fetched = 051        for i, lst in enumerate(listings.values()):52            if i >= self.max_details:53                break54            key = hashlib.sha1(55                f"{lst.title}|{lst.price_label}|{lst.availability}"56                .encode("utf-8")).hexdigest()5758            def _fetch(url=lst.url):59                nonlocal fetched60                if fetched >= self.max_details:61                    raise RuntimeError("plafond de requêtes détail atteint")62                fetched += 163                return self._fetch_detail(url)6465            try:66                payload = self.detail(lst.external_id, key, _fetch)67            except Exception:68                payload = {}69            if payload:70                self._apply_detail(lst, payload)7172        return list(listings.values())7374    def _parse_card(self, card) -> Listing | None:75        link = card.select_one("a.listing__thumbnail__content__title-wrapper") \76            or card.select_one("a[href*='/appartements-a-louer/']")77        if not link:78            return None79        url = link.get("href", "").split("?")[0]80        m = re.search(r"/appartements-a-louer/([a-z0-9\-]+)/?$", url)81        if not m:82            return None83        slug = m.group(1)8485        fav = card.select_one("[data-unit-id]")86        ext_id = (fav.get("data-unit-id", "").strip() if fav else "") or slug8788        h3 = card.select_one("h3")89        title = h3.get_text(" ", strip=True) if h3 else slug90        sector_el = card.select_one(91            ".listing__thumbnail__content__title-wrapper p")92        sector = sector_el.get_text(strip=True) if sector_el else ""9394        price_el = card.select_one(".listing__thumbnail__price")95        price_label = price_el.get_text(" ", strip=True) if price_el else ""96        avail_el = card.select_one(".listing__thumbnail__availability")97        avail = avail_el.get_text(" ", strip=True) if avail_el else ""98        size_el = card.select_one(".listing__thumbnail__size")99        unit_raw = size_el.get_text(" ", strip=True) if size_el else ""100101        # Exclusions (prudence : le site est résidentiel)102        if re.search(r"stationnement|commercial|rangement|entrepos",103                     f"{title} {unit_raw}", re.I):104            return None105106        # Adresse dérivée du slug : « 350-101-chemin-ste-foy-… » ->107        # « 350, Chemin Ste-Foy » (n° d'immeuble, n° d'unité, rue)108        address = ""109        s = re.sub(r"^copie-de-", "", slug)110        ma = re.match(r"^(\d+)-\d+[a-z]?-([a-z\-]+?)"111                      r"(?:-selection|-modele|-app|$)", s)112        if ma:113            street = " ".join(w.capitalize() for w in ma.group(2).split("-"))114            address = f"{ma.group(1)}, {street}"115116        amenities = [img.get("title") or img.get("alt", "")117                     for img in card.select(118                         ".listing__thumbnail__content__features img")]119        amenities = [a.strip() for a in amenities if a and a.strip()]120121        images = []122        for img in card.select(".swiper-slide img"):123            src = img.get("src") or img.get("data-src") or ""124            if src.startswith("http") and not IMG_NOISE_RE.search(src):125                images.append(src)126127        return Listing(128            source=self.source_id,129            external_id=ext_id,130            url=url,131            title=title,132            address=address,133            sector=sector,134            city=infer_city(sector),135            unit_type=normalize_unit_type(unit_raw),136            price=parse_price(price_label),137            price_label=price_label,138            availability=avail,139            amenities=amenities,140            images=list(dict.fromkeys(images)),141        )142143    def _fetch_detail(self, url: str) -> dict:144        """Télécharge une page détail et en extrait le payload brut (cacheable)."""145        html = self.get(url).text146        soup = BeautifulSoup(html, "html.parser")147148        # entête : adresse complète (code postal) + disponibilité149        address = availability = ""150        info = soup.select_one("div.single-unit__info")151        if info:152            paras = [p.get_text(" ", strip=True) for p in info.select("p")]153            for t in paras:154                if not address and re.search(r"\d.+(?:Québec|Lévis|G\d[A-Z]\s?\d[A-Z]\d)", t):155                    address = t156                elif re.search(r"Disponible|Libre|Loué", t, re.I):157                    availability = t.strip(" .")158159        # description riche (sans les invites « Demander une visite »)160        desc = ""161        sec = soup.select_one("section.single-unit__description")162        if sec:163            desc = re.sub(r"\s+", " ", sec.get_text(" ", strip=True))164            desc = re.sub(r"Cliquer sur le lien ici pour\s*(Demander une visite)?\s*",165                          "", desc).strip()166        if not desc:167            og = soup.find("meta", attrs={"property": "og:description"}) or \168                soup.find("meta", attrs={"name": "description"})169            if og and og.get("content"):170                desc = og["content"].strip()171172        # superficie affichée sous le plan (« 1360 pi2 ») — texte brut,173        # normalisé ensuite par finalize()174        surf_el = soup.select_one("span.single-unit__plan-surface")175        surface = surf_el.get_text(" ", strip=True) if surf_el else ""176177        # caractéristiques de l'appartement + commodités de l'immeuble178        # (les « Services de proximité » — épicerie, parc… — sont exclus)179        amenities: list[str] = []180        for section in soup.select("section.single-unit__features"):181            title_el = section.select_one("h2")182            title = title_el.get_text(" ", strip=True) if title_el else ""183            if re.search(r"proximit", title, re.I):184                continue185            for li in section.select("li"):186                t = li.get_text(" ", strip=True)187                if t and len(t) < 60 and t not in amenities:188                    amenities.append(t)189190        images = [u for u in dict.fromkeys(IMG_RE.findall(html))191                  if not IMG_NOISE_RE.search(u)192                  and not re.search(r"-\d+x\d+\.", u)]193194        return {"address": address, "availability": availability,195                "description": desc[:800], "surface": surface,196                "amenities": amenities, "images": images}197198    def _apply_detail(self, lst: Listing, payload: dict) -> None:199        """Applique le payload d'une page détail (frais ou depuis le cache)."""200        if payload.get("address"):201            lst.address = payload["address"]202        if payload.get("availability") and not lst.availability:203            lst.availability = payload["availability"]204        if payload.get("description"):205            lst.description = payload["description"]206        # superficie : texte source (« 1360 pi2 ») ajouté aux commodités pour207        # affichage + parsing par finalize()208        extra = list(payload.get("amenities") or [])209        if payload.get("surface"):210            extra.append(payload["surface"])211        lst.amenities = list(dict.fromkeys(lst.amenities + extra))212        merged = list(dict.fromkeys((payload.get("images") or []) + lst.images))213        if merged:214            lst.images = merged[:25]215