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/hazelview.py : Hazelview Properties5# (hazelviewproperties.com — ex-Timbercreek). The site is client-rendered6# over the RentSync/LiftSystem API (lift-api.rentsync.com/v2, client_id7# 497, public token embedded in the site JS). /v2/cities is queried and8# EVERY city outside Québec is kept (Ottawa, GTA, Hamilton, London, KW,9# Halifax, Calgary… whatever the API publishes), then /v2/search per city10# and bedroom count returns the available unit types and rents. One11# listing per building per unit type. The building page (via the DB12# cache) embeds a full JSON (map widget's data-locations attribute):13# amenities, photo gallery, detailed pet policy, suites.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as htmllib18import json19import os20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, strip_accents25from .base import BaseConnector2627GALLERY = "https://assets.rentsync.com/timbercreek_communities/images/gallery/full/"2829API = "https://lift-api.rentsync.com/v2"30CLIENT_ID = "497"31AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (présent dans main.js)3233SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false"34 "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000")3536# (min_bed, max_bed, unit type)37_BED_QUERIES = [(0, 0, "Studio"), (1, 1, "1 bedroom"), (2, 2, "2 bedrooms"),38 (3, 3, "3 bedrooms"), (4, 5, "4 bedrooms")]39_TAG_RE = re.compile(r"<[^>]+>")404142def _pets_from_flags(d: dict) -> str | None:43 """Drapeaux animaux du JSON immeuble ('1'/'0'/None) -> oui/non/conditions."""44 def flag(k):45 v = d.get(k)46 return None if v in (None, "") else str(v) == "1"47 if flag("pets_not_allowed"):48 return "non"49 small, cats, large = (flag("pets_small_dogs"), flag("pets_cats"),50 flag("pets_large_dogs"))51 if any(v for v in (small, cats, large)):52 # certains types refusés explicitement -> sous conditions53 if False in (small, cats, large):54 return "conditions"55 return "oui"56 if flag("pet_friendly"):57 return "oui"58 if flag("pet_friendly") is False:59 return "non"60 return None616263class HazelviewConnector(BaseConnector):64 source_id = "hazelview"65 request_delay = 0.666 max_cities = 60 # safety cap (cities outside QC)67 max_details = 400 # safety cap on building pages (real requests)6869 def _api(self, path: str, extra: str = "") -> list | dict:70 url = (f"{API}/{path}?client_id={CLIENT_ID}&auth_token={AUTH_TOKEN}"71 f"&locale=en{('&' + extra) if extra else ''}")72 return self.get(url).json()7374 def fetch(self) -> list[Listing]:75 cities = self._api("cities")76 roc = [] # every city outside Québec published by the API77 for c in cities if isinstance(cities, list) else []:78 code = (c.get("province_code") or "").upper()79 if not code or code == "QC":80 continue # Québec is Rent-Ka's territory81 name = (c.get("city_name") or "").strip()82 if name:83 roc.append((c.get("id"), name, code))8485 listings: list[Listing] = []86 bed_range: dict[str, tuple[int, int]] = {}8788 # (city_id, display city, forced sector, province)89 targets = [(cid, name, None, code)90 for cid, name, code in roc[: self.max_cities]]9192 for city_id, city, forced_sector, province in targets:93 for min_bed, max_bed, unit_type in _BED_QUERIES:94 try:95 props = self._api(96 "search",97 f"city_ids={city_id}&min_bed={min_bed}"98 f"&max_bed={max_bed}&{SEARCH_PARAMS}&limit=50")99 except Exception:100 continue101 if not isinstance(props, list):102 continue103 for p in props:104 try:105 lst = self._prop_listing(p, unit_type, min_bed,106 city, forced_sector,107 province=province)108 if lst:109 listings.append(lst)110 bed_range[lst.external_id] = (min_bed, max_bed)111 except Exception:112 continue113114 # Page immeuble du site (cache BD, 1 requête par immeuble) : le JSON115 # embarqué (data-locations) fournit commodités, galerie, animaux116 # détaillés et suites (superficie, date de disponibilité par unité)117 self._fetched = 0118 memo: dict[str, dict] = {}119 for lst in listings:120 pid = lst.external_id.split("-")[0]121 if not lst.url:122 continue123 if pid not in memo:124 key = f"{pid}|{lst.availability}|{lst.address}"125 try:126 memo[pid] = self.detail(127 pid, key, lambda u=lst.url: self._fetch_building(u))128 except Exception:129 memo[pid] = {}130 mn, mx = bed_range.get(lst.external_id, (None, None))131 self._apply_building(lst, memo[pid], mn, mx)132 return listings133134 def _prop_listing(self, p: dict, unit_type: str, beds: int,135 city: str, forced_sector: str | None,136 province: str = "ON") -> Listing | None:137 if not p.get("availability_count"):138 return None139 addr = p.get("address") or {}140 stats = ((p.get("statistics") or {}).get("suites") or {})141 rates = stats.get("rates") or {}142 rmin, rmax = rates.get("min"), rates.get("max")143 sq = stats.get("square_feet") or {}144145 def _num(v):146 try:147 return float(v)148 except (TypeError, ValueError):149 return None150151 rmin, rmax = _num(rmin), _num(rmax)152 price = rmin153 if rmin and rmax and rmax != rmin:154 price_label = f"À partir de {int(rmin)} $ (max {int(rmax)} $)"155 elif rmin:156 price_label = f"{int(rmin)} $/mois"157 else:158 price_label = ""159160 sector = forced_sector or (addr.get("neighbourhood") or "").strip()161 details = p.get("details") or {}162 desc = _TAG_RE.sub(" ", details.get("overview") or "")163 desc = re.sub(r"\s+", " ", desc).strip()[:500]164 sbits = []165 sqmin, sqmax = _num(sq.get("min")), _num(sq.get("max"))166 if sqmin:167 sqtxt = (f"{int(sqmin)}-{int(sqmax)}"168 if sqmax and sqmax != sqmin else f"{int(sqmin)}")169 sbits.append(f"{sqtxt} pi²")170 sbits.append(f"{p['availability_count']} unité(s) disponible(s)")171172 amenities = []173 feats = _TAG_RE.sub("|", details.get("features") or "")174 for f in feats.split("|"):175 f = f.strip()176 if 2 < len(f) < 60 and f not in amenities:177 amenities.append(f)178 amenities = amenities[:20]179180 images = []181 if p.get("photo_path"):182 images.append(p["photo_path"])183184 pid = p.get("id")185 name = (p.get("name") or "").strip()186 geo = p.get("geocode") or {}187 try:188 lat = float(geo.get("latitude"))189 lng = float(geo.get("longitude"))190 except (TypeError, ValueError):191 lat = lng = None192193 # champs structurés de l'API : animaux (bool), contact de location194 pets = None195 if isinstance(p.get("pet_friendly"), bool):196 pets = "oui" if p["pet_friendly"] else "non"197 details: dict = {}198 contact = p.get("contact") or {}199 cinfo: dict = {}200 if (contact.get("phone") or "").strip():201 cinfo["phone"] = contact["phone"].strip()202 for em in (contact.get("email") or "").split(","):203 em = em.strip()204 if em and "leadmanaging" not in em: # relais de tracking exclu205 cinfo["email"] = em206 break207 if cinfo:208 details["contact"] = cinfo209210 return Listing(211 source=self.source_id,212 external_id=f"{pid}-{beds}bed",213 url=p.get("permalink") or "",214 title=f"{name} — {unit_type}",215 address=", ".join(x for x in [216 (addr.get("address") or "").strip(), city, province,217 (addr.get("postal_code") or "").strip()] if x),218 sector=sector,219 city=city,220 province=province,221 unit_type=unit_type,222 price=price,223 price_label=price_label,224 availability=(p.get("min_availability_date")225 or p.get("availability_status_label") or ""),226 area_sqft=sqmin if sqmin else None, # stats API (min du type)227 pets=pets,228 description=" — ".join([desc] + sbits if desc else sbits)[:600],229 amenities=amenities,230 details=details,231 images=images,232 lat=lat,233 lng=lng,234 )235236 # -- page immeuble du site (JSON embarqué data-locations) ------------------237 def _fetch_building(self, url: str) -> dict:238 """Extrait le JSON immeuble embarqué dans la page (widget carte) :239 commodités, services inclus, galerie, animaux détaillés, suites."""240 if self._fetched >= self.max_details:241 raise RuntimeError("budget de pages immeuble atteint")242 self._fetched += 1243 html = self.get(url).text244 soup = BeautifulSoup(html, "html.parser")245 el = soup.select_one("[data-locations]")246 if not el:247 return {}248 raw = el.get("data-locations") or ""249 try:250 data = json.loads(raw)251 except Exception:252 data = json.loads(htmllib.unescape(raw))253 node = (data[0] if isinstance(data, list) and data else {}) or {}254 d = node.get("data") or {}255 out: dict = {}256257 out["amenities"] = [a.get("name", "").strip()258 for a in (d.get("Amenities") or [])259 if a.get("name", "").strip()][:30]260 out["utilities"] = [u.get("name", "").strip()261 for u in (d.get("Utilities") or [])262 if isinstance(u, dict) and u.get("name", "").strip()]263 out["photos"] = [GALLERY + ph["image"]264 for ph in (d.get("photos") or [])265 if ph.get("image")][:15]266 out["pets_flags"] = {k: d.get(k) for k in267 ("pet_friendly", "pets_small_dogs",268 "pets_large_dogs", "pets_cats",269 "pets_not_allowed") if d.get(k) is not None}270 out["pets_details"] = (d.get("pets_details") or "").strip()271 out["suites"] = [{272 "bed": s.get("bed"), "available": s.get("available"),273 "availability_date": s.get("availability_date"),274 "sq_ft": s.get("sq_ft"), "furnished": s.get("furnished"),275 } for s in (d.get("suites") or [])]276 return out277278 def _apply_building(self, lst: Listing, d: dict,279 min_bed: int | None, max_bed: int | None) -> None:280 """Reporte le JSON immeuble (frais/cache) sur l'annonce."""281 if not d:282 return283 merged = list(dict.fromkeys(284 lst.amenities + (d.get("amenities") or []) +285 (d.get("utilities") or [])))286 if merged:287 lst.amenities = merged[:30]288 if d.get("photos"):289 lst.images = list(dict.fromkeys(lst.images + d["photos"]))[:15]290 pets = _pets_from_flags(d.get("pets_flags") or {})291 if pets:292 lst.pets = pets293294 # suites du type demandé : superficie et date précise si publiées295 suites = []296 for s in d.get("suites") or []:297 try:298 bed = int(s.get("bed"))299 except (TypeError, ValueError):300 continue301 if min_bed is None or not (min_bed <= bed <= (max_bed or min_bed)):302 continue303 if str(s.get("available")) == "1":304 suites.append(s)305 if suites:306 if lst.area_sqft is None:307 sqs = []308 for s in suites:309 try:310 v = float(s.get("sq_ft") or 0)311 except (TypeError, ValueError):312 v = 0313 if v >= 80:314 sqs.append(v)315 if sqs:316 lst.area_sqft = min(sqs)317 dates = [s.get("availability_date") for s in suites318 if s.get("availability_date")319 and not str(s["availability_date"]).startswith("0000")]320 if dates and len(dates) == len(suites):321 # toutes les unités du type ont une date précise publiée322 lst.availability = min(dates)323 if all(str(s.get("furnished")) == "1" for s in suites):324 lst.furnished = True325