Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/homestead.py : connecteur Homestead Land Holdings (homestead.ca)5# Un des 5 plus gros gestionnaires de l'Ontario (~27 000 unités, siège à6# Kingston) : Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo,7# Kingston, Guelph, Sarnia… Le site est une SPA Rentsync « nouvelle8# génération » (bundle cdn.rentsync.com/site/homestead_rebuild) qui parle à9# la passerelle JSON PUBLIQUE (aucune auth, aucun anti-bot) :10# https://website-gateway.rentsync.com/v1/homestead_rebuild/11# properties?limit=500 → 172 immeubles (adresse,12# GPS, description, animaux, permaLink, cityId, modified)13# cities/property-summary?limit=100 → cityId → nom + province14# units?where=buildingId~in:a|b|…,status~in:enabled15# → types d'unités avec bed/bath/pi²/prix/dispo/date (séparateur16# multi-valeurs : « | » ; paginer via meta.totalPages)17# properties/{id}/photos + /utilities → galerie + services inclus18# Photos : https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full/…19# (clé S3 « homestead », PAS « homestead_rebuild » qui sert au contenu CMS).20# On émet UNE annonce par type d'unité DISPONIBLE (available=1), regroupées21# par (immeuble, type, cc, sdb) — certains immeubles listent chaque logement.22# Fiche (photos+services) via le cache BD self.detail(), clé = modified.23# -----------------------------------------------------------------------------24from __future__ import annotations2526import hashlib27import os28import re2930from bs4 import BeautifulSoup3132from ..schema import Listing, normalize_unit_type33from .base import BaseConnector3435_ONTARIO = True # Rent-Ka: always on (ROC scope)3637BASE = "https://www.homestead.ca"38GATEWAY = "https://website-gateway.rentsync.com/v1/homestead_rebuild"39# galerie S3 : tailles 512/768/1152/full — « full » validé live40IMG_BASE = "https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full"4142_ISO_DATE = re.compile(r"^20\d{2}-\d{2}-\d{2}")434445def _slug(s: str) -> str:46 return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")474849def _txt(html: str) -> str:50 """HTML de la passerelle → texte plat."""51 if not html:52 return ""53 return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)545556class HomesteadConnector(BaseConnector):57 source_id = "homestead"58 request_delay = 0.859 disabled = False60 max_properties = 300 # garde-fou (172 immeubles au 2026-08)61 max_images = 1562 chunk_size = 25 # immeubles par requête « units »6364 # -- passerelle JSON --------------------------------------------------------65 def _api(self, path: str, **params) -> dict:66 resp = self.get(f"{GATEWAY}/{path}", params=params,67 headers={"Accept": "application/json",68 "Origin": BASE, "Referer": BASE + "/"})69 return resp.json()7071 def _api_all(self, path: str, **params) -> list[dict]:72 """Toutes les pages d'une collection (meta.totalPages)."""73 params.setdefault("limit", 500)74 out: list[dict] = []75 page = 176 while True:77 d = self._api(path, page=page, **params)78 out.extend(d.get("data") or [])79 meta = d.get("meta") or {}80 total = meta.get("totalPages") or 181 if page >= total:82 return out83 page += 18485 # -- villes : cityId → (nom, code province) ---------------------------------86 def _cities(self) -> dict[int, tuple[str, str]]:87 cities: dict[int, tuple[str, str]] = {}88 try:89 for c in self._api_all("cities/property-summary", limit=100):90 cid = c.get("cityId")91 if cid is not None:92 cities[int(cid)] = ((c.get("cityName") or "").strip(),93 (c.get("provinceCode") or "").strip().upper())94 except Exception:95 pass # repli : ville dérivée du permaLink dans _listing96 return cities9798 def fetch(self) -> list[Listing]:99 cities = self._cities()100 props = self._api_all("properties")[: self.max_properties]101 by_id = {int(p["id"]): p for p in props if p.get("id") is not None}102103 # types d'unités actifs, par lots d'immeubles (séparateur « | »)104 units: list[dict] = []105 ids = list(by_id)106 for i in range(0, len(ids), self.chunk_size):107 chunk = "|".join(str(x) for x in ids[i:i + self.chunk_size])108 try:109 units.extend(self._api_all(110 "units", where=f"buildingId~in:{chunk},status~in:enabled"))111 except Exception:112 continue113114 # regrouper les unités DISPONIBLES par (immeuble, type, cc, sdb) —115 # certains immeubles publient une ligne par logement individuel116 groups: dict[tuple, list[dict]] = {}117 for u in units:118 if not u.get("available") or u.get("hideSuiteTypeWebsite"):119 continue120 bid = int(u.get("buildingId") or 0)121 if bid not in by_id:122 continue123 key = (bid, _slug(u.get("typeName") or ""),124 u.get("bed"), u.get("bath"))125 groups.setdefault(key, []).append(u)126127 listings: list[Listing] = []128 for key, grp in groups.items():129 try:130 listings.append(self._listing(by_id[key[0]], grp, cities))131 except Exception:132 continue133 return listings134135 # -- une annonce par type d'unité disponible dans un immeuble ---------------136 def _listing(self, p: dict, grp: list[dict],137 cities: dict[int, tuple[str, str]]) -> Listing:138 pid = int(p["id"])139 u0 = grp[0]140 type_name = (u0.get("typeName") or "").strip()141 name = (p.get("buildingName") or "").strip()142 perma = (p.get("permaLink") or "").strip()143 url = f"{BASE}/residential/{perma}" if perma else BASE144145 # ville : mapping cityId → nom officiel ; repli = dernier segment du slug146 city, prov = cities.get(int(p.get("cityId") or 0), ("", "ON"))147 if not city and perma:148 city = perma.rsplit("-", 1)[-1].replace("-", " ").title()149150 # adresse complète : rue + ville + ON + code postal151 street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),152 (p.get("streetName") or "").strip()) if x)153 postal = (p.get("postal") or "").strip()154 full_addr = ", ".join(x for x in (street, city) if x)155 if full_addr:156 full_addr += f", ON {postal}".rstrip()157158 # coordonnées GPS structurées de la passerelle159 try:160 lat = float(p["latitude"]) if p.get("latitude") else None161 lng = float(p["longitude"]) if p.get("longitude") else None162 except (TypeError, ValueError):163 lat = lng = None164165 # prix : plus bas tarif affichable du groupe (0 = prix masqué)166 rates = []167 for u in grp:168 if u.get("hideRateWebsites"):169 continue170 try:171 r = float(u.get("rate") or 0)172 except (TypeError, ValueError):173 r = 0.0174 if r > 0:175 rates.append(r)176 price = min(rates) if rates else None177 price_label = ""178 if price is not None:179 price_label = (f"À partir de {price:.0f} $"180 if len(grp) > 1 or (rates and max(rates) != price)181 else f"{price:.0f} $ /mois")182183 # disponibilité : plus proche date du groupe (None = maintenant)184 dates = sorted(str(u.get("availabilityDate") or "")[:10]185 for u in grp if _ISO_DATE.match(186 str(u.get("availabilityDate") or "")))187 avail_date = dates[0] if dates else None188 availability = (f"Disponible le {avail_date}" if avail_date189 else "Disponible maintenant")190191 # superficie : plus petite valeur plausible du groupe192 area = None193 for u in grp:194 for k in ("sqFt", "sqFtMin"):195 try:196 v = float(u.get(k) or 0)197 except (TypeError, ValueError):198 continue199 if 80 <= v <= 20000 and (area is None or v < area):200 area = v201202 # chambres / salles de bain : champs structurés de l'unité203 try:204 bedrooms = float(u0["bed"]) if u0.get("bed") is not None else None205 bathrooms = float(u0["bath"]) if u0.get("bath") is not None else None206 except (TypeError, ValueError):207 bedrooms = bathrooms = None208 unit_type = ("Studio" if bedrooms == 0209 else normalize_unit_type(type_name))210211 # animaux : indicateurs structurés de l'immeuble212 if p.get("petsNotAllowed"):213 pets = "non"214 elif p.get("petFriendly"):215 pets = "oui"216 else:217 pets = None218219 # description : aperçu de l'immeuble + détails de suites (HTML → texte)220 desc = " ".join(x for x in (221 _txt(p.get("buildingOverview") or ""),222 _txt(p.get("suiteDetails") or ""),223 ) if x)[:800]224225 # commodités : caractéristiques de l'immeuble + services inclus (fiche)226 amenities: list[str] = []227 for t in re.split(r"[\n;•]|</li>|<li>",228 p.get("buildingFeatures") or ""):229 t = _txt(t)230 if t and t not in amenities:231 amenities.append(t)232233 # champs structurés234 details: dict = {}235 contact: dict = {}236 if (p.get("phone") or "").strip():237 contact["phone"] = p["phone"].strip()238 if (p.get("email") or "").strip():239 contact["email"] = p["email"].strip()240 if contact:241 details["contact"] = contact242 if (p.get("neighbourhood") or "").strip():243 details["Quartier"] = p["neighbourhood"].strip()244245 # fiche (galerie photo + services inclus) via le cache BD : revisitée246 # seulement quand l'immeuble change (horodatage « modified »)247 det_key = hashlib.sha1(str(p.get("modified") or "").encode()).hexdigest()248 d = self.detail(f"b{pid}", det_key, lambda: self._fetch_detail(pid))249 for t in d.get("utilities") or []:250 t = f"{t} incluse" if t in ("Eau", "Électricité") else t251 if t and t not in amenities:252 amenities.append(t)253 images = list(d.get("images") or [])254255 return Listing(256 source=self.source_id,257 external_id=f"{pid}-{_slug(type_name) or 'unite'}-"258 f"{u0.get('bed')}cc-{u0.get('bath')}sdb",259 url=url,260 title=f"{name} — {type_name}" if type_name else name,261 address=full_addr,262 sector=(p.get("neighbourhood") or "").strip(),263 city=city,264 province=prov or "ON",265 unit_type=unit_type,266 bedrooms=bedrooms,267 bathrooms=bathrooms,268 price=price,269 price_label=price_label,270 availability=availability,271 availability_date=avail_date,272 area_sqft=area,273 pets=pets,274 description=desc,275 amenities=amenities[:25],276 details=details,277 images=images[: self.max_images],278 lat=lat,279 lng=lng,280 )281282 # -- fiche immeuble : galerie photo + services inclus (2 appels, cachés) ----283 def _fetch_detail(self, pid: int) -> dict:284 out: dict = {"images": [], "utilities": []}285 # traductions FR des services inclus les plus fréquents286 fr = {"Water": "Eau", "Heat": "Chauffage", "Hydro": "Électricité",287 "Electricity": "Électricité", "Internet": "Internet",288 "Cable": "Câble"}289 try:290 photos = self._api_all(f"properties/{pid}/photos", limit=100)291 except Exception:292 photos = []293 photos = [ph for ph in photos294 if ph.get("active") and (ph.get("image") or "").strip()]295 photos.sort(key=lambda ph: (0 if ph.get("mainGallery") else 1,296 ph.get("orderBy") or 0))297 for ph in photos:298 u = f"{IMG_BASE}/{ph['image'].strip()}"299 if u not in out["images"]:300 out["images"].append(u)301 out["images"] = out["images"][: self.max_images]302 try:303 for ut in self._api_all(f"properties/{pid}/utilities", limit=50):304 t = (ut.get("name") or "").strip()305 if t:306 out["utilities"].append(fr.get(t, t))307 except Exception:308 pass309 return out310