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%
10.9 KB · 251 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/westdale.py : connecteur Westdale Properties (westdaleproperties.com)5#   Gestionnaire familial de Toronto/North York (14 immeubles résidentiels au6#   moment de l'écriture : Forest Hill, Redpath, St George, Don Mills…).7#   Site legacy ASP.NET « VICommunity » entièrement rendu serveur, AUCUN8#   anti-bot (requests direct suffit ; réponses gzip gérées par requests) :9#     - la PAGE D'ACCUEIL est la liste : blocs `.bld-content` avec10#       onclick="window.open('rental/<slug>','_self')", nom (h3), résumé11#       (p.brief), « N suite options | From $X » (p.options) et vignette ;12#     - la fiche /rental/<slug> donne l'adresse civique + téléphone (premier13#       <p><a maps>), la description (#txtDetail), la galerie (#mainslider),14#       les commodités (h2 Suite/Building amenities + Utilities -> tags), le15#       stationnement, et l'accordéon Options : une ligne `.btn_option` par16#       type de suite — nom, « N (lit) N (bain) » (icônes svg, baths peut17#       être 1.5), « Starting at $X », « Size: N ft² » — avec plan d'étage18#       et photos de la suite (#single_phoN, « Not Available. » = pas de19#       photos, PAS une indisponibilité).20#   Une annonce PAR TYPE DE SUITE (ligne d'accordéon avec prix), repli « une21#   annonce par immeuble » avec le prix « From $X » du bloc de la liste.22#   Les prix sont des « Starting at » de catalogue (« Please call for23#   availabilities ») : availability laissée vide — rien d'inventé. Idem24#   animaux : la section « Pet-Friendly » est vide partout -> pets None.25#   Fiches revisitées via self.detail() seulement quand le bloc de la liste26#   change (résumé, nombre d'options, prix plancher, vignette).27#   Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.28# -----------------------------------------------------------------------------29from __future__ import annotations3031import hashlib32import os33import re3435from bs4 import BeautifulSoup3637from ..schema import Listing, normalize_unit_type38from .base import BaseConnector3940BASE = "https://www.westdaleproperties.com"4142# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent43_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4445_SLUG_RE = re.compile(r"window\.open\('rental/([^']+)'")46_PRICE_RE = re.compile(r"(?:From|Starting at)\s*\$\s*([\d,]+(?:\.\d{2})?)",47                       re.I)48_SQFT_RE = re.compile(r"Size:\s*([\d,]+)\s*ft", re.I)49# colonnes lits/bains : « 1 <img class='bed-svg'…> 1.5 <img class='bath-svg'…> »50_BED_RE = re.compile(r"([\d.]+)\s*<img[^>]*bed-svg")51_BATH_RE = re.compile(r"([\d.]+)\s*<img[^>]*bath-svg")525354class WestdaleConnector(BaseConnector):55    source_id = "westdale"56    request_delay = 1.057    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)58    max_properties = 25       # garde-fou (14 immeubles au 2026-08)59    max_listings = 150        # garde-fou lignes de suites (~66 au 2026-08)60    max_images = 156162    # -- liste : blocs .bld-content de la page d'accueil --------------------------63    def fetch(self) -> list[Listing]:64        soup = BeautifulSoup(self.get(BASE + "/").text, "html.parser")65        listings: list[Listing] = []66        count = 067        for block in soup.select(".bld-content[onclick]"):68            m = _SLUG_RE.search(block.get("onclick") or "")69            if not m:70                continue          # blocs non résidentiels (tel:, Admin/…)71            if count >= self.max_properties or \72                    len(listings) >= self.max_listings:73                break74            count += 175            try:76                listings.extend(self._listings(m.group(1), block))77            except Exception:78                continue79        return listings[: self.max_listings]8081    # -- annonces d'un immeuble (une par type de suite) ---------------------------82    def _listings(self, slug: str, block) -> list[Listing]:83        # nom : le h3 du bloc texte (PAS le h3.bonus du bandeau promo)84        h3 = block.select_one(".text h3") or block.h385        name = h3.get_text(" ", strip=True) if h3 else slug86        block_txt = block.get_text(" ", strip=True)87        img = block.find("img")88        thumb = self._abs((img.get("src") or "").strip()) if img else ""8990        url = f"{BASE}/rental/{slug}"91        # fiche revisitée seulement quand le bloc de la liste change92        feed_key = hashlib.sha1(93            f"{block_txt}|{thumb}".encode("utf-8")).hexdigest()94        d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))9596        city = (d.get("city") or "").strip() or "Toronto"97        details: dict = {}98        if d.get("phone"):99            details["contact"] = {"phone": d["phone"]}100        if d.get("parking"):101            details["Stationnement"] = d["parking"]102103        base_images = [u for u in (d.get("images") or []) if u] or \104            ([thumb] if thumb else [])105106        common = dict(107            source=self.source_id, url=url,108            address=d.get("address") or "", city=city, province="ON",109            description=(d.get("description") or "")[:900],110            amenities=(d.get("amenities") or [])[:25],111        )112113        out: list[Listing] = []114        for su in d.get("suites") or []:115            if su.get("price") is None:116                continue          # ligne sans « Starting at » : rien d'inventé117            images = [u for u in (su.get("images") or []) + base_images118                      if u][: self.max_images]119            beds = su.get("beds")120            out.append(Listing(121                external_id=f"{slug}-{su['id']}",122                title=f"{name} — {su['name']}" if su.get("name") else name,123                unit_type=self._unit_type(su.get("name") or "", beds),124                bedrooms=beds,125                bathrooms=su.get("baths"),126                price=su["price"],127                price_label=f"À partir de {su['price']:.0f} $ /mois",128                area_sqft=su.get("sqft"),129                details=dict(details),130                images=images,131                **common,132            ))133        if out:134            return out135136        # repli : une annonce par immeuble avec le « From $X » du bloc liste137        mp = _PRICE_RE.search(block_txt)138        if not mp:139            return []             # ni suite avec prix ni prix plancher : rien140        price = float(mp.group(1).replace(",", ""))141        return [Listing(142            external_id=slug,143            title=name,144            price=price,145            price_label=f"À partir de {price:.0f} $ /mois",146            details=details,147            images=base_images[: self.max_images],148            **common,149        )]150151    @staticmethod152    def _unit_type(su_name: str, beds: float | None) -> str:153        """Type d'unité : nom de la ligne (Bachelor -> Studio) sinon les cc."""154        ut = normalize_unit_type(su_name)155        if ut and ut != su_name.strip():156            return ut157        if beds == 0:158            return "Studio"159        if beds is not None:160            return normalize_unit_type(f"{int(beds)} chambres")161        return ut162163    @staticmethod164    def _abs(u: str) -> str:165        return u if u.startswith("http") else (BASE + u if u else "")166167    # -- fiche /rental/<slug> ------------------------------------------------------168    def _fetch_detail(self, url: str) -> dict:169        soup = BeautifulSoup(self.get(url).text, "html.parser")170        out: dict = {"suites": []}171172        # adresse + téléphone : premier <p> sous le h1 (liens maps + tel:)173        h1 = soup.find("h1", class_="property_title")174        p = h1.find_next("p") if h1 else None175        if p:176            a_map = p.find("a", href=re.compile(r"google\.com/maps"))177            if a_map:178                addr = a_map.get_text(" ", strip=True)179                out["address"] = addr180                # « 484 Avenue Rd, Toronto, ON » -> ville avant « ON »181                parts = [x.strip() for x in addr.split(",")]182                if len(parts) >= 2 and parts[-1].upper().startswith("ON"):183                    out["city"] = parts[-2]184            a_tel = p.find("a", href=re.compile(r"^tel:"))185            if a_tel:186                out["phone"] = a_tel.get_text(" ", strip=True)187188        desc = soup.find(id="txtDetail")189        if desc:190            out["description"] = desc.get_text(" ", strip=True)191192        # galerie de l'immeuble (#mainslider)193        images: list[str] = []194        slider = soup.find(id="mainslider")195        for a in (slider.find_all("a", class_="pics") if slider else []):196            u = self._abs((a.get("href") or "").strip())197            if u and u not in images:198                images.append(u)199        out["images"] = images[: self.max_images]200201        # commodités (suite + immeuble) et services inclus202        amenities: list[str] = []203        for div in soup.select(".amenity_tags, .utility_tags"):204            t = div.get_text(" ", strip=True)205            if t and len(t) < 90 and t not in amenities:206                amenities.append(t)207        out["amenities"] = amenities208209        # stationnement : <p> sous le h2 « Parking »210        for h2 in soup.find_all("h2"):211            if h2.get_text(strip=True).lower() == "parking":212                pp = h2.find_next("p")213                if pp:214                    out["parking"] = pp.get_text(" ", strip=True)[:300]215                break216217        # accordéon Options : une ligne .btn_option par type de suite218        for row in soup.select("#accordion .group .btn_option"):219            rid = (row.get("id") or "").strip()220            cols = row.select("[class*=col-sm]")221            su_name = cols[0].get_text(" ", strip=True) if cols else ""222            if not rid or not su_name:223                continue224            row_html = str(row)225            mb = _BED_RE.search(row_html)226            mba = _BATH_RE.search(row_html)227            txt = row.get_text(" ", strip=True)228            mp = _PRICE_RE.search(txt)229            msq = _SQFT_RE.search(txt)230            sqft = float(msq.group(1).replace(",", "")) if msq else None231            # photos propres à la suite (« Not Available. » = aucune)232            su_imgs: list[str] = []233            pho = soup.find(id=f"single_pho{rid}")234            for a in (pho.find_all("a") if pho else []):235                u = self._abs((a.get("href") or "").strip())236                if u and u not in su_imgs:237                    su_imgs.append(u)238            beds = float(mb.group(1)) if mb else \239                (0.0 if re.search(r"bachelor|studio", su_name, re.I) else None)240            out["suites"].append({241                "id": rid,242                "name": su_name,243                "beds": beds,244                "baths": float(mba.group(1)) if mba else None,245                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,246                "price": (float(mp.group(1).replace(",", ""))247                          if mp else None),248                "images": su_imgs[:5],249            })250        return out251