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%
14.7 KB · 343 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/killam.py : connecteur Killam Apartment REIT (killamreit.com)5#   REIT national (~17 000 unités, Maritimes + Ontario + Alberta/C.-B.) — on ne6#   garde ici QUE l'Ontario : Toronto, Ottawa (Kanata), London, Kitchener-7#   Waterloo, Cambridge, Mississauga (~29 propriétés au moment de l'écriture).8#   Le site est un « corporate site » Yardi RentCafe derrière Cloudflare9#   (challenge JS : curl nu = 403 « Just a moment… »). Aucune API interne à10#   rejouer : la page de recherche /apartments embarque TOUT le portefeuille11#   (204 propriétés) dans un input caché `#available_prop` — un JSON doublement12#   encodé (chaîne JSON contenant du JSON aux guillemets "). On le décode13#   avec deux json.loads, sans exécuter de JavaScript : Scrapfly ASP suffit14#   (asp=true, PAS de render_js), via la chaîne de secours de BaseConnector.get15#   (Oxylabs résidentiel → Scrapfly → Bright Data).16#   Le flux liste donne nom, adresse civique, ville/province/code postal,17#   lat/lng, fourchettes loyer/chambres/sdb/pi², politique animaux, téléphone18#   et l'URL de la fiche. La fiche propriété (rendu serveur, même anti-bot)19#   fournit description, galerie photo (resource.rentcafe.com) et les cartes20#   « plans d'étage » (nom, chambres, sdb, pi², « Starting at $X /Month ») —21#   visitée via le cache BD self.detail() (clé = hash de la ligne du flux).22#   On émet UNE annonce PAR PLAN D'ÉTAGE (granularité type d'unité), avec un23#   repli « une annonce par propriété » si les cartes sont introuvables.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import hashlib28import json29import os30import re3132from bs4 import BeautifulSoup3334from ..schema import Listing, normalize_unit_type35from .base import BaseConnector3637BASE = "https://www.killamreit.com"38SEARCH_PAGE = f"{BASE}/apartments"3940# Gate expansion Ontario : le connecteur reste hors registre tant que la41_ONTARIO = True  # Rent-Ka: always on (ROC scope)4243# blob JSON du portefeuille dans la page de recherche (input caché)44_PROP_BLOB_RE = re.compile(45    r"id=\"available_prop\"\s+value='(.*?)'", re.S)4647# cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. »48_BED_RE = re.compile(r"(\d+)\s*Bed")49_BATH_RE = re.compile(r"([\d.]+)\s*Bath")50_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)51_PRICE_RE = re.compile(r"\$\s*([\d,]+)")52_FPID_RE = re.compile(r"#fp-modal-(\d+)")53# disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété54_DISCLAIMER_RE = re.compile(55    r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I)56_BOILERPLATE_RE = re.compile(57    r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I)5859# IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR60_AMENITY_FR = {61    "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement",62    "7": "Planchers de bois franc", "8": "Grands placards",63    "9": "Balcon/terrasse", "10": "Laveuse-sécheuse",64    "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant",65    "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires",66    "15": "Salle de réception", "16": "Accès contrôlé",67    "17": "Stationnement couvert", "18": "Garage",68    "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie",69    "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine",70    "25": "Salle d'entraînement", "26": "Terrain de basketball",71    "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux",72    "30": "Supports à vélos", "31": "Électroménagers écoénergétiques",73    "32": "Thermostat électronique", "33": "Bâtiment écologique",74    "34": "Transport en commun à proximité", "35": "Recyclage",75}7677# villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa »)78_CITY_FIX = {79    "kanata ottawa": ("Ottawa", "Kanata"),80    "kanata-ottawa": ("Ottawa", "Kanata"),81}828384def _money(txt: str) -> float | None:85    m = _PRICE_RE.search(txt or "")86    return float(m.group(1).replace(",", "")) if m else None878889class KillamConnector(BaseConnector):90    source_id = "killam"91    request_delay = 1.2       # Cloudflare/Scrapfly : rester très poli92    disabled = False93    max_properties = 40       # garde-fou (29 propriétés ON aujourd'hui)94    max_images = 209596    # -- portefeuille : blob embarqué de la page de recherche -------------------97    def _properties(self) -> list[dict]:98        page = self.get(SEARCH_PAGE).text99        m = _PROP_BLOB_RE.search(page)100        if not m:101            raise RuntimeError("Killam : blob #available_prop introuvable "102                               "(gabarit RentCafe modifié ?)")103        # doublement encodé : value='"[{"companyid":0,…}]"'104        return json.loads(json.loads(m.group(1)))105106    def fetch(self) -> list[Listing]:107        props = self._properties()108        listings: list[Listing] = []109        count = 0110        for p in props:111            if (p.get("propertyState") or "").strip().upper() != "ON":112                continue        # REIT national : Ontario seulement ici113            if count >= self.max_properties:114                break115            count += 1116            try:117                listings.extend(self._listings(p))118            except Exception:119                continue120        return listings121122    # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------123    def _listings(self, p: dict) -> list[Listing]:124        pid = str(p.get("propertyid"))125        url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")126               .strip().rstrip("?")) or SEARCH_PAGE127        name = (p.get("propertyName") or "").strip()128129        # adresse complète : rue + ville + « ON » + code postal130        city_raw = (p.get("propertyCity") or "").strip()131        city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, ""))132        street = (p.get("propertyAddress") or "").strip()133        postal = (p.get("propertyZipCode") or "").strip()134        full_addr = ", ".join(x for x in (street, city) if x) + ", ON"135        if postal:136            full_addr += f" {postal}"137138        # coordonnées GPS du flux (finalize() valide la bbox Ontario)139        try:140            lat = float(p.get("propertyLat")) or None141            lng = float(p.get("propertyLng")) or None142        except (TypeError, ValueError):143            lat = lng = None144145        # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true}146        pets = None147        try:148            pol = json.loads(p.get("bPetPolicy") or "{}")149            if pol.get("bNoPetsAllowed") is True:150                pets = "non"151            elif pol.get("bCats") and pol.get("bDogs"):152                pets = "oui"153            elif pol.get("bCats") or pol.get("bDogs"):154                pets = "conditions"155        except (ValueError, TypeError):156            pass157158        # commodités : IDs RentCafe -> libellés français159        amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or [])160                     if a in _AMENITY_FR]161162        # disponibilité : le flux n'expose qu'un booléen d'occupation + une163        # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé164        occupied = p.get("IsFullyOccupied") is True165        availability = ("Aucune unité disponible" if occupied166                        else "Unités disponibles")167        avail_date = None168        mad = str(p.get("dtMinUnitAvailable") or "").strip()169        md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad)170        if md:171            avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}"172173        details: dict = {}174        phone = (p.get("phone") or "").strip()175        if phone:176            details["contact"] = {"phone": phone}177178        # fiche propriété (description, galerie, plans d'étage) — via le cache179        # BD : revisitée seulement quand la ligne du flux change180        feed_key = hashlib.sha1("|".join(str(x) for x in (181            p.get("propertyMinRent"), p.get("propertyMaxRent"),182            p.get("propertyMinBed"), p.get("propertyMaxBed"),183            p.get("propertyMinArea"), p.get("propertyMaxArea"),184            p.get("IsFullyOccupied"), mad, p.get("propertyThumb"),185        )).encode("utf-8")).hexdigest()186        d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))187188        desc = d.get("description") or ""189        images = list(d.get("images") or [])190        thumb = (p.get("propertyThumb") or "").strip()191        if thumb and thumb not in images:192            images.insert(0, thumb)193        images = images[: self.max_images]194195        common = dict(196            source=self.source_id, url=url, address=full_addr, sector=sector,197            city=city, province="ON", availability=availability,198            availability_date=avail_date, pets=pets, description=desc,199            amenities=amenities[:25], images=images, lat=lat, lng=lng,200        )201202        # une annonce par plan d'étage (prix/chambres/sdb/pi² précis)203        out: list[Listing] = []204        for fp in d.get("floorplans") or []:205            price = fp.get("price")206            beds = fp.get("beds")207            out.append(Listing(208                external_id=f"{pid}-{fp['fpid']}",209                title=f"{name} — {fp['name']}" if fp.get("name") else name,210                unit_type=("Studio" if beds == 0 else normalize_unit_type(211                    f"{int(beds)} chambres") if beds is not None else ""),212                bedrooms=beds,213                bathrooms=fp.get("baths"),214                price=price,215                price_label=(f"À partir de {price:.0f} $ /mois"216                             if price is not None else ""),217                area_sqft=fp.get("sqft"),218                details=dict(details),219                **common,220            ))221        if out:222            return out223224        # repli : une annonce par propriété (fourchettes du flux liste)225        price = None226        try:227            price = float(p.get("propertyMinRent") or 0) or None228        except (TypeError, ValueError):229            pass230        price_label = ""231        if price is not None:232            try:233                pmax = float(p.get("propertyMaxRent") or 0)234            except (TypeError, ValueError):235                pmax = 0.0236            price_label = (f"À partir de {price:.0f} $"237                           if pmax and pmax != price else f"{price:.0f} $ /mois")238        beds = baths = None239        try:240            bmin, bmax = float(p.get("propertyMinBed") or -1), \241                float(p.get("propertyMaxBed") or -1)242            if bmin >= 0 and bmin == bmax:243                beds = bmin244            if float(p.get("propertyMinBath") or 0) > 0:245                baths = float(p["propertyMinBath"])246        except (TypeError, ValueError):247            pass248        area = None249        try:250            v = float(p.get("propertyMinArea") or 0)251            if 80 <= v <= 20000:252                area = v253        except (TypeError, ValueError):254            pass255        return [Listing(256            external_id=pid,257            title=name,258            unit_type=("Studio" if beds == 0 else normalize_unit_type(259                f"{int(beds)} chambres") if beds is not None else ""),260            bedrooms=beds,261            bathrooms=baths,262            price=price,263            price_label=price_label,264            area_sqft=area,265            details=details,266            **common,267        )]268269    # -- fiche propriété : description + galerie + plans d'étage ----------------270    def _fetch_detail(self, url: str) -> dict:271        out: dict = {"description": "", "images": [], "floorplans": []}272        try:273            page = self.get(url).text274        except Exception:275            return out276        soup = BeautifulSoup(page, "html.parser")277278        # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. /279        # Starting at $X /Month » (modal #fp-modal-<id> = identifiant stable)280        fp_imgs: set[str] = set()281        for card in soup.select(".card"):282            txt = card.get_text(" ", strip=True)283            if "Bed" not in txt and "Studio" not in txt:284                continue285            ref = card.find(attrs={"data-target": _FPID_RE})286            mid = _FPID_RE.search(ref["data-target"]) if ref else None287            if not mid:288                continue289            h = card.find(["h2", "h3", "h4"])290            mb = _BED_RE.search(txt)291            beds = (float(mb.group(1)) if mb292                    else 0.0 if "Studio" in txt else None)293            mba = _BATH_RE.search(txt)294            msq = _SQFT_RE.search(txt)295            sqft = float(msq.group(1).replace(",", "")) if msq else None296            self._append_fp(out, {297                "fpid": mid.group(1),298                "name": h.get_text(" ", strip=True) if h else "",299                "beds": beds,300                "baths": float(mba.group(1)) if mba else None,301                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,302                "price": _money(txt),303            })304            for img in card.find_all("img"):305                src = img.get("src") or img.get("data-src") or ""306                if src:307                    fp_imgs.add(src)308309        # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage310        images: list[str] = []311        for img in soup.find_all("img"):312            src = (img.get("src") or img.get("data-src") or "").strip()313            if ("resource.rentcafe.com" not in src or src in fp_imgs314                    or img.find_parent(class_=re.compile(r"ysi-logo"))):315                continue316            if not img.find_parent(class_=re.compile(r"ysi-picture")):317                continue318            # variante généreuse de la transformation Cloudinary319            src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",320                         "c_limit,w_1200", src)321            if src not in images:322                images.append(src)323        out["images"] = images[: self.max_images]324325        # description : paragraphes de présentation (sans le boilerplate326        # consentement/cookies ni le disclaimer promo)327        paras = []328        for ptag in soup.find_all("p"):329            t = ptag.get_text(" ", strip=True)330            if len(t) < 150 or _BOILERPLATE_RE.search(t):331                continue332            t = _DISCLAIMER_RE.sub("", t).strip()333            if t and t not in paras:334                paras.append(t)335        out["description"] = " ".join(paras)[:900]336        return out337338    @staticmethod339    def _append_fp(out: dict, fp: dict) -> None:340        """Ajoute un plan d'étage en dédoublonnant sur son identifiant."""341        if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}:342            out["floorplans"].append(fp)343