SPB Git

spb/lou-ka Public

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

HTML 99.7%
13.1 KB · 327 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/hazelview.py : connecteur Hazelview Properties5#   (hazelviewproperties.com — ex-Timbercreek). Le site est rendu côté client6#   via l'API RentSync/LiftSystem (lift-api.rentsync.com/v2, client_id 497,7#   jeton public embarqué dans le JS du site). On interroge /v2/cities pour8#   les villes QC (toutes dans le Grand Montréal : Montréal, Verdun,9#   Côte-Saint-Luc, Pointe-Claire, Longueuil...), puis /v2/search par ville et10#   par nombre de chambres pour obtenir les types d'unités disponibles et leur11#   loyer. Une annonce par immeuble et par type d'unité. La page immeuble du12#   site (via cache BD) embarque un JSON complet (attribut data-locations de13#   la carte) : commodités, galerie photos, animaux détaillés, suites.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as htmllib18import json19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, strip_accents24from .base import BaseConnector2526GALLERY = "https://assets.rentsync.com/timbercreek_communities/images/gallery/full/"2728API = "https://lift-api.rentsync.com/v2"29CLIENT_ID = "497"30AUTH_TOKEN = "sswpREkUtyeYjeoahA2i"    # jeton public (présent dans main.js)3132SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false"33                 "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000")3435# Villes QC admissibles (Grand Montréal) -> (ville, secteur imposé)36_GM_CITIES = {37    "montreal": ("Montréal", None),38    "verdun": ("Montréal", "Verdun"),39    "cote-saint-luc": ("Côte-Saint-Luc", None),40    "dollard-des-ormeaux": ("Dollard-des-Ormeaux", None),41    "pointe-claire": ("Pointe-Claire", None),42    "longueuil": ("Longueuil", None),43    "lasalle": ("Montréal", "LaSalle"),44}4546# (min_bed, max_bed, type d'unité)47_BED_QUERIES = [(0, 0, "Studio"), (1, 1, "3½"), (2, 2, "4½"),48                (3, 3, "5½"), (4, 5, "6½")]49_TAG_RE = re.compile(r"<[^>]+>")505152def _pets_from_flags(d: dict) -> str | None:53    """Drapeaux animaux du JSON immeuble ('1'/'0'/None) -> oui/non/conditions."""54    def flag(k):55        v = d.get(k)56        return None if v in (None, "") else str(v) == "1"57    if flag("pets_not_allowed"):58        return "non"59    small, cats, large = (flag("pets_small_dogs"), flag("pets_cats"),60                          flag("pets_large_dogs"))61    if any(v for v in (small, cats, large)):62        # certains types refusés explicitement -> sous conditions63        if False in (small, cats, large):64            return "conditions"65        return "oui"66    if flag("pet_friendly"):67        return "oui"68    if flag("pet_friendly") is False:69        return "non"70    return None717273class HazelviewConnector(BaseConnector):74    source_id = "hazelview"75    request_delay = 0.676    max_cities = 10          # garde-fou77    max_details = 150        # garde-fou pages immeuble (vraies requêtes)7879    def _api(self, path: str, extra: str = "") -> list | dict:80        url = (f"{API}/{path}?client_id={CLIENT_ID}&auth_token={AUTH_TOKEN}"81               f"&locale=en{('&' + extra) if extra else ''}")82        return self.get(url).json()8384    def fetch(self) -> list[Listing]:85        cities = self._api("cities")86        qc = []87        for c in cities if isinstance(cities, list) else []:88            if (c.get("province_code") or "").upper() != "QC":89                continue90            key = strip_accents((c.get("city_name") or "").strip().lower())91            if key in _GM_CITIES:92                qc.append((c.get("id"), key))9394        listings: list[Listing] = []95        bed_range: dict[str, tuple[int, int]] = {}96        for city_id, key in qc[: self.max_cities]:97            city, forced_sector = _GM_CITIES[key]98            for min_bed, max_bed, unit_type in _BED_QUERIES:99                try:100                    props = self._api(101                        "search",102                        f"city_ids={city_id}&min_bed={min_bed}"103                        f"&max_bed={max_bed}&{SEARCH_PARAMS}&limit=50")104                except Exception:105                    continue106                if not isinstance(props, list):107                    continue108                for p in props:109                    try:110                        lst = self._prop_listing(p, unit_type, min_bed,111                                                 city, forced_sector)112                        if lst:113                            listings.append(lst)114                            bed_range[lst.external_id] = (min_bed, max_bed)115                    except Exception:116                        continue117118        # Page immeuble du site (cache BD, 1 requête par immeuble) : le JSON119        # embarqué (data-locations) fournit commodités, galerie, animaux120        # détaillés et suites (superficie, date de disponibilité par unité)121        self._fetched = 0122        memo: dict[str, dict] = {}123        for lst in listings:124            pid = lst.external_id.split("-")[0]125            if not lst.url:126                continue127            if pid not in memo:128                key = f"{pid}|{lst.availability}|{lst.address}"129                try:130                    memo[pid] = self.detail(131                        pid, key, lambda u=lst.url: self._fetch_building(u))132                except Exception:133                    memo[pid] = {}134            mn, mx = bed_range.get(lst.external_id, (None, None))135            self._apply_building(lst, memo[pid], mn, mx)136        return listings137138    def _prop_listing(self, p: dict, unit_type: str, beds: int,139                      city: str, forced_sector: str | None) -> Listing | None:140        if not p.get("availability_count"):141            return None142        addr = p.get("address") or {}143        stats = ((p.get("statistics") or {}).get("suites") or {})144        rates = stats.get("rates") or {}145        rmin, rmax = rates.get("min"), rates.get("max")146        sq = stats.get("square_feet") or {}147148        def _num(v):149            try:150                return float(v)151            except (TypeError, ValueError):152                return None153154        rmin, rmax = _num(rmin), _num(rmax)155        price = rmin156        if rmin and rmax and rmax != rmin:157            price_label = f"À partir de {int(rmin)} $ (max {int(rmax)} $)"158        elif rmin:159            price_label = f"{int(rmin)} $/mois"160        else:161            price_label = ""162163        sector = forced_sector or (addr.get("neighbourhood") or "").strip()164        details = p.get("details") or {}165        desc = _TAG_RE.sub(" ", details.get("overview") or "")166        desc = re.sub(r"\s+", " ", desc).strip()[:500]167        sbits = []168        sqmin, sqmax = _num(sq.get("min")), _num(sq.get("max"))169        if sqmin:170            sqtxt = (f"{int(sqmin)}-{int(sqmax)}"171                     if sqmax and sqmax != sqmin else f"{int(sqmin)}")172            sbits.append(f"{sqtxt} pi²")173        sbits.append(f"{p['availability_count']} unité(s) disponible(s)")174175        amenities = []176        feats = _TAG_RE.sub("|", details.get("features") or "")177        for f in feats.split("|"):178            f = f.strip()179            if 2 < len(f) < 60 and f not in amenities:180                amenities.append(f)181        amenities = amenities[:20]182183        images = []184        if p.get("photo_path"):185            images.append(p["photo_path"])186187        pid = p.get("id")188        name = (p.get("name") or "").strip()189        geo = p.get("geocode") or {}190        try:191            lat = float(geo.get("latitude"))192            lng = float(geo.get("longitude"))193        except (TypeError, ValueError):194            lat = lng = None195196        # champs structurés de l'API : animaux (bool), contact de location197        pets = None198        if isinstance(p.get("pet_friendly"), bool):199            pets = "oui" if p["pet_friendly"] else "non"200        details: dict = {}201        contact = p.get("contact") or {}202        cinfo: dict = {}203        if (contact.get("phone") or "").strip():204            cinfo["phone"] = contact["phone"].strip()205        for em in (contact.get("email") or "").split(","):206            em = em.strip()207            if em and "leadmanaging" not in em:      # relais de tracking exclu208                cinfo["email"] = em209                break210        if cinfo:211            details["contact"] = cinfo212213        return Listing(214            source=self.source_id,215            external_id=f"{pid}-{beds}bed",216            url=p.get("permalink") or "",217            title=f"{name}{unit_type}",218            address=", ".join(x for x in [219                (addr.get("address") or "").strip(), city,220                (addr.get("postal_code") or "").strip()] if x),221            sector=sector,222            city=city,223            unit_type=unit_type,224            price=price,225            price_label=price_label,226            availability=(p.get("min_availability_date")227                          or p.get("availability_status_label") or ""),228            area_sqft=sqmin if sqmin else None,      # stats API (min du type)229            pets=pets,230            description=" — ".join([desc] + sbits if desc else sbits)[:600],231            amenities=amenities,232            details=details,233            images=images,234            lat=lat,235            lng=lng,236        )237238    # -- page immeuble du site (JSON embarqué data-locations) ------------------239    def _fetch_building(self, url: str) -> dict:240        """Extrait le JSON immeuble embarqué dans la page (widget carte) :241        commodités, services inclus, galerie, animaux détaillés, suites."""242        if self._fetched >= self.max_details:243            raise RuntimeError("budget de pages immeuble atteint")244        self._fetched += 1245        html = self.get(url).text246        soup = BeautifulSoup(html, "html.parser")247        el = soup.select_one("[data-locations]")248        if not el:249            return {}250        raw = el.get("data-locations") or ""251        try:252            data = json.loads(raw)253        except Exception:254            data = json.loads(htmllib.unescape(raw))255        node = (data[0] if isinstance(data, list) and data else {}) or {}256        d = node.get("data") or {}257        out: dict = {}258259        out["amenities"] = [a.get("name", "").strip()260                            for a in (d.get("Amenities") or [])261                            if a.get("name", "").strip()][:30]262        out["utilities"] = [u.get("name", "").strip()263                            for u in (d.get("Utilities") or [])264                            if isinstance(u, dict) and u.get("name", "").strip()]265        out["photos"] = [GALLERY + ph["image"]266                         for ph in (d.get("photos") or [])267                         if ph.get("image")][:15]268        out["pets_flags"] = {k: d.get(k) for k in269                             ("pet_friendly", "pets_small_dogs",270                              "pets_large_dogs", "pets_cats",271                              "pets_not_allowed") if d.get(k) is not None}272        out["pets_details"] = (d.get("pets_details") or "").strip()273        out["suites"] = [{274            "bed": s.get("bed"), "available": s.get("available"),275            "availability_date": s.get("availability_date"),276            "sq_ft": s.get("sq_ft"), "furnished": s.get("furnished"),277        } for s in (d.get("suites") or [])]278        return out279280    def _apply_building(self, lst: Listing, d: dict,281                        min_bed: int | None, max_bed: int | None) -> None:282        """Reporte le JSON immeuble (frais/cache) sur l'annonce."""283        if not d:284            return285        merged = list(dict.fromkeys(286            lst.amenities + (d.get("amenities") or []) +287            (d.get("utilities") or [])))288        if merged:289            lst.amenities = merged[:30]290        if d.get("photos"):291            lst.images = list(dict.fromkeys(lst.images + d["photos"]))[:15]292        pets = _pets_from_flags(d.get("pets_flags") or {})293        if pets:294            lst.pets = pets295296        # suites du type demandé : superficie et date précise si publiées297        suites = []298        for s in d.get("suites") or []:299            try:300                bed = int(s.get("bed"))301            except (TypeError, ValueError):302                continue303            if min_bed is None or not (min_bed <= bed <= (max_bed or min_bed)):304                continue305            if str(s.get("available")) == "1":306                suites.append(s)307        if suites:308            if lst.area_sqft is None:309                sqs = []310                for s in suites:311                    try:312                        v = float(s.get("sq_ft") or 0)313                    except (TypeError, ValueError):314                        v = 0315                    if v >= 80:316                        sqs.append(v)317                if sqs:318                    lst.area_sqft = min(sqs)319            dates = [s.get("availability_date") for s in suites320                     if s.get("availability_date")321                     and not str(s["availability_date"]).startswith("0000")]322            if dates and len(dates) == len(suites):323                # toutes les unités du type ont une date précise publiée324                lst.availability = min(dates)325            if all(str(s.get("furnished")) == "1" for s in suites):326                lst.furnished = True327