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%
13.1 KB · 325 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/hazelview.py : Hazelview Properties5#   (hazelviewproperties.com — ex-Timbercreek). The site is client-rendered6#   over the RentSync/LiftSystem API (lift-api.rentsync.com/v2, client_id7#   497, public token embedded in the site JS). /v2/cities is queried and8#   EVERY city outside Québec is kept (Ottawa, GTA, Hamilton, London, KW,9#   Halifax, Calgary… whatever the API publishes), then /v2/search per city10#   and bedroom count returns the available unit types and rents. One11#   listing per building per unit type. The building page (via the DB12#   cache) embeds a full JSON (map widget's data-locations attribute):13#   amenities, photo gallery, detailed pet policy, suites.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as htmllib18import json19import os20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, strip_accents25from .base import BaseConnector2627GALLERY = "https://assets.rentsync.com/timbercreek_communities/images/gallery/full/"2829API = "https://lift-api.rentsync.com/v2"30CLIENT_ID = "497"31AUTH_TOKEN = "sswpREkUtyeYjeoahA2i"    # jeton public (présent dans main.js)3233SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false"34                 "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000")3536# (min_bed, max_bed, unit type)37_BED_QUERIES = [(0, 0, "Studio"), (1, 1, "1 bedroom"), (2, 2, "2 bedrooms"),38                (3, 3, "3 bedrooms"), (4, 5, "4 bedrooms")]39_TAG_RE = re.compile(r"<[^>]+>")404142def _pets_from_flags(d: dict) -> str | None:43    """Drapeaux animaux du JSON immeuble ('1'/'0'/None) -> oui/non/conditions."""44    def flag(k):45        v = d.get(k)46        return None if v in (None, "") else str(v) == "1"47    if flag("pets_not_allowed"):48        return "non"49    small, cats, large = (flag("pets_small_dogs"), flag("pets_cats"),50                          flag("pets_large_dogs"))51    if any(v for v in (small, cats, large)):52        # certains types refusés explicitement -> sous conditions53        if False in (small, cats, large):54            return "conditions"55        return "oui"56    if flag("pet_friendly"):57        return "oui"58    if flag("pet_friendly") is False:59        return "non"60    return None616263class HazelviewConnector(BaseConnector):64    source_id = "hazelview"65    request_delay = 0.666    max_cities = 60          # safety cap (cities outside QC)67    max_details = 400        # safety cap on building pages (real requests)6869    def _api(self, path: str, extra: str = "") -> list | dict:70        url = (f"{API}/{path}?client_id={CLIENT_ID}&auth_token={AUTH_TOKEN}"71               f"&locale=en{('&' + extra) if extra else ''}")72        return self.get(url).json()7374    def fetch(self) -> list[Listing]:75        cities = self._api("cities")76        roc = []   # every city outside Québec published by the API77        for c in cities if isinstance(cities, list) else []:78            code = (c.get("province_code") or "").upper()79            if not code or code == "QC":80                continue   # Québec is Rent-Ka's territory81            name = (c.get("city_name") or "").strip()82            if name:83                roc.append((c.get("id"), name, code))8485        listings: list[Listing] = []86        bed_range: dict[str, tuple[int, int]] = {}8788        # (city_id, display city, forced sector, province)89        targets = [(cid, name, None, code)90                   for cid, name, code in roc[: self.max_cities]]9192        for city_id, city, forced_sector, province in targets:93            for min_bed, max_bed, unit_type in _BED_QUERIES:94                try:95                    props = self._api(96                        "search",97                        f"city_ids={city_id}&min_bed={min_bed}"98                        f"&max_bed={max_bed}&{SEARCH_PARAMS}&limit=50")99                except Exception:100                    continue101                if not isinstance(props, list):102                    continue103                for p in props:104                    try:105                        lst = self._prop_listing(p, unit_type, min_bed,106                                                 city, forced_sector,107                                                 province=province)108                        if lst:109                            listings.append(lst)110                            bed_range[lst.external_id] = (min_bed, max_bed)111                    except Exception:112                        continue113114        # Page immeuble du site (cache BD, 1 requête par immeuble) : le JSON115        # embarqué (data-locations) fournit commodités, galerie, animaux116        # détaillés et suites (superficie, date de disponibilité par unité)117        self._fetched = 0118        memo: dict[str, dict] = {}119        for lst in listings:120            pid = lst.external_id.split("-")[0]121            if not lst.url:122                continue123            if pid not in memo:124                key = f"{pid}|{lst.availability}|{lst.address}"125                try:126                    memo[pid] = self.detail(127                        pid, key, lambda u=lst.url: self._fetch_building(u))128                except Exception:129                    memo[pid] = {}130            mn, mx = bed_range.get(lst.external_id, (None, None))131            self._apply_building(lst, memo[pid], mn, mx)132        return listings133134    def _prop_listing(self, p: dict, unit_type: str, beds: int,135                      city: str, forced_sector: str | None,136                      province: str = "ON") -> Listing | None:137        if not p.get("availability_count"):138            return None139        addr = p.get("address") or {}140        stats = ((p.get("statistics") or {}).get("suites") or {})141        rates = stats.get("rates") or {}142        rmin, rmax = rates.get("min"), rates.get("max")143        sq = stats.get("square_feet") or {}144145        def _num(v):146            try:147                return float(v)148            except (TypeError, ValueError):149                return None150151        rmin, rmax = _num(rmin), _num(rmax)152        price = rmin153        if rmin and rmax and rmax != rmin:154            price_label = f"À partir de {int(rmin)} $ (max {int(rmax)} $)"155        elif rmin:156            price_label = f"{int(rmin)} $/mois"157        else:158            price_label = ""159160        sector = forced_sector or (addr.get("neighbourhood") or "").strip()161        details = p.get("details") or {}162        desc = _TAG_RE.sub(" ", details.get("overview") or "")163        desc = re.sub(r"\s+", " ", desc).strip()[:500]164        sbits = []165        sqmin, sqmax = _num(sq.get("min")), _num(sq.get("max"))166        if sqmin:167            sqtxt = (f"{int(sqmin)}-{int(sqmax)}"168                     if sqmax and sqmax != sqmin else f"{int(sqmin)}")169            sbits.append(f"{sqtxt} pi²")170        sbits.append(f"{p['availability_count']} unité(s) disponible(s)")171172        amenities = []173        feats = _TAG_RE.sub("|", details.get("features") or "")174        for f in feats.split("|"):175            f = f.strip()176            if 2 < len(f) < 60 and f not in amenities:177                amenities.append(f)178        amenities = amenities[:20]179180        images = []181        if p.get("photo_path"):182            images.append(p["photo_path"])183184        pid = p.get("id")185        name = (p.get("name") or "").strip()186        geo = p.get("geocode") or {}187        try:188            lat = float(geo.get("latitude"))189            lng = float(geo.get("longitude"))190        except (TypeError, ValueError):191            lat = lng = None192193        # champs structurés de l'API : animaux (bool), contact de location194        pets = None195        if isinstance(p.get("pet_friendly"), bool):196            pets = "oui" if p["pet_friendly"] else "non"197        details: dict = {}198        contact = p.get("contact") or {}199        cinfo: dict = {}200        if (contact.get("phone") or "").strip():201            cinfo["phone"] = contact["phone"].strip()202        for em in (contact.get("email") or "").split(","):203            em = em.strip()204            if em and "leadmanaging" not in em:      # relais de tracking exclu205                cinfo["email"] = em206                break207        if cinfo:208            details["contact"] = cinfo209210        return Listing(211            source=self.source_id,212            external_id=f"{pid}-{beds}bed",213            url=p.get("permalink") or "",214            title=f"{name} — {unit_type}",215            address=", ".join(x for x in [216                (addr.get("address") or "").strip(), city, province,217                (addr.get("postal_code") or "").strip()] if x),218            sector=sector,219            city=city,220            province=province,221            unit_type=unit_type,222            price=price,223            price_label=price_label,224            availability=(p.get("min_availability_date")225                          or p.get("availability_status_label") or ""),226            area_sqft=sqmin if sqmin else None,      # stats API (min du type)227            pets=pets,228            description=" — ".join([desc] + sbits if desc else sbits)[:600],229            amenities=amenities,230            details=details,231            images=images,232            lat=lat,233            lng=lng,234        )235236    # -- page immeuble du site (JSON embarqué data-locations) ------------------237    def _fetch_building(self, url: str) -> dict:238        """Extrait le JSON immeuble embarqué dans la page (widget carte) :239        commodités, services inclus, galerie, animaux détaillés, suites."""240        if self._fetched >= self.max_details:241            raise RuntimeError("budget de pages immeuble atteint")242        self._fetched += 1243        html = self.get(url).text244        soup = BeautifulSoup(html, "html.parser")245        el = soup.select_one("[data-locations]")246        if not el:247            return {}248        raw = el.get("data-locations") or ""249        try:250            data = json.loads(raw)251        except Exception:252            data = json.loads(htmllib.unescape(raw))253        node = (data[0] if isinstance(data, list) and data else {}) or {}254        d = node.get("data") or {}255        out: dict = {}256257        out["amenities"] = [a.get("name", "").strip()258                            for a in (d.get("Amenities") or [])259                            if a.get("name", "").strip()][:30]260        out["utilities"] = [u.get("name", "").strip()261                            for u in (d.get("Utilities") or [])262                            if isinstance(u, dict) and u.get("name", "").strip()]263        out["photos"] = [GALLERY + ph["image"]264                         for ph in (d.get("photos") or [])265                         if ph.get("image")][:15]266        out["pets_flags"] = {k: d.get(k) for k in267                             ("pet_friendly", "pets_small_dogs",268                              "pets_large_dogs", "pets_cats",269                              "pets_not_allowed") if d.get(k) is not None}270        out["pets_details"] = (d.get("pets_details") or "").strip()271        out["suites"] = [{272            "bed": s.get("bed"), "available": s.get("available"),273            "availability_date": s.get("availability_date"),274            "sq_ft": s.get("sq_ft"), "furnished": s.get("furnished"),275        } for s in (d.get("suites") or [])]276        return out277278    def _apply_building(self, lst: Listing, d: dict,279                        min_bed: int | None, max_bed: int | None) -> None:280        """Reporte le JSON immeuble (frais/cache) sur l'annonce."""281        if not d:282            return283        merged = list(dict.fromkeys(284            lst.amenities + (d.get("amenities") or []) +285            (d.get("utilities") or [])))286        if merged:287            lst.amenities = merged[:30]288        if d.get("photos"):289            lst.images = list(dict.fromkeys(lst.images + d["photos"]))[:15]290        pets = _pets_from_flags(d.get("pets_flags") or {})291        if pets:292            lst.pets = pets293294        # suites du type demandé : superficie et date précise si publiées295        suites = []296        for s in d.get("suites") or []:297            try:298                bed = int(s.get("bed"))299            except (TypeError, ValueError):300                continue301            if min_bed is None or not (min_bed <= bed <= (max_bed or min_bed)):302                continue303            if str(s.get("available")) == "1":304                suites.append(s)305        if suites:306            if lst.area_sqft is None:307                sqs = []308                for s in suites:309                    try:310                        v = float(s.get("sq_ft") or 0)311                    except (TypeError, ValueError):312                        v = 0313                    if v >= 80:314                        sqs.append(v)315                if sqs:316                    lst.area_sqft = min(sqs)317            dates = [s.get("availability_date") for s in suites318                     if s.get("availability_date")319                     and not str(s["availability_date"]).startswith("0000")]320            if dates and len(dates) == len(suites):321                # toutes les unités du type ont une date précise publiée322                lst.availability = min(dates)323            if all(str(s.get("furnished")) == "1" for s in suites):324                lst.furnished = True325