# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/westdale.py : connecteur Westdale Properties (westdaleproperties.com) # Gestionnaire familial de Toronto/North York (14 immeubles résidentiels au # moment de l'écriture : Forest Hill, Redpath, St George, Don Mills…). # Site legacy ASP.NET « VICommunity » entièrement rendu serveur, AUCUN # anti-bot (requests direct suffit ; réponses gzip gérées par requests) : # - la PAGE D'ACCUEIL est la liste : blocs `.bld-content` avec # onclick="window.open('rental/','_self')", nom (h3), résumé # (p.brief), « N suite options | From $X » (p.options) et vignette ; # - la fiche /rental/ donne l'adresse civique + téléphone (premier #

), la description (#txtDetail), la galerie (#mainslider), # les commodités (h2 Suite/Building amenities + Utilities -> tags), le # stationnement, et l'accordéon Options : une ligne `.btn_option` par # type de suite — nom, « N (lit) N (bain) » (icônes svg, baths peut # être 1.5), « Starting at $X », « Size: N ft² » — avec plan d'étage # et photos de la suite (#single_phoN, « Not Available. » = pas de # photos, PAS une indisponibilité). # Une annonce PAR TYPE DE SUITE (ligne d'accordéon avec prix), repli « une # annonce par immeuble » avec le prix « From $X » du bloc de la liste. # Les prix sont des « Starting at » de catalogue (« Please call for # availabilities ») : availability laissée vide — rien d'inventé. Idem # animaux : la section « Pet-Friendly » est vide partout -> pets None. # Fiches revisitées via self.detail() seulement quand le bloc de la liste # change (résumé, nombre d'options, prix plancher, vignette). # Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.westdaleproperties.com" # Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" _SLUG_RE = re.compile(r"window\.open\('rental/([^']+)'") _PRICE_RE = re.compile(r"(?:From|Starting at)\s*\$\s*([\d,]+(?:\.\d{2})?)", re.I) _SQFT_RE = re.compile(r"Size:\s*([\d,]+)\s*ft", re.I) # colonnes lits/bains : « 1 1.5 » _BED_RE = re.compile(r"([\d.]+)\s*]*bed-svg") _BATH_RE = re.compile(r"([\d.]+)\s*]*bath-svg") class WestdaleConnector(BaseConnector): source_id = "westdale" request_delay = 1.0 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) max_properties = 25 # garde-fou (14 immeubles au 2026-08) max_listings = 150 # garde-fou lignes de suites (~66 au 2026-08) max_images = 15 # -- liste : blocs .bld-content de la page d'accueil -------------------------- def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(BASE + "/").text, "html.parser") listings: list[Listing] = [] count = 0 for block in soup.select(".bld-content[onclick]"): m = _SLUG_RE.search(block.get("onclick") or "") if not m: continue # blocs non résidentiels (tel:, Admin/…) if count >= self.max_properties or \ len(listings) >= self.max_listings: break count += 1 try: listings.extend(self._listings(m.group(1), block)) except Exception: continue return listings[: self.max_listings] # -- annonces d'un immeuble (une par type de suite) --------------------------- def _listings(self, slug: str, block) -> list[Listing]: # nom : le h3 du bloc texte (PAS le h3.bonus du bandeau promo) h3 = block.select_one(".text h3") or block.h3 name = h3.get_text(" ", strip=True) if h3 else slug block_txt = block.get_text(" ", strip=True) img = block.find("img") thumb = self._abs((img.get("src") or "").strip()) if img else "" url = f"{BASE}/rental/{slug}" # fiche revisitée seulement quand le bloc de la liste change feed_key = hashlib.sha1( f"{block_txt}|{thumb}".encode("utf-8")).hexdigest() d = self.detail(slug, feed_key, lambda: self._fetch_detail(url)) city = (d.get("city") or "").strip() or "Toronto" details: dict = {} if d.get("phone"): details["contact"] = {"phone": d["phone"]} if d.get("parking"): details["Stationnement"] = d["parking"] base_images = [u for u in (d.get("images") or []) if u] or \ ([thumb] if thumb else []) common = dict( source=self.source_id, url=url, address=d.get("address") or "", city=city, province="ON", description=(d.get("description") or "")[:900], amenities=(d.get("amenities") or [])[:25], ) out: list[Listing] = [] for su in d.get("suites") or []: if su.get("price") is None: continue # ligne sans « Starting at » : rien d'inventé images = [u for u in (su.get("images") or []) + base_images if u][: self.max_images] beds = su.get("beds") out.append(Listing( external_id=f"{slug}-{su['id']}", title=f"{name} — {su['name']}" if su.get("name") else name, unit_type=self._unit_type(su.get("name") or "", beds), bedrooms=beds, bathrooms=su.get("baths"), price=su["price"], price_label=f"À partir de {su['price']:.0f} $ /mois", area_sqft=su.get("sqft"), details=dict(details), images=images, **common, )) if out: return out # repli : une annonce par immeuble avec le « From $X » du bloc liste mp = _PRICE_RE.search(block_txt) if not mp: return [] # ni suite avec prix ni prix plancher : rien price = float(mp.group(1).replace(",", "")) return [Listing( external_id=slug, title=name, price=price, price_label=f"À partir de {price:.0f} $ /mois", details=details, images=base_images[: self.max_images], **common, )] @staticmethod def _unit_type(su_name: str, beds: float | None) -> str: """Type d'unité : nom de la ligne (Bachelor -> Studio) sinon les cc.""" ut = normalize_unit_type(su_name) if ut and ut != su_name.strip(): return ut if beds == 0: return "Studio" if beds is not None: return normalize_unit_type(f"{int(beds)} chambres") return ut @staticmethod def _abs(u: str) -> str: return u if u.startswith("http") else (BASE + u if u else "") # -- fiche /rental/ ------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {"suites": []} # adresse + téléphone : premier

sous le h1 (liens maps + tel:) h1 = soup.find("h1", class_="property_title") p = h1.find_next("p") if h1 else None if p: a_map = p.find("a", href=re.compile(r"google\.com/maps")) if a_map: addr = a_map.get_text(" ", strip=True) out["address"] = addr # « 484 Avenue Rd, Toronto, ON » -> ville avant « ON » parts = [x.strip() for x in addr.split(",")] if len(parts) >= 2 and parts[-1].upper().startswith("ON"): out["city"] = parts[-2] a_tel = p.find("a", href=re.compile(r"^tel:")) if a_tel: out["phone"] = a_tel.get_text(" ", strip=True) desc = soup.find(id="txtDetail") if desc: out["description"] = desc.get_text(" ", strip=True) # galerie de l'immeuble (#mainslider) images: list[str] = [] slider = soup.find(id="mainslider") for a in (slider.find_all("a", class_="pics") if slider else []): u = self._abs((a.get("href") or "").strip()) if u and u not in images: images.append(u) out["images"] = images[: self.max_images] # commodités (suite + immeuble) et services inclus amenities: list[str] = [] for div in soup.select(".amenity_tags, .utility_tags"): t = div.get_text(" ", strip=True) if t and len(t) < 90 and t not in amenities: amenities.append(t) out["amenities"] = amenities # stationnement :

sous le h2 « Parking » for h2 in soup.find_all("h2"): if h2.get_text(strip=True).lower() == "parking": pp = h2.find_next("p") if pp: out["parking"] = pp.get_text(" ", strip=True)[:300] break # accordéon Options : une ligne .btn_option par type de suite for row in soup.select("#accordion .group .btn_option"): rid = (row.get("id") or "").strip() cols = row.select("[class*=col-sm]") su_name = cols[0].get_text(" ", strip=True) if cols else "" if not rid or not su_name: continue row_html = str(row) mb = _BED_RE.search(row_html) mba = _BATH_RE.search(row_html) txt = row.get_text(" ", strip=True) mp = _PRICE_RE.search(txt) msq = _SQFT_RE.search(txt) sqft = float(msq.group(1).replace(",", "")) if msq else None # photos propres à la suite (« Not Available. » = aucune) su_imgs: list[str] = [] pho = soup.find(id=f"single_pho{rid}") for a in (pho.find_all("a") if pho else []): u = self._abs((a.get("href") or "").strip()) if u and u not in su_imgs: su_imgs.append(u) beds = float(mb.group(1)) if mb else \ (0.0 if re.search(r"bachelor|studio", su_name, re.I) else None) out["suites"].append({ "id": rid, "name": su_name, "beds": beds, "baths": float(mba.group(1)) if mba else None, "sqft": sqft if sqft and 80 <= sqft <= 20000 else None, "price": (float(mp.group(1).replace(",", "")) if mp else None), "images": su_imgs[:5], }) return out