SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
12.8 KB · 310 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/homestead.py : connecteur Homestead Land Holdings (homestead.ca)5#   Un des 5 plus gros gestionnaires de l'Ontario (~27 000 unités, siège à6#   Kingston) : Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo,7#   Kingston, Guelph, Sarnia… Le site est une SPA Rentsync « nouvelle8#   génération » (bundle cdn.rentsync.com/site/homestead_rebuild) qui parle à9#   la passerelle JSON PUBLIQUE (aucune auth, aucun anti-bot) :10#     https://website-gateway.rentsync.com/v1/homestead_rebuild/11#       properties?limit=500                        → 172 immeubles (adresse,12#           GPS, description, animaux, permaLink, cityId, modified)13#       cities/property-summary?limit=100           → cityId → nom + province14#       units?where=buildingId~in:a|b|…,status~in:enabled15#           → types d'unités avec bed/bath/pi²/prix/dispo/date (séparateur16#             multi-valeurs : « | » ; paginer via meta.totalPages)17#       properties/{id}/photos + /utilities         → galerie + services inclus18#   Photos : https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full/…19#   (clé S3 « homestead », PAS « homestead_rebuild » qui sert au contenu CMS).20#   On émet UNE annonce par type d'unité DISPONIBLE (available=1), regroupées21#   par (immeuble, type, cc, sdb) — certains immeubles listent chaque logement.22#   Fiche (photos+services) via le cache BD self.detail(), clé = modified.23# -----------------------------------------------------------------------------24from __future__ import annotations2526import hashlib27import os28import re2930from bs4 import BeautifulSoup3132from ..schema import Listing, normalize_unit_type33from .base import BaseConnector3435_ONTARIO = True  # Rent-Ka: always on (ROC scope)3637BASE = "https://www.homestead.ca"38GATEWAY = "https://website-gateway.rentsync.com/v1/homestead_rebuild"39# galerie S3 : tailles 512/768/1152/full — « full » validé live40IMG_BASE = "https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full"4142_ISO_DATE = re.compile(r"^20\d{2}-\d{2}-\d{2}")434445def _slug(s: str) -> str:46    return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")474849def _txt(html: str) -> str:50    """HTML de la passerelle → texte plat."""51    if not html:52        return ""53    return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)545556class HomesteadConnector(BaseConnector):57    source_id = "homestead"58    request_delay = 0.859    disabled = False60    max_properties = 300          # garde-fou (172 immeubles au 2026-08)61    max_images = 1562    chunk_size = 25               # immeubles par requête « units »6364    # -- passerelle JSON --------------------------------------------------------65    def _api(self, path: str, **params) -> dict:66        resp = self.get(f"{GATEWAY}/{path}", params=params,67                        headers={"Accept": "application/json",68                                 "Origin": BASE, "Referer": BASE + "/"})69        return resp.json()7071    def _api_all(self, path: str, **params) -> list[dict]:72        """Toutes les pages d'une collection (meta.totalPages)."""73        params.setdefault("limit", 500)74        out: list[dict] = []75        page = 176        while True:77            d = self._api(path, page=page, **params)78            out.extend(d.get("data") or [])79            meta = d.get("meta") or {}80            total = meta.get("totalPages") or 181            if page >= total:82                return out83            page += 18485    # -- villes : cityId → (nom, code province) ---------------------------------86    def _cities(self) -> dict[int, tuple[str, str]]:87        cities: dict[int, tuple[str, str]] = {}88        try:89            for c in self._api_all("cities/property-summary", limit=100):90                cid = c.get("cityId")91                if cid is not None:92                    cities[int(cid)] = ((c.get("cityName") or "").strip(),93                                        (c.get("provinceCode") or "").strip().upper())94        except Exception:95            pass    # repli : ville dérivée du permaLink dans _listing96        return cities9798    def fetch(self) -> list[Listing]:99        cities = self._cities()100        props = self._api_all("properties")[: self.max_properties]101        by_id = {int(p["id"]): p for p in props if p.get("id") is not None}102103        # types d'unités actifs, par lots d'immeubles (séparateur « | »)104        units: list[dict] = []105        ids = list(by_id)106        for i in range(0, len(ids), self.chunk_size):107            chunk = "|".join(str(x) for x in ids[i:i + self.chunk_size])108            try:109                units.extend(self._api_all(110                    "units", where=f"buildingId~in:{chunk},status~in:enabled"))111            except Exception:112                continue113114        # regrouper les unités DISPONIBLES par (immeuble, type, cc, sdb) —115        # certains immeubles publient une ligne par logement individuel116        groups: dict[tuple, list[dict]] = {}117        for u in units:118            if not u.get("available") or u.get("hideSuiteTypeWebsite"):119                continue120            bid = int(u.get("buildingId") or 0)121            if bid not in by_id:122                continue123            key = (bid, _slug(u.get("typeName") or ""),124                   u.get("bed"), u.get("bath"))125            groups.setdefault(key, []).append(u)126127        listings: list[Listing] = []128        for key, grp in groups.items():129            try:130                listings.append(self._listing(by_id[key[0]], grp, cities))131            except Exception:132                continue133        return listings134135    # -- une annonce par type d'unité disponible dans un immeuble ---------------136    def _listing(self, p: dict, grp: list[dict],137                 cities: dict[int, tuple[str, str]]) -> Listing:138        pid = int(p["id"])139        u0 = grp[0]140        type_name = (u0.get("typeName") or "").strip()141        name = (p.get("buildingName") or "").strip()142        perma = (p.get("permaLink") or "").strip()143        url = f"{BASE}/residential/{perma}" if perma else BASE144145        # ville : mapping cityId → nom officiel ; repli = dernier segment du slug146        city, prov = cities.get(int(p.get("cityId") or 0), ("", "ON"))147        if not city and perma:148            city = perma.rsplit("-", 1)[-1].replace("-", " ").title()149150        # adresse complète : rue + ville + ON + code postal151        street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),152                                      (p.get("streetName") or "").strip()) if x)153        postal = (p.get("postal") or "").strip()154        full_addr = ", ".join(x for x in (street, city) if x)155        if full_addr:156            full_addr += f", ON {postal}".rstrip()157158        # coordonnées GPS structurées de la passerelle159        try:160            lat = float(p["latitude"]) if p.get("latitude") else None161            lng = float(p["longitude"]) if p.get("longitude") else None162        except (TypeError, ValueError):163            lat = lng = None164165        # prix : plus bas tarif affichable du groupe (0 = prix masqué)166        rates = []167        for u in grp:168            if u.get("hideRateWebsites"):169                continue170            try:171                r = float(u.get("rate") or 0)172            except (TypeError, ValueError):173                r = 0.0174            if r > 0:175                rates.append(r)176        price = min(rates) if rates else None177        price_label = ""178        if price is not None:179            price_label = (f"À partir de {price:.0f} $"180                           if len(grp) > 1 or (rates and max(rates) != price)181                           else f"{price:.0f} $ /mois")182183        # disponibilité : plus proche date du groupe (None = maintenant)184        dates = sorted(str(u.get("availabilityDate") or "")[:10]185                       for u in grp if _ISO_DATE.match(186                           str(u.get("availabilityDate") or "")))187        avail_date = dates[0] if dates else None188        availability = (f"Disponible le {avail_date}" if avail_date189                        else "Disponible maintenant")190191        # superficie : plus petite valeur plausible du groupe192        area = None193        for u in grp:194            for k in ("sqFt", "sqFtMin"):195                try:196                    v = float(u.get(k) or 0)197                except (TypeError, ValueError):198                    continue199                if 80 <= v <= 20000 and (area is None or v < area):200                    area = v201202        # chambres / salles de bain : champs structurés de l'unité203        try:204            bedrooms = float(u0["bed"]) if u0.get("bed") is not None else None205            bathrooms = float(u0["bath"]) if u0.get("bath") is not None else None206        except (TypeError, ValueError):207            bedrooms = bathrooms = None208        unit_type = ("Studio" if bedrooms == 0209                     else normalize_unit_type(type_name))210211        # animaux : indicateurs structurés de l'immeuble212        if p.get("petsNotAllowed"):213            pets = "non"214        elif p.get("petFriendly"):215            pets = "oui"216        else:217            pets = None218219        # description : aperçu de l'immeuble + détails de suites (HTML → texte)220        desc = " ".join(x for x in (221            _txt(p.get("buildingOverview") or ""),222            _txt(p.get("suiteDetails") or ""),223        ) if x)[:800]224225        # commodités : caractéristiques de l'immeuble + services inclus (fiche)226        amenities: list[str] = []227        for t in re.split(r"[\n;•]|</li>|<li>",228                          p.get("buildingFeatures") or ""):229            t = _txt(t)230            if t and t not in amenities:231                amenities.append(t)232233        # champs structurés234        details: dict = {}235        contact: dict = {}236        if (p.get("phone") or "").strip():237            contact["phone"] = p["phone"].strip()238        if (p.get("email") or "").strip():239            contact["email"] = p["email"].strip()240        if contact:241            details["contact"] = contact242        if (p.get("neighbourhood") or "").strip():243            details["Quartier"] = p["neighbourhood"].strip()244245        # fiche (galerie photo + services inclus) via le cache BD : revisitée246        # seulement quand l'immeuble change (horodatage « modified »)247        det_key = hashlib.sha1(str(p.get("modified") or "").encode()).hexdigest()248        d = self.detail(f"b{pid}", det_key, lambda: self._fetch_detail(pid))249        for t in d.get("utilities") or []:250            t = f"{t} incluse" if t in ("Eau", "Électricité") else t251            if t and t not in amenities:252                amenities.append(t)253        images = list(d.get("images") or [])254255        return Listing(256            source=self.source_id,257            external_id=f"{pid}-{_slug(type_name) or 'unite'}-"258                        f"{u0.get('bed')}cc-{u0.get('bath')}sdb",259            url=url,260            title=f"{name} — {type_name}" if type_name else name,261            address=full_addr,262            sector=(p.get("neighbourhood") or "").strip(),263            city=city,264            province=prov or "ON",265            unit_type=unit_type,266            bedrooms=bedrooms,267            bathrooms=bathrooms,268            price=price,269            price_label=price_label,270            availability=availability,271            availability_date=avail_date,272            area_sqft=area,273            pets=pets,274            description=desc,275            amenities=amenities[:25],276            details=details,277            images=images[: self.max_images],278            lat=lat,279            lng=lng,280        )281282    # -- fiche immeuble : galerie photo + services inclus (2 appels, cachés) ----283    def _fetch_detail(self, pid: int) -> dict:284        out: dict = {"images": [], "utilities": []}285        # traductions FR des services inclus les plus fréquents286        fr = {"Water": "Eau", "Heat": "Chauffage", "Hydro": "Électricité",287              "Electricity": "Électricité", "Internet": "Internet",288              "Cable": "Câble"}289        try:290            photos = self._api_all(f"properties/{pid}/photos", limit=100)291        except Exception:292            photos = []293        photos = [ph for ph in photos294                  if ph.get("active") and (ph.get("image") or "").strip()]295        photos.sort(key=lambda ph: (0 if ph.get("mainGallery") else 1,296                                    ph.get("orderBy") or 0))297        for ph in photos:298            u = f"{IMG_BASE}/{ph['image'].strip()}"299            if u not in out["images"]:300                out["images"].append(u)301        out["images"] = out["images"][: self.max_images]302        try:303            for ut in self._api_all(f"properties/{pid}/utilities", limit=50):304                t = (ut.get("name") or "").strip()305                if t:306                    out["utilities"].append(fr.get(t, t))307        except Exception:308            pass309        return out310