# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/hazelview.py : connecteur Hazelview Properties # (hazelviewproperties.com — ex-Timbercreek). Le site est rendu côté client # via l'API RentSync/LiftSystem (lift-api.rentsync.com/v2, client_id 497, # jeton public embarqué dans le JS du site). On interroge /v2/cities pour # les villes QC (toutes dans le Grand Montréal : Montréal, Verdun, # Côte-Saint-Luc, Pointe-Claire, Longueuil...), puis /v2/search par ville et # par nombre de chambres pour obtenir les types d'unités disponibles et leur # loyer. Une annonce par immeuble et par type d'unité. La page immeuble du # site (via cache BD) embarque un JSON complet (attribut data-locations de # la carte) : commodités, galerie photos, animaux détaillés, suites. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector GALLERY = "https://assets.rentsync.com/timbercreek_communities/images/gallery/full/" API = "https://lift-api.rentsync.com/v2" CLIENT_ID = "497" AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (présent dans main.js) SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false" "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000") # Villes QC admissibles (Grand Montréal) -> (ville, secteur imposé) _GM_CITIES = { "montreal": ("Montréal", None), "verdun": ("Montréal", "Verdun"), "cote-saint-luc": ("Côte-Saint-Luc", None), "dollard-des-ormeaux": ("Dollard-des-Ormeaux", None), "pointe-claire": ("Pointe-Claire", None), "longueuil": ("Longueuil", None), "lasalle": ("Montréal", "LaSalle"), } # (min_bed, max_bed, type d'unité) _BED_QUERIES = [(0, 0, "Studio"), (1, 1, "3½"), (2, 2, "4½"), (3, 3, "5½"), (4, 5, "6½")] _TAG_RE = re.compile(r"<[^>]+>") def _pets_from_flags(d: dict) -> str | None: """Drapeaux animaux du JSON immeuble ('1'/'0'/None) -> oui/non/conditions.""" def flag(k): v = d.get(k) return None if v in (None, "") else str(v) == "1" if flag("pets_not_allowed"): return "non" small, cats, large = (flag("pets_small_dogs"), flag("pets_cats"), flag("pets_large_dogs")) if any(v for v in (small, cats, large)): # certains types refusés explicitement -> sous conditions if False in (small, cats, large): return "conditions" return "oui" if flag("pet_friendly"): return "oui" if flag("pet_friendly") is False: return "non" return None class HazelviewConnector(BaseConnector): source_id = "hazelview" request_delay = 0.6 max_cities = 10 # garde-fou max_details = 150 # garde-fou pages immeuble (vraies requêtes) def _api(self, path: str, extra: str = "") -> list | dict: url = (f"{API}/{path}?client_id={CLIENT_ID}&auth_token={AUTH_TOKEN}" f"&locale=en{('&' + extra) if extra else ''}") return self.get(url).json() def fetch(self) -> list[Listing]: cities = self._api("cities") qc = [] for c in cities if isinstance(cities, list) else []: if (c.get("province_code") or "").upper() != "QC": continue key = strip_accents((c.get("city_name") or "").strip().lower()) if key in _GM_CITIES: qc.append((c.get("id"), key)) listings: list[Listing] = [] bed_range: dict[str, tuple[int, int]] = {} for city_id, key in qc[: self.max_cities]: city, forced_sector = _GM_CITIES[key] for min_bed, max_bed, unit_type in _BED_QUERIES: try: props = self._api( "search", f"city_ids={city_id}&min_bed={min_bed}" f"&max_bed={max_bed}&{SEARCH_PARAMS}&limit=50") except Exception: continue if not isinstance(props, list): continue for p in props: try: lst = self._prop_listing(p, unit_type, min_bed, city, forced_sector) if lst: listings.append(lst) bed_range[lst.external_id] = (min_bed, max_bed) except Exception: continue # Page immeuble du site (cache BD, 1 requête par immeuble) : le JSON # embarqué (data-locations) fournit commodités, galerie, animaux # détaillés et suites (superficie, date de disponibilité par unité) self._fetched = 0 memo: dict[str, dict] = {} for lst in listings: pid = lst.external_id.split("-")[0] if not lst.url: continue if pid not in memo: key = f"{pid}|{lst.availability}|{lst.address}" try: memo[pid] = self.detail( pid, key, lambda u=lst.url: self._fetch_building(u)) except Exception: memo[pid] = {} mn, mx = bed_range.get(lst.external_id, (None, None)) self._apply_building(lst, memo[pid], mn, mx) return listings def _prop_listing(self, p: dict, unit_type: str, beds: int, city: str, forced_sector: str | None) -> Listing | None: if not p.get("availability_count"): return None addr = p.get("address") or {} stats = ((p.get("statistics") or {}).get("suites") or {}) rates = stats.get("rates") or {} rmin, rmax = rates.get("min"), rates.get("max") sq = stats.get("square_feet") or {} def _num(v): try: return float(v) except (TypeError, ValueError): return None rmin, rmax = _num(rmin), _num(rmax) price = rmin if rmin and rmax and rmax != rmin: price_label = f"À partir de {int(rmin)} $ (max {int(rmax)} $)" elif rmin: price_label = f"{int(rmin)} $/mois" else: price_label = "" sector = forced_sector or (addr.get("neighbourhood") or "").strip() details = p.get("details") or {} desc = _TAG_RE.sub(" ", details.get("overview") or "") desc = re.sub(r"\s+", " ", desc).strip()[:500] sbits = [] sqmin, sqmax = _num(sq.get("min")), _num(sq.get("max")) if sqmin: sqtxt = (f"{int(sqmin)}-{int(sqmax)}" if sqmax and sqmax != sqmin else f"{int(sqmin)}") sbits.append(f"{sqtxt} pi²") sbits.append(f"{p['availability_count']} unité(s) disponible(s)") amenities = [] feats = _TAG_RE.sub("|", details.get("features") or "") for f in feats.split("|"): f = f.strip() if 2 < len(f) < 60 and f not in amenities: amenities.append(f) amenities = amenities[:20] images = [] if p.get("photo_path"): images.append(p["photo_path"]) pid = p.get("id") name = (p.get("name") or "").strip() geo = p.get("geocode") or {} try: lat = float(geo.get("latitude")) lng = float(geo.get("longitude")) except (TypeError, ValueError): lat = lng = None # champs structurés de l'API : animaux (bool), contact de location pets = None if isinstance(p.get("pet_friendly"), bool): pets = "oui" if p["pet_friendly"] else "non" details: dict = {} contact = p.get("contact") or {} cinfo: dict = {} if (contact.get("phone") or "").strip(): cinfo["phone"] = contact["phone"].strip() for em in (contact.get("email") or "").split(","): em = em.strip() if em and "leadmanaging" not in em: # relais de tracking exclu cinfo["email"] = em break if cinfo: details["contact"] = cinfo return Listing( source=self.source_id, external_id=f"{pid}-{beds}bed", url=p.get("permalink") or "", title=f"{name} — {unit_type}", address=", ".join(x for x in [ (addr.get("address") or "").strip(), city, (addr.get("postal_code") or "").strip()] if x), sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=(p.get("min_availability_date") or p.get("availability_status_label") or ""), area_sqft=sqmin if sqmin else None, # stats API (min du type) pets=pets, description=" — ".join([desc] + sbits if desc else sbits)[:600], amenities=amenities, details=details, images=images, lat=lat, lng=lng, ) # -- page immeuble du site (JSON embarqué data-locations) ------------------ def _fetch_building(self, url: str) -> dict: """Extrait le JSON immeuble embarqué dans la page (widget carte) : commodités, services inclus, galerie, animaux détaillés, suites.""" if self._fetched >= self.max_details: raise RuntimeError("budget de pages immeuble atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") el = soup.select_one("[data-locations]") if not el: return {} raw = el.get("data-locations") or "" try: data = json.loads(raw) except Exception: data = json.loads(htmllib.unescape(raw)) node = (data[0] if isinstance(data, list) and data else {}) or {} d = node.get("data") or {} out: dict = {} out["amenities"] = [a.get("name", "").strip() for a in (d.get("Amenities") or []) if a.get("name", "").strip()][:30] out["utilities"] = [u.get("name", "").strip() for u in (d.get("Utilities") or []) if isinstance(u, dict) and u.get("name", "").strip()] out["photos"] = [GALLERY + ph["image"] for ph in (d.get("photos") or []) if ph.get("image")][:15] out["pets_flags"] = {k: d.get(k) for k in ("pet_friendly", "pets_small_dogs", "pets_large_dogs", "pets_cats", "pets_not_allowed") if d.get(k) is not None} out["pets_details"] = (d.get("pets_details") or "").strip() out["suites"] = [{ "bed": s.get("bed"), "available": s.get("available"), "availability_date": s.get("availability_date"), "sq_ft": s.get("sq_ft"), "furnished": s.get("furnished"), } for s in (d.get("suites") or [])] return out def _apply_building(self, lst: Listing, d: dict, min_bed: int | None, max_bed: int | None) -> None: """Reporte le JSON immeuble (frais/cache) sur l'annonce.""" if not d: return merged = list(dict.fromkeys( lst.amenities + (d.get("amenities") or []) + (d.get("utilities") or []))) if merged: lst.amenities = merged[:30] if d.get("photos"): lst.images = list(dict.fromkeys(lst.images + d["photos"]))[:15] pets = _pets_from_flags(d.get("pets_flags") or {}) if pets: lst.pets = pets # suites du type demandé : superficie et date précise si publiées suites = [] for s in d.get("suites") or []: try: bed = int(s.get("bed")) except (TypeError, ValueError): continue if min_bed is None or not (min_bed <= bed <= (max_bed or min_bed)): continue if str(s.get("available")) == "1": suites.append(s) if suites: if lst.area_sqft is None: sqs = [] for s in suites: try: v = float(s.get("sq_ft") or 0) except (TypeError, ValueError): v = 0 if v >= 80: sqs.append(v) if sqs: lst.area_sqft = min(sqs) dates = [s.get("availability_date") for s in suites if s.get("availability_date") and not str(s["availability_date"]).startswith("0000")] if dates and len(dates) == len(suites): # toutes les unités du type ont une date précise publiée lst.availability = min(dates) if all(str(s.get("furnished")) == "1" for s in suites): lst.furnished = True