# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/rakotta.py : connecteur Rakotta Real Estate (rakotta.com) # Centre-ville de Montréal — Appartements Dorchester (1555 René-Lévesque O.), # Gramercy Residences (1900-1950 Sherbrooke O.) et Le 2100 (2100 boul. de # Maisonneuve O.). Le site rakotta.com est une SPA Rentsync/LiftSystem # (client « rakotta_rebuild », clientId 484 — mêmes gens que # appartementsdorchester.com et residencesgramercy.com) ; l'API passerelle # publique website-gateway.rentsync.com/v1/rakotta_rebuild expose : # - /properties?findBy[status]=enabled : les immeubles actifs (adresse, # intersection, contact, stationnement) ; # - /units?where=status~in:enabled&limit=750 : TOUTES les unités (id # stable, numéro, prix, pi², chambres, sdb, étage, date de dispo, # drapeaux furnished/shortTerm et durée de bail leaseTerm) ; # - /photos?where=buildingId~in: : galeries (mainGallery), fichiers # servis par assets.rentsync.com/appartements_dorchester/images/gallery/. # ⚠️ Rakotta fait aussi du meublé à bail flexible (1 à 12 mois) : seuls les # BAUX 12 MOIS sont conservés (leaseTerm == "12-month", shortTerm == 0) — # le meublé 12 mois reste admissible (furnished=True). Les unités # d'immeubles absents de /properties (immeubles désactivés) sont exclues. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..schema import Listing from .base import BaseConnector GATEWAY = "https://website-gateway.rentsync.com/v1/rakotta_rebuild" SITE = "https://www.rakotta.com" ASSETS = ("https://assets.rentsync.com/appartements_dorchester" "/images/gallery/full/") _TAG_RE = re.compile(r"<[^>]+>") _WS_RE = re.compile(r"\s+") _LEASE_12_RE = re.compile(r"^\s*12[\s-]*month", re.I) def _num(v) -> float | None: try: return float(v) except (TypeError, ValueError): return None class RakottaConnector(BaseConnector): source_id = "rakotta" request_delay = 0.6 max_images = 10 def _api(self, path: str) -> list: data = self.get(f"{GATEWAY}/{path}").json() out = data.get("data") if isinstance(data, dict) else data return out if isinstance(out, list) else [] def fetch(self) -> list[Listing]: props = {p.get("id"): p for p in self._api("properties?findBy%5Bstatus%5D=enabled")} units = self._api("units?where=status~in:enabled&limit=750") # galerie par immeuble (une petite requête par immeuble actif) photos: dict[int, list[str]] = {} for bid in props: bucket: list[str] = [] try: rows = self._api(f"photos?where=buildingId~in:{bid}" f"&limit={self.max_images * 2}") except Exception: rows = [] for ph in rows: if not ph.get("active") or not ph.get("image"): continue if len(bucket) < self.max_images: bucket.append(ASSETS + ph["image"]) photos[bid] = bucket listings: list[Listing] = [] for u in units: try: lst = self._listing(u, props, photos) if lst: listings.append(lst) except Exception: continue # une unité ne bloque pas le reste # dédup par external_id (sécurité) uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values()) # -- une annonce par unité disponible (bail 12 mois seulement) -------------- def _listing(self, u: dict, props: dict, photos: dict) -> Listing | None: if not u.get("available"): return None prop = props.get(u.get("buildingId")) if not prop: return None # immeuble désactivé : exclu # long terme seulement : bail 12 mois, pas de court terme if u.get("shortTerm"): return None lease = str(u.get("leaseTerm") or "") if lease and not _LEASE_12_RE.match(lease): return None # bail 3/6 mois (mid-term) : exclu ext_id = str(u.get("id") or "") if not ext_id: return None num = str(u.get("number") or "").strip().lstrip("#") bname = _WS_RE.sub(" ", str(prop.get("buildingName") or "")).strip() bed = int(_num(u.get("bed")) or 0) bath = _num(u.get("bath")) unit_type = "Studio" if bed == 0 else f"{bed + 2}½" price = _num(u.get("rateMin")) or _num(u.get("rate")) if price is not None and not 100 <= price <= 20000: price = None area = _num(u.get("sqFtMin")) or _num(u.get("sqFt")) if area is not None and not 80 <= area <= 20000: area = None dispo = str(u.get("availabilityDate") or "").strip() availability = f"Libre le {dispo}" if dispo else "Disponible" furnished = None if u.get("furnished"): furnished = True elif u.get("unfurnished"): furnished = False # description : HTML doublement encodé par l'API (<p>…) desc = _html.unescape(_html.unescape(str(u.get("description") or ""))) desc = _WS_RE.sub(" ", _TAG_RE.sub(" ", desc)).strip() facts = " — ".join(x for x in [ str(u.get("typeName") or ""), f"{area:g} pi²" if area else "", f"étage {u['floor']}" if str(u.get("floor") or "").strip() else "", f"{bed} chambre(s)" if bed else "", f"{bath:g} salle(s) de bain" if bath else "", "bail 12 mois"] if x) desc = f"{desc}\n{facts}" if desc else facts details: dict = {"building": bname, "bedrooms": bed, "lease_term": "12 mois"} floor = str(u.get("floor") or "").strip() if floor.isdigit(): details["floor"] = int(floor) phone = str(prop.get("phone") or "").strip() if phone: details["contact"] = {"phone": phone} address = ", ".join(x for x in [ " ".join(x for x in [str(prop.get("streetNumber") or "").strip(), str(prop.get("streetName") or "").strip()] if x), "Montréal", str(prop.get("postal") or "").strip()] if x) return Listing( source=self.source_id, external_id=ext_id, url=f"{SITE}/properties/{prop.get('permaLink')}", title=(f"{bname} — Unité {num}" if num else bname), address=address, sector="Centre-ville", city="Montréal", unit_type=unit_type, bedrooms=float(bed), bathrooms=bath, price=price, availability=availability, area_sqft=area, furnished=furnished, description=desc[:900], details=details, images=list(photos.get(u.get("buildingId")) or []), )