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/medallion.py : connecteur Medallion Corporation (medallioncorp.com)5# Gestionnaire torontois (~25 000 unités est., Toronto/GTA + Brampton, Ajax,6# Whitby, Oshawa, Richmond Hill, Hamilton, London). Site WordPress (thème7# RealHomes/inspiry + CPT UI) SANS anti-bot : le CPT `property` est exposé8# par l'API REST standard — `/wp-json/wp/v2/properties?per_page=100`9# (84 propriétés au moment de l'écriture, x-wp-total 84, une seule page).10# Chaque entrée porte dans `class_list` la ville (property-city-<slug>), le11# type (property-type-residential/commercial…) et le statut (property-status-12# for-rent vs commercial-rent) : on ne garde que le résidentiel à louer13# (56 immeubles). `property_meta` fournit l'adresse civique complète14# (REAL_HOMES_property_address), les coordonnées GPS et la galerie photo15# (URLs wp-content/uploads, variantes redimensionnées).16# Les prix/types d'unités ne sont PAS dans le flux : la fiche propriété17# (rendu serveur) affiche un accordéon « Floor Plans » (nom, N Bedrooms,18# N Bathrooms, prix plancher, « Call for Availability ») — visitée via le19# cache BD self.detail() (clé = date `modified` du flux). Une annonce par20# plan d'étage ; repli « une annonce par propriété » (sans prix inventé)21# quand l'accordéon est absent.22#23# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le24# connecteur est `disabled` et exclu du registre (zéro impact prod QC).25# -----------------------------------------------------------------------------26from __future__ import annotations2728import os29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, normalize_unit_type, parse_price34from .base import BaseConnector3536BASE = "https://medallioncorp.com"37FEED_URL = f"{BASE}/wp-json/wp/v2/properties?per_page=100"3839# Gate expansion Ontario : le connecteur reste hors registre tant que la40# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.41_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4243# villes du flux (slug de class_list `property-city-<slug>`) -> affichage ;44# tout slug inconnu est titré tel quel (« richmond-hill » -> « Richmond Hill »)45_CITY_LABEL = {46 "toronto": "Toronto",47 "brampton": "Brampton",48 "ajax": "Ajax",49 "whitby": "Whitby",50 "oshawa": "Oshawa",51 "richmond-hill": "Richmond Hill",52 "hamilton": "Hamilton",53 "london": "London",54 "milton": "Milton",55}5657# lignes de l'accordéon plans d'étage : « 2 Bedrooms », « 1.5 Bathrooms »58_BED_RE = re.compile(r"(\d+)\s*Bedroom", re.I)59_BATH_RE = re.compile(r"([\d.]+)\s*Bathroom", re.I)60_SQFT_RE = re.compile(r"([\d,]+)\s*(?:sq\.?\s*ft|pi2|ft2)", re.I)61_CITY_CLS_RE = re.compile(r"^property-city-(.+)$")626364class MedallionConnector(BaseConnector):65 source_id = "medallion"66 request_delay = 1.067 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)68 max_properties = 80 # garde-fou (56 immeubles résidentiels aujourd'hui)69 max_images = 207071 def fetch(self) -> list[Listing]:72 props = self.get(FEED_URL, headers={"Accept": "application/json"}).json()7374 listings: list[Listing] = []75 count = 076 for p in props:77 try:78 cls = set(p.get("class_list") or [])79 # résidentiel longue durée seulement : à louer, non commercial80 if "property-status-for-rent" not in cls:81 continue82 if "property-type-residential" not in cls:83 continue84 if count >= self.max_properties:85 break86 count += 187 listings.extend(self._property_listings(p))88 except Exception:89 continue90 return listings9192 # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------93 def _property_listings(self, p: dict) -> list[Listing]:94 pid = str(p.get("id"))95 url = p.get("link") or ""96 title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",97 "html.parser").get_text(" ", strip=True)98 meta = p.get("property_meta") or {}99100 # adresse civique complète du flux (« 25 Kitney Dr, Ajax, ON L1S 0G6,101 # Canada ») — suffixe « , Canada » retiré102 address = re.sub(r",\s*Canada$", "",103 (meta.get("REAL_HOMES_property_address") or "").strip())104105 # ville : slug property-city-<slug> de class_list106 city = ""107 for c in p.get("class_list") or []:108 m = _CITY_CLS_RE.match(c)109 if m:110 slug = m.group(1)111 city = _CITY_LABEL.get(slug, slug.replace("-", " ").title())112 break113114 # coordonnées GPS du flux (finalize() valide la bbox Ontario)115 loc = meta.get("REAL_HOMES_property_location") or {}116 try:117 lat = float(loc.get("latitude")) if loc.get("latitude") else None118 lng = float(loc.get("longitude")) if loc.get("longitude") else None119 except (TypeError, ValueError):120 lat = lng = None121122 # galerie photo : variantes redimensionnées du flux (large ~1024px)123 images: list[str] = []124 for img in meta.get("REAL_HOMES_property_images") or []:125 sizes = (img or {}).get("sizes") or {}126 u = ""127 for k in ("1536x1536", "large", "medium_large"):128 u = (sizes.get(k) or {}).get("url") or ""129 if u:130 break131 if not u and img.get("file"):132 u = f"{BASE}/wp-content/uploads/{img['file']}"133 if u and u not in images:134 images.append(u)135 images = images[: self.max_images]136137 # description : contenu WordPress de la fiche (rendu texte)138 desc = BeautifulSoup((p.get("content") or {}).get("rendered") or "",139 "html.parser").get_text(" ", strip=True)[:600]140141 # fiche propriété (plans d'étage + commodités) via le cache BD :142 # revisitée seulement quand le post WordPress est modifié143 feed_key = str(p.get("modified") or p.get("modified_gmt") or "")144 d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))145 amenities = d.get("amenities") or []146147 common = dict(148 source=self.source_id, url=url, address=address, city=city,149 province="ON", description=desc, amenities=amenities[:25],150 images=images, lat=lat, lng=lng,151 )152153 # une annonce par plan d'étage (type d'unité + prix plancher affiché)154 out: list[Listing] = []155 for fp in d.get("floorplans") or []:156 name = fp.get("name") or ""157 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")158 beds = fp.get("beds")159 price = fp.get("price")160 out.append(Listing(161 external_id=f"{pid}-{slug or 'u'}",162 title=f"{title} — {name}" if name else title,163 unit_type=("Studio" if beds == 0 else normalize_unit_type(164 f"{int(beds)} chambres") if beds is not None165 else normalize_unit_type(name)),166 bedrooms=beds,167 bathrooms=fp.get("baths"),168 price=price,169 price_label=(f"À partir de {price:.0f} $ /mois"170 if price is not None else ""),171 availability=fp.get("avail") or "",172 area_sqft=fp.get("sqft"),173 **common,174 ))175 if out:176 return out177178 # repli : une annonce par propriété — aucun prix inventé (None = inconnu)179 return [Listing(180 external_id=pid,181 title=title,182 unit_type="",183 **common,184 )]185186 # -- fiche propriété : plans d'étage + commodités ----------------------------187 def _fetch_detail(self, url: str) -> dict:188 """Scrape l'accordéon « Floor Plans » (nom, chambres, sdb, prix,189 disponibilité) et la liste « Buildings Features » de la fiche."""190 out: dict = {"floorplans": [], "amenities": []}191 if not url:192 return out193 try:194 page = self.get(url).text195 except Exception:196 return out197 soup = BeautifulSoup(page, "html.parser")198199 # plans d'étage : <div class="floor-plan"> (nom h3, meta « N Bedrooms /200 # N Bathrooms / prix / Call for Availability »)201 seen: set[str] = set()202 for fp in soup.select(".floor-plans-accordions .floor-plan"):203 h = fp.select_one(".floor-plan-title .title h3")204 name = h.get_text(" ", strip=True) if h else ""205 if not name or name.lower() in seen:206 continue207 seen.add(name.lower())208 meta_el = fp.select_one(".floor-plan-meta")209 meta_txt = meta_el.get_text(" ", strip=True) if meta_el else ""210 mb = _BED_RE.search(meta_txt) or _BED_RE.search(name)211 beds = (float(mb.group(1)) if mb212 else 0.0 if re.search(r"bachelor|studio", name, re.I)213 else None)214 mba = _BATH_RE.search(meta_txt)215 msq = _SQFT_RE.search(meta_txt)216 sqft = float(msq.group(1).replace(",", "")) if msq else None217 price_el = fp.select_one(".floor-price-value")218 price = parse_price(price_el.get_text(" ", strip=True)) \219 if price_el else None220 # texte de dispo : ce qui suit le prix (« Call for Availability »)221 avail = ""222 fprice = fp.select_one(".floor-price")223 if fprice:224 avail = fprice.get_text(" ", strip=True)225 if price_el:226 avail = avail.replace(227 price_el.get_text(" ", strip=True), "").strip()228 out["floorplans"].append({229 "name": name,230 "beds": beds,231 "baths": float(mba.group(1)) if mba else None,232 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,233 "price": price,234 "avail": avail,235 })236237 # commodités : « Buildings Features » (liste à puces de la fiche)238 h = soup.find(["h3", "h4"], string=re.compile(239 r"Buildings? Features", re.I))240 if h:241 ul = h.find_next("ul")242 if ul:243 amenities = []244 for li in ul.find_all("li"):245 t = li.get_text(" ", strip=True)246 if t and len(t) < 60 and t not in amenities:247 amenities.append(t)248 out["amenities"] = amenities[:25]249 return out250