SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.9 KB · 175 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/horizon_terrebonne.py : connecteur Horizon Terrebonne5#   (horizonterrebonne.com — Groupe Socam, condos locatifs neufs en 2 tours6#   au 1441 chemin Gascon à Terrebonne). WordPress rendu SERVEUR : la page7#   /condos/ liste des cartes <article class="apartment"> (statut, tour,8#   chambres, prix mensuel, photo, lien) ; chaque fiche « Phase n - x.5 à9#   partir de » ajoute salle(s) de bain, étage, superficie et galerie.10#   Granularité MODÈLE « à partir de » par phase/typologie (pas d'unités11#   individuelles publiées) ; external_id = id du post WordPress (stable,12#   classe post-NNNN). Seules les cartes « Disponible » sont retenues.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://horizonterrebonne.com"24LIST_URL = f"{BASE}/condos/"25ADDRESS = "1441, chemin Gascon, Terrebonne"26LAT, LNG = 45.712055, -73.652372728POST_ID_RE = re.compile(r"\bpost-(\d+)\b")29PRICE_RE = re.compile(r"([\d\s  ]+)\s*\$")30IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I)31# « Phase 2 – 4.5 à partir de » -> typologie 4½32TYPE_RE = re.compile(r"(\d)[.,]5")333435def _price(text: str) -> float | None:36    m = PRICE_RE.search(text or "")37    if not m:38        return None39    try:40        val = float(re.sub(r"[\s  ]", "", m.group(1)))41    except ValueError:42        return None43    return val if 300 <= val <= 20000 else None444546class HorizonTerrebonneConnector(BaseConnector):47    source_id = "horizon_terrebonne"48    request_delay = 0.84950    def fetch(self) -> list[Listing]:51        try:52            soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")53        except Exception:54            return []55        listings: list[Listing] = []56        for card in soup.select("article.apartment"):57            status = card.select_one(".apartment__status-value")58            status = status.get_text(strip=True) if status else ""59            if "disponible" not in status.lower() or "non" in status.lower():60                continue61            link = card.select_one("a[href]")62            if not link:63                continue64            url = link["href"]65            m = POST_ID_RE.search(" ".join(card.get("class") or []))66            ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]6768            tour = card.select_one(".apartment__location-tour")69            tour = tour.get_text(strip=True) if tour else ""70            rooms = card.select_one(".apartment__room-value")71            bedrooms = None72            if rooms:73                try:74                    bedrooms = float(rooms.get_text(strip=True))75                except ValueError:76                    pass77            price_el = card.select_one(".apartment__monthly-price")78            price = _price(price_el.get_text(" ", strip=True)) if price_el else None7980            # fiche détail (cache BD) : titre, sdb, étage, superficie, galerie81            detail = self.detail(ext_id, f"{status}|{price}|{tour}",82                                 lambda u=url: self._detail(u))83            title = detail.get("title") or ""84            unit_type = ""85            m = TYPE_RE.search(title)86            if m:87                unit_type = normalize_unit_type(f"{m.group(1)} 1/2")88            images = detail.get("images") or []89            img = card.select_one(".apartment__image img[src]")90            if img:91                src = IMG_SIZE_RE.sub("", img["src"])92                if src not in images:93                    images = [src] + images9495            details: dict = {}96            if tour:97                details["tour"] = tour98            for k in ("bathrooms", "floor", "orientation"):99                if detail.get(k):100                    details[k] = detail[k]101102            desc_bits = [b for b in (103                f"{title} — {tour}" if tour else title,104                f"Superficie : {detail['area']} pi²" if detail.get("area") else "",105            ) if b]106            listings.append(Listing(107                source=self.source_id,108                external_id=ext_id,109                url=url,110                title=title or f"Condo {unit_type} — Horizon Terrebonne",111                address=ADDRESS,112                city="Terrebonne",113                unit_type=unit_type,114                bedrooms=bedrooms,115                price=price,116                price_label=f"à partir de {price:.0f} $ /mois" if price else "",117                availability=status,118                area_sqft=detail.get("area"),119                description=" | ".join(desc_bits)[:600],120                amenities=detail.get("amenities") or [],121                details=details,122                images=images[:25],123                lat=LAT,124                lng=LNG,125            ))126        return listings127128    # -- page détail « Phase n - x.5 à partir de » ------------------------------129    def _detail(self, url: str) -> dict:130        try:131            soup = BeautifulSoup(self.get(url).text, "html.parser")132        except Exception:133            return {}134        out: dict = {}135        h1 = soup.select_one("h1") or soup.select_one("title")136        if h1:137            out["title"] = re.sub(r"\s*\|.*$", "",138                                  h1.get_text(" ", strip=True)).strip()139        text = soup.get_text(" ", strip=True)140        m = re.search(r"Salle\(s\) de bain\s*(\d+(?:[.,]\d)?)", text)141        if m:142            out["bathrooms"] = float(m.group(1).replace(",", "."))143        m = re.search(r"Étage\(s\)\s*([\w\d]+)", text)144        if m and m.group(1) != "—":145            out["floor"] = m.group(1)146        m = re.search(r"Superficie\s*([\d\s  ]+)\s*pi", text)147        if m:148            try:149                out["area"] = float(re.sub(r"[\s  ]", "",150                                           m.group(1)))151            except ValueError:152                pass153        # avantages de l'immeuble (liste courte avant « Détails du projet »)154        amen: list[str] = []155        for li in soup.select("li"):156            t = li.get_text(" ", strip=True)157            if 3 < len(t) < 60 and re.search(158                    r"piscine|potager|lavage|parc|ascenseur|lounge|"159                    r"entra[iî]nement|terrasse", t, re.I) and t not in amen:160                amen.append(t)161        if amen:162            out["amenities"] = amen[:12]163        imgs: list[str] = []164        for img in soup.select("img[src]"):165            src = img["src"]166            if "/app/uploads/" not in src or re.search(r"logo|icon", src, re.I):167                continue168            src = IMG_SIZE_RE.sub("", src)169            if src.endswith("-scaled.jpg"):170                pass171            if src not in imgs:172                imgs.append(src)173        out["images"] = imgs[:25]174        return out175