Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/rakotta.py : connecteur Rakotta Real Estate (rakotta.com)5# Centre-ville de Montréal — Appartements Dorchester (1555 René-Lévesque O.),6# Gramercy Residences (1900-1950 Sherbrooke O.) et Le 2100 (2100 boul. de7# Maisonneuve O.). Le site rakotta.com est une SPA Rentsync/LiftSystem8# (client « rakotta_rebuild », clientId 484 — mêmes gens que9# appartementsdorchester.com et residencesgramercy.com) ; l'API passerelle10# publique website-gateway.rentsync.com/v1/rakotta_rebuild expose :11# - /properties?findBy[status]=enabled : les immeubles actifs (adresse,12# intersection, contact, stationnement) ;13# - /units?where=status~in:enabled&limit=750 : TOUTES les unités (id14# stable, numéro, prix, pi², chambres, sdb, étage, date de dispo,15# drapeaux furnished/shortTerm et durée de bail leaseTerm) ;16# - /photos?where=buildingId~in:<ids> : galeries (mainGallery), fichiers17# servis par assets.rentsync.com/appartements_dorchester/images/gallery/.18# ⚠️ Rakotta fait aussi du meublé à bail flexible (1 à 12 mois) : seuls les19# BAUX 12 MOIS sont conservés (leaseTerm == "12-month", shortTerm == 0) —20# le meublé 12 mois reste admissible (furnished=True). Les unités21# d'immeubles absents de /properties (immeubles désactivés) sont exclues.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import html as _html26import re2728from ..schema import Listing29from .base import BaseConnector3031GATEWAY = "https://website-gateway.rentsync.com/v1/rakotta_rebuild"32SITE = "https://www.rakotta.com"33ASSETS = ("https://assets.rentsync.com/appartements_dorchester"34 "/images/gallery/full/")3536_TAG_RE = re.compile(r"<[^>]+>")37_WS_RE = re.compile(r"\s+")38_LEASE_12_RE = re.compile(r"^\s*12[\s-]*month", re.I)394041def _num(v) -> float | None:42 try:43 return float(v)44 except (TypeError, ValueError):45 return None464748class RakottaConnector(BaseConnector):49 source_id = "rakotta"50 request_delay = 0.651 max_images = 105253 def _api(self, path: str) -> list:54 data = self.get(f"{GATEWAY}/{path}").json()55 out = data.get("data") if isinstance(data, dict) else data56 return out if isinstance(out, list) else []5758 def fetch(self) -> list[Listing]:59 props = {p.get("id"): p for p in60 self._api("properties?findBy%5Bstatus%5D=enabled")}61 units = self._api("units?where=status~in:enabled&limit=750")6263 # galerie par immeuble (une petite requête par immeuble actif)64 photos: dict[int, list[str]] = {}65 for bid in props:66 bucket: list[str] = []67 try:68 rows = self._api(f"photos?where=buildingId~in:{bid}"69 f"&limit={self.max_images * 2}")70 except Exception:71 rows = []72 for ph in rows:73 if not ph.get("active") or not ph.get("image"):74 continue75 if len(bucket) < self.max_images:76 bucket.append(ASSETS + ph["image"])77 photos[bid] = bucket7879 listings: list[Listing] = []80 for u in units:81 try:82 lst = self._listing(u, props, photos)83 if lst:84 listings.append(lst)85 except Exception:86 continue # une unité ne bloque pas le reste8788 # dédup par external_id (sécurité)89 uniq: dict[str, Listing] = {}90 for lst in listings:91 uniq.setdefault(lst.external_id, lst)92 return list(uniq.values())9394 # -- une annonce par unité disponible (bail 12 mois seulement) --------------95 def _listing(self, u: dict, props: dict, photos: dict) -> Listing | None:96 if not u.get("available"):97 return None98 prop = props.get(u.get("buildingId"))99 if not prop:100 return None # immeuble désactivé : exclu101 # long terme seulement : bail 12 mois, pas de court terme102 if u.get("shortTerm"):103 return None104 lease = str(u.get("leaseTerm") or "")105 if lease and not _LEASE_12_RE.match(lease):106 return None # bail 3/6 mois (mid-term) : exclu107108 ext_id = str(u.get("id") or "")109 if not ext_id:110 return None111 num = str(u.get("number") or "").strip().lstrip("#")112 bname = _WS_RE.sub(" ", str(prop.get("buildingName") or "")).strip()113114 bed = int(_num(u.get("bed")) or 0)115 bath = _num(u.get("bath"))116 unit_type = "Studio" if bed == 0 else f"{bed + 2}½"117118 price = _num(u.get("rateMin")) or _num(u.get("rate"))119 if price is not None and not 100 <= price <= 20000:120 price = None121 area = _num(u.get("sqFtMin")) or _num(u.get("sqFt"))122 if area is not None and not 80 <= area <= 20000:123 area = None124125 dispo = str(u.get("availabilityDate") or "").strip()126 availability = f"Libre le {dispo}" if dispo else "Disponible"127128 furnished = None129 if u.get("furnished"):130 furnished = True131 elif u.get("unfurnished"):132 furnished = False133134 # description : HTML doublement encodé par l'API (<p>…)135 desc = _html.unescape(_html.unescape(str(u.get("description") or "")))136 desc = _WS_RE.sub(" ", _TAG_RE.sub(" ", desc)).strip()137 facts = " — ".join(x for x in [138 str(u.get("typeName") or ""),139 f"{area:g} pi²" if area else "",140 f"étage {u['floor']}" if str(u.get("floor") or "").strip()141 else "",142 f"{bed} chambre(s)" if bed else "",143 f"{bath:g} salle(s) de bain" if bath else "",144 "bail 12 mois"] if x)145 desc = f"{desc}\n{facts}" if desc else facts146147 details: dict = {"building": bname, "bedrooms": bed,148 "lease_term": "12 mois"}149 floor = str(u.get("floor") or "").strip()150 if floor.isdigit():151 details["floor"] = int(floor)152 phone = str(prop.get("phone") or "").strip()153 if phone:154 details["contact"] = {"phone": phone}155156 address = ", ".join(x for x in [157 " ".join(x for x in [str(prop.get("streetNumber") or "").strip(),158 str(prop.get("streetName") or "").strip()]159 if x),160 "Montréal",161 str(prop.get("postal") or "").strip()] if x)162163 return Listing(164 source=self.source_id,165 external_id=ext_id,166 url=f"{SITE}/properties/{prop.get('permaLink')}",167 title=(f"{bname} — Unité {num}" if num else bname),168 address=address,169 sector="Centre-ville",170 city="Montréal",171 unit_type=unit_type,172 bedrooms=float(bed),173 bathrooms=bath,174 price=price,175 availability=availability,176 area_sqft=area,177 furnished=furnished,178 description=desc[:900],179 details=details,180 images=list(photos.get(u.get("buildingId")) or []),181 )182