Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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