SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.8 KB · 250 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: 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# -----------------------------------------------------------------------------28from __future__ import annotations2930import hashlib31import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type37from .base import BaseConnector3839BASE = "https://www.westdaleproperties.com"4041# Rent-Ka: connector always active.42_ONTARIO = True  # Rent-Ka: always on (ROC scope)4344_SLUG_RE = re.compile(r"window\.open\('rental/([^']+)'")45_PRICE_RE = re.compile(r"(?:From|Starting at)\s*\$\s*([\d,]+(?:\.\d{2})?)",46                       re.I)47_SQFT_RE = re.compile(r"Size:\s*([\d,]+)\s*ft", re.I)48# colonnes lits/bains : « 1 <img class='bed-svg'…> 1.5 <img class='bath-svg'…> »49_BED_RE = re.compile(r"([\d.]+)\s*<img[^>]*bed-svg")50_BATH_RE = re.compile(r"([\d.]+)\s*<img[^>]*bath-svg")515253class WestdaleConnector(BaseConnector):54    source_id = "westdale"55    request_delay = 1.056    disabled = False57    max_properties = 25       # garde-fou (14 immeubles au 2026-08)58    max_listings = 150        # garde-fou lignes de suites (~66 au 2026-08)59    max_images = 156061    # -- liste : blocs .bld-content de la page d'accueil --------------------------62    def fetch(self) -> list[Listing]:63        soup = BeautifulSoup(self.get(BASE + "/").text, "html.parser")64        listings: list[Listing] = []65        count = 066        for block in soup.select(".bld-content[onclick]"):67            m = _SLUG_RE.search(block.get("onclick") or "")68            if not m:69                continue          # blocs non résidentiels (tel:, Admin/…)70            if count >= self.max_properties or \71                    len(listings) >= self.max_listings:72                break73            count += 174            try:75                listings.extend(self._listings(m.group(1), block))76            except Exception:77                continue78        return listings[: self.max_listings]7980    # -- annonces d'un immeuble (une par type de suite) ---------------------------81    def _listings(self, slug: str, block) -> list[Listing]:82        # nom : le h3 du bloc texte (PAS le h3.bonus du bandeau promo)83        h3 = block.select_one(".text h3") or block.h384        name = h3.get_text(" ", strip=True) if h3 else slug85        block_txt = block.get_text(" ", strip=True)86        img = block.find("img")87        thumb = self._abs((img.get("src") or "").strip()) if img else ""8889        url = f"{BASE}/rental/{slug}"90        # fiche revisitée seulement quand le bloc de la liste change91        feed_key = hashlib.sha1(92            f"{block_txt}|{thumb}".encode("utf-8")).hexdigest()93        d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))9495        city = (d.get("city") or "").strip() or "Toronto"96        details: dict = {}97        if d.get("phone"):98            details["contact"] = {"phone": d["phone"]}99        if d.get("parking"):100            details["Stationnement"] = d["parking"]101102        base_images = [u for u in (d.get("images") or []) if u] or \103            ([thumb] if thumb else [])104105        common = dict(106            source=self.source_id, url=url,107            address=d.get("address") or "", city=city, province="ON",108            description=(d.get("description") or "")[:900],109            amenities=(d.get("amenities") or [])[:25],110        )111112        out: list[Listing] = []113        for su in d.get("suites") or []:114            if su.get("price") is None:115                continue          # ligne sans « Starting at » : rien d'inventé116            images = [u for u in (su.get("images") or []) + base_images117                      if u][: self.max_images]118            beds = su.get("beds")119            out.append(Listing(120                external_id=f"{slug}-{su['id']}",121                title=f"{name} — {su['name']}" if su.get("name") else name,122                unit_type=self._unit_type(su.get("name") or "", beds),123                bedrooms=beds,124                bathrooms=su.get("baths"),125                price=su["price"],126                price_label=f"À partir de {su['price']:.0f} $ /mois",127                area_sqft=su.get("sqft"),128                details=dict(details),129                images=images,130                **common,131            ))132        if out:133            return out134135        # repli : une annonce par immeuble avec le « From $X » du bloc liste136        mp = _PRICE_RE.search(block_txt)137        if not mp:138            return []             # ni suite avec prix ni prix plancher : rien139        price = float(mp.group(1).replace(",", ""))140        return [Listing(141            external_id=slug,142            title=name,143            price=price,144            price_label=f"À partir de {price:.0f} $ /mois",145            details=details,146            images=base_images[: self.max_images],147            **common,148        )]149150    @staticmethod151    def _unit_type(su_name: str, beds: float | None) -> str:152        """Type d'unité : nom de la ligne (Bachelor -> Studio) sinon les cc."""153        ut = normalize_unit_type(su_name)154        if ut and ut != su_name.strip():155            return ut156        if beds == 0:157            return "Studio"158        if beds is not None:159            return normalize_unit_type(f"{int(beds)} chambres")160        return ut161162    @staticmethod163    def _abs(u: str) -> str:164        return u if u.startswith("http") else (BASE + u if u else "")165166    # -- fiche /rental/<slug> ------------------------------------------------------167    def _fetch_detail(self, url: str) -> dict:168        soup = BeautifulSoup(self.get(url).text, "html.parser")169        out: dict = {"suites": []}170171        # adresse + téléphone : premier <p> sous le h1 (liens maps + tel:)172        h1 = soup.find("h1", class_="property_title")173        p = h1.find_next("p") if h1 else None174        if p:175            a_map = p.find("a", href=re.compile(r"google\.com/maps"))176            if a_map:177                addr = a_map.get_text(" ", strip=True)178                out["address"] = addr179                # « 484 Avenue Rd, Toronto, ON » -> ville avant « ON »180                parts = [x.strip() for x in addr.split(",")]181                if len(parts) >= 2 and parts[-1].upper().startswith("ON"):182                    out["city"] = parts[-2]183            a_tel = p.find("a", href=re.compile(r"^tel:"))184            if a_tel:185                out["phone"] = a_tel.get_text(" ", strip=True)186187        desc = soup.find(id="txtDetail")188        if desc:189            out["description"] = desc.get_text(" ", strip=True)190191        # galerie de l'immeuble (#mainslider)192        images: list[str] = []193        slider = soup.find(id="mainslider")194        for a in (slider.find_all("a", class_="pics") if slider else []):195            u = self._abs((a.get("href") or "").strip())196            if u and u not in images:197                images.append(u)198        out["images"] = images[: self.max_images]199200        # commodités (suite + immeuble) et services inclus201        amenities: list[str] = []202        for div in soup.select(".amenity_tags, .utility_tags"):203            t = div.get_text(" ", strip=True)204            if t and len(t) < 90 and t not in amenities:205                amenities.append(t)206        out["amenities"] = amenities207208        # stationnement : <p> sous le h2 « Parking »209        for h2 in soup.find_all("h2"):210            if h2.get_text(strip=True).lower() == "parking":211                pp = h2.find_next("p")212                if pp:213                    out["parking"] = pp.get_text(" ", strip=True)[:300]214                break215216        # accordéon Options : une ligne .btn_option par type de suite217        for row in soup.select("#accordion .group .btn_option"):218            rid = (row.get("id") or "").strip()219            cols = row.select("[class*=col-sm]")220            su_name = cols[0].get_text(" ", strip=True) if cols else ""221            if not rid or not su_name:222                continue223            row_html = str(row)224            mb = _BED_RE.search(row_html)225            mba = _BATH_RE.search(row_html)226            txt = row.get_text(" ", strip=True)227            mp = _PRICE_RE.search(txt)228            msq = _SQFT_RE.search(txt)229            sqft = float(msq.group(1).replace(",", "")) if msq else None230            # photos propres à la suite (« Not Available. » = aucune)231            su_imgs: list[str] = []232            pho = soup.find(id=f"single_pho{rid}")233            for a in (pho.find_all("a") if pho else []):234                u = self._abs((a.get("href") or "").strip())235                if u and u not in su_imgs:236                    su_imgs.append(u)237            beds = float(mb.group(1)) if mb else \238                (0.0 if re.search(r"bachelor|studio", su_name, re.I) else None)239            out["suites"].append({240                "id": rid,241                "name": su_name,242                "beds": beds,243                "baths": float(mba.group(1)) if mba else None,244                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,245                "price": (float(mp.group(1).replace(",", ""))246                          if mp else None),247                "images": su_imgs[:5],248            })249        return out250