SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.3 KB · 182 lines python
Raw Blame History
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 (&lt;p&gt;…)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