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/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# connecteur est `disabled` et exclu du registre (zéro impact prod QC).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re2930from bs4 import BeautifulSoup3132from ..schema import Listing, normalize_unit_type, parse_price33from .base import BaseConnector3435BASE = "https://medallioncorp.com"36FEED_URL = f"{BASE}/wp-json/wp/v2/properties?per_page=100"3738# Gate expansion Ontario : le connecteur reste hors registre tant que la39_ONTARIO = True # Rent-Ka: always on (ROC scope)4041# villes du flux (slug de class_list `property-city-<slug>`) -> affichage ;42# tout slug inconnu est titré tel quel (« richmond-hill » -> « Richmond Hill »)43_CITY_LABEL = {44 "toronto": "Toronto",45 "brampton": "Brampton",46 "ajax": "Ajax",47 "whitby": "Whitby",48 "oshawa": "Oshawa",49 "richmond-hill": "Richmond Hill",50 "hamilton": "Hamilton",51 "london": "London",52 "milton": "Milton",53}5455# lignes de l'accordéon plans d'étage : « 2 Bedrooms », « 1.5 Bathrooms »56_BED_RE = re.compile(r"(\d+)\s*Bedroom", re.I)57_BATH_RE = re.compile(r"([\d.]+)\s*Bathroom", re.I)58_SQFT_RE = re.compile(r"([\d,]+)\s*(?:sq\.?\s*ft|pi2|ft2)", re.I)59_CITY_CLS_RE = re.compile(r"^property-city-(.+)$")606162class MedallionConnector(BaseConnector):63 source_id = "medallion"64 request_delay = 1.065 disabled = False66 max_properties = 80 # garde-fou (56 immeubles résidentiels aujourd'hui)67 max_images = 206869 def fetch(self) -> list[Listing]:70 props = self.get(FEED_URL, headers={"Accept": "application/json"}).json()7172 listings: list[Listing] = []73 count = 074 for p in props:75 try:76 cls = set(p.get("class_list") or [])77 # résidentiel longue durée seulement : à louer, non commercial78 if "property-status-for-rent" not in cls:79 continue80 if "property-type-residential" not in cls:81 continue82 if count >= self.max_properties:83 break84 count += 185 listings.extend(self._property_listings(p))86 except Exception:87 continue88 return listings8990 # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------91 def _property_listings(self, p: dict) -> list[Listing]:92 pid = str(p.get("id"))93 url = p.get("link") or ""94 title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",95 "html.parser").get_text(" ", strip=True)96 meta = p.get("property_meta") or {}9798 # adresse civique complète du flux (« 25 Kitney Dr, Ajax, ON L1S 0G6,99 # Canada ») — suffixe « , Canada » retiré100 address = re.sub(r",\s*Canada$", "",101 (meta.get("REAL_HOMES_property_address") or "").strip())102103 # ville : slug property-city-<slug> de class_list104 city = ""105 for c in p.get("class_list") or []:106 m = _CITY_CLS_RE.match(c)107 if m:108 slug = m.group(1)109 city = _CITY_LABEL.get(slug, slug.replace("-", " ").title())110 break111112 # coordonnées GPS du flux (finalize() valide la bbox Ontario)113 loc = meta.get("REAL_HOMES_property_location") or {}114 try:115 lat = float(loc.get("latitude")) if loc.get("latitude") else None116 lng = float(loc.get("longitude")) if loc.get("longitude") else None117 except (TypeError, ValueError):118 lat = lng = None119120 # galerie photo : variantes redimensionnées du flux (large ~1024px)121 images: list[str] = []122 for img in meta.get("REAL_HOMES_property_images") or []:123 sizes = (img or {}).get("sizes") or {}124 u = ""125 for k in ("1536x1536", "large", "medium_large"):126 u = (sizes.get(k) or {}).get("url") or ""127 if u:128 break129 if not u and img.get("file"):130 u = f"{BASE}/wp-content/uploads/{img['file']}"131 if u and u not in images:132 images.append(u)133 images = images[: self.max_images]134135 # description : contenu WordPress de la fiche (rendu texte)136 desc = BeautifulSoup((p.get("content") or {}).get("rendered") or "",137 "html.parser").get_text(" ", strip=True)[:600]138139 # fiche propriété (plans d'étage + commodités) via le cache BD :140 # revisitée seulement quand le post WordPress est modifié141 feed_key = str(p.get("modified") or p.get("modified_gmt") or "")142 d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))143 amenities = d.get("amenities") or []144145 common = dict(146 source=self.source_id, url=url, address=address, city=city,147 province="ON", description=desc, amenities=amenities[:25],148 images=images, lat=lat, lng=lng,149 )150151 # une annonce par plan d'étage (type d'unité + prix plancher affiché)152 out: list[Listing] = []153 for fp in d.get("floorplans") or []:154 name = fp.get("name") or ""155 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")156 beds = fp.get("beds")157 price = fp.get("price")158 out.append(Listing(159 external_id=f"{pid}-{slug or 'u'}",160 title=f"{title} — {name}" if name else title,161 unit_type=("Studio" if beds == 0 else normalize_unit_type(162 f"{int(beds)} chambres") if beds is not None163 else normalize_unit_type(name)),164 bedrooms=beds,165 bathrooms=fp.get("baths"),166 price=price,167 price_label=(f"À partir de {price:.0f} $ /mois"168 if price is not None else ""),169 availability=fp.get("avail") or "",170 area_sqft=fp.get("sqft"),171 **common,172 ))173 if out:174 return out175176 # repli : une annonce par propriété — aucun prix inventé (None = inconnu)177 return [Listing(178 external_id=pid,179 title=title,180 unit_type="",181 **common,182 )]183184 # -- fiche propriété : plans d'étage + commodités ----------------------------185 def _fetch_detail(self, url: str) -> dict:186 """Scrape l'accordéon « Floor Plans » (nom, chambres, sdb, prix,187 disponibilité) et la liste « Buildings Features » de la fiche."""188 out: dict = {"floorplans": [], "amenities": []}189 if not url:190 return out191 try:192 page = self.get(url).text193 except Exception:194 return out195 soup = BeautifulSoup(page, "html.parser")196197 # plans d'étage : <div class="floor-plan"> (nom h3, meta « N Bedrooms /198 # N Bathrooms / prix / Call for Availability »)199 seen: set[str] = set()200 for fp in soup.select(".floor-plans-accordions .floor-plan"):201 h = fp.select_one(".floor-plan-title .title h3")202 name = h.get_text(" ", strip=True) if h else ""203 if not name or name.lower() in seen:204 continue205 seen.add(name.lower())206 meta_el = fp.select_one(".floor-plan-meta")207 meta_txt = meta_el.get_text(" ", strip=True) if meta_el else ""208 mb = _BED_RE.search(meta_txt) or _BED_RE.search(name)209 beds = (float(mb.group(1)) if mb210 else 0.0 if re.search(r"bachelor|studio", name, re.I)211 else None)212 mba = _BATH_RE.search(meta_txt)213 msq = _SQFT_RE.search(meta_txt)214 sqft = float(msq.group(1).replace(",", "")) if msq else None215 price_el = fp.select_one(".floor-price-value")216 price = parse_price(price_el.get_text(" ", strip=True)) \217 if price_el else None218 # texte de dispo : ce qui suit le prix (« Call for Availability »)219 avail = ""220 fprice = fp.select_one(".floor-price")221 if fprice:222 avail = fprice.get_text(" ", strip=True)223 if price_el:224 avail = avail.replace(225 price_el.get_text(" ", strip=True), "").strip()226 out["floorplans"].append({227 "name": name,228 "beds": beds,229 "baths": float(mba.group(1)) if mba else None,230 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,231 "price": price,232 "avail": avail,233 })234235 # commodités : « Buildings Features » (liste à puces de la fiche)236 h = soup.find(["h3", "h4"], string=re.compile(237 r"Buildings? Features", re.I))238 if h:239 ul = h.find_next("ul")240 if ul:241 amenities = []242 for li in ul.find_all("li"):243 t = li.get_text(" ", strip=True)244 if t and len(t) < 60 and t not in amenities:245 amenities.append(t)246 out["amenities"] = amenities[:25]247 return out248