SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.3 KB · 134 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/immeubles_db.py : connecteur Les Immeubles D.B. Busque-Massé5#   (immeublesdb.com) — 12 immeubles / ~400 apparts à Sherbrooke, 4 secteurs.6#   Site custom PHP (« pm_editor », derrière Cloudflare — le UA de base.py7#   passe) : 4 pages statiques /logements-a-louer/secteur-{nord,est,8#   centre-ville,ouest}/. Chaque logement offert = une section .container9#   avec une cellule galerie (photos /modules/upload/…) et une cellule texte10#   (h3 = adresse civique, liste <li> = type, prix « … $ par mois »,11#   inclusions). Aucun identifiant publié : external_id = empreinte stable12#   (secteur + adresse + ligne de type). Les logements loués sont simplement13#   retirés de la page (aucun marqueur « complet » à filtrer).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://immeublesdb.com"26SECTORS = {27    "secteur-nord": "Nord",28    "secteur-est": "Est",29    "secteur-centre-ville": "Centre-ville",30    "secteur-ouest": "Ouest",31}3233_PRICE_LI = re.compile(r"\d[\d\s]{0,5}\s?\$\s*par mois", re.I)343536def _clean_price_label(label: str) -> str:37    """« 1 125$ par mois » (espace de milliers) -> compatible parse_price."""38    return re.sub(r"(\d)\s(\d{3})", r"\1\2", label)394041class ImmeublesDbConnector(BaseConnector):42    source_id = "immeubles_db"43    request_delay = 0.64445    def fetch(self) -> list[Listing]:46        listings: dict[str, Listing] = {}47        for slug, sector in SECTORS.items():48            try:49                html = self.get(f"{BASE}/logements-a-louer/{slug}/").text50            except Exception:51                continue52            soup = BeautifulSoup(html, "html.parser")53            for container in soup.select("div.container"):54                try:55                    lst = self._parse_block(container, sector)56                except Exception:57                    continue58                if lst and lst.external_id not in listings:59                    listings[lst.external_id] = lst60        return list(listings.values())6162    # -- une section .container = un logement offert --------------------------------63    def _parse_block(self, container, sector: str) -> Listing | None:64        h3 = container.select_one(".cell_container h3")65        ul = container.select_one(".cell_container ul")66        if not (h3 and ul):67            return None68        address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip(" ,")69        lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()70               for li in ul.select("li")]71        lis = [t for t in lis if t]72        if not (address_full and lis):73            return None7475        # « 250, Olivier, Sherbrooke, QC » -> adresse sans la province76        parts = [p.strip() for p in address_full.split(",") if p.strip()]77        address = ", ".join(p for p in parts if p.upper() != "QC")78        city = "Sherbrooke"7980        type_line = lis[0]81        # « 4 CHAMBRES disponibles à partir de 550 $ » = location à la chambre82        # (normalize_unit_type transformerait « 4 chambres » en 6½+)83        if re.search(r"chambres?\s+disponibles?", type_line, re.I):84            unit_type = "Chambre"85        else:86            unit_type = normalize_unit_type(type_line)87            if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",88                                unit_type or ""):89                unit_type = ""9091        # prix : première ligne « … $ par mois »92        price = None93        price_label = ""94        for t in lis:95            if _PRICE_LI.search(t):96                price_label = t97                price = parse_price(_clean_price_label(t))98                break99100        # disponibilité : seulement si la source l'écrit (sinon vide)101        availability = ""102        m = re.search(r"libre\s+imm[ée]diatement", " ".join(lis), re.I)103        if m:104            availability = "Libre immédiatement"105106        # photos de la galerie jumelle (chemins relatifs /modules/upload/…)107        images: list[str] = []108        for img in container.select(".galerie_img_block a[href]"):109            href = (img.get("href") or "").strip()110            if href.startswith("/"):111                href = BASE + href112            if href.startswith("http") and href not in images:113                images.append(href)114115        ext_id = hashlib.sha1(116            f"{sector}|{address_full}|{type_line}".encode("utf-8")).hexdigest()[:16]117118        return Listing(119            source=self.source_id,120            external_id=ext_id,121            url=f"{BASE}/logements-a-louer/"122                f"{[k for k, v in SECTORS.items() if v == sector][0]}/",123            title=f"{type_line}{address}",124            address=address,125            sector=sector,126            city=city,127            unit_type=unit_type,128            price=price,129            price_label=price_label,130            availability=availability,131            description=" | ".join(lis)[:1500],132            images=images[:20],133        )134