Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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