SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.8 KB · 137 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/lelaureat.py : connecteur Résidences Le Lauréat5#   (residences-lelaureat.com) — 107 studios étudiants tout inclus rénovés au6#   2145, rue Galt Ouest, Sherbrooke (bail Régie du logement, non-fumeur,7#   occupation simple). Site WordPress WPBakery d'une seule page : la section8#   « Dimensions des studios » publie 5 TYPES d'unités (Studio Type, Centre9#   Arrière, Coin Est, Coin Ouest, Loft) avec dimensions et prix par variante10#   (étage / demi-sous-sol). Granularité = type d'unité (aucune unité11#   individuelle publiée), comme sig.py. Les inclusions communes (« Notre12#   service tout inclus englobe ») sont reprises en commodités. 1 requête.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, strip_accents21from .base import BaseConnector2223BASE = "https://www.residences-lelaureat.com"24ADDRESS = "2145 Galt Ouest, Sherbrooke, J1K 3A8"2526_AMOUNT_RE = re.compile(r"\$\s?(\d{3,4})")27_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)282930def _slug(name: str) -> str:31    s = strip_accents(name.strip().lower())32    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")333435class LelaureatConnector(BaseConnector):36    source_id = "lelaureat"37    request_delay = 0.63839    def fetch(self) -> list[Listing]:40        html = self.get(f"{BASE}/").text41        soup = BeautifulSoup(html, "html.parser")4243        # inclusions communes : listes de la section « tout inclus »44        common: list[str] = []45        for marker in ("Notre service tout inclus englobe",46                       "Nos appartements sont équipés"):47            el = soup.find(string=re.compile(marker))48            if not el:49                continue50            ul = el.parent.find_next("ul")51            if ul:52                for li in ul.select("li"):53                    t = re.sub(r"\s+", " ",54                               li.get_text(" ", strip=True)).strip(" ►")55                    if t and t not in common:56                        common.append(t)5758        # galerie du site (photos des studios)59        gallery: list[str] = []60        for img in soup.select("img[src*='/wp-content/uploads/']"):61            src = _VARIANT_IMG.sub("", str(img.get("src") or ""))62            if src.startswith("http") and src not in gallery \63                    and re.search(r"room-|logement|Un-logement", src):64                gallery.append(src)6566        # 5 types d'unités : titres « Studio … » / « Loft » de la section67        # dimensions, chacun précédé de sa dimension et suivi de sa liste68        # (prix par variante, particularités)69        listings: list[Listing] = []70        seen: set[str] = set()71        for h in soup.find_all(["h3", "h4", "h5"]):72            name = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip()73            if not re.match(r"^(Studio|Loft)", name) or len(name) > 40:74                continue75            sid = _slug(name)76            if sid in seen:77                continue78            seen.add(sid)7980            dim_el = h.find_previous(string=re.compile(r"\d+\s*'"))81            dimension = re.sub(r"\s+", " ", str(dim_el)).strip() if dim_el else ""8283            # toutes les puces entre ce titre de type et le suivant : prix par84            # variante (étage / demi-sous-sol) + particularités85            bullets: list[str] = []86            for el in h.find_all_next():87                if el.name in ("h3", "h4", "h5") and re.match(88                        r"^(Studio|Loft|Façade|Galerie|Localisation)",89                        el.get_text(" ", strip=True)):90                    break91                if el.name == "li" or (el.name == "p" and92                                       "►" in el.get_text()):93                    t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))94                    t = t.replace("►", " ").strip()95                    t = re.sub(r"\s+", " ", t)96                    if t and t not in bullets:97                        bullets.append(t)9899            # prix : « $855 /$825 » (étage) et « $810 » (demi-sous-sol) ->100            # le plus bas des variantes du type101            price_lines = [b for b in bullets if "$" in b]102            price_label = " / ".join(price_lines)[:80]103            amounts = [int(a) for a in _AMOUNT_RE.findall(" ".join(price_lines))]104            price = float(min(amounts)) if amounts else None105106            # disponibilité : seulement si écrite (ex. Loft « Disponibilité à107            # partir de janvier 2026 »)108            availability = ""109            for b in bullets:110                m = re.search(r"Disponibilit[eé].*$", b)111                if m:112                    availability = m.group(0).strip()113                    break114115            desc_parts = [f"Dimensions : {dimension}"] if dimension else []116            desc_parts += [b for b in bullets if b]117            amenities = [f"Dimensions : {dimension}"] if dimension else []118            amenities += common[:12]119120            listings.append(Listing(121                source=self.source_id,122                external_id=sid,123                url=f"{BASE}/",124                title=f"Résidences Le Lauréat — {name} (étudiant tout inclus)",125                address=ADDRESS,126                sector="",127                city="Sherbrooke",128                unit_type="Loft" if name.lower().startswith("loft") else "Studio",129                price=price,130                price_label=price_label,131                availability=availability,132                description=" | ".join(desc_parts)[:1000],133                amenities=amenities,134                images=gallery[:12],135            ))136        return listings137