# ----------------------------------------------------------------------------- # Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/killam.py : connecteur Killam Apartment REIT (killamreit.com) # REIT national (~17 000 unités, Maritimes + Ontario + Alberta/C.-B.) — on ne # garde ici QUE l'Ontario : Toronto, Ottawa (Kanata), London, Kitchener- # Waterloo, Cambridge, Mississauga (~29 propriétés au moment de l'écriture). # Le site est un « corporate site » Yardi RentCafe derrière Cloudflare # (challenge JS : curl nu = 403 « Just a moment… »). Aucune API interne à # rejouer : la page de recherche /apartments embarque TOUT le portefeuille # (204 propriétés) dans un input caché `#available_prop` — un JSON doublement # encodé (chaîne JSON contenant du JSON aux guillemets "). On le décode # avec deux json.loads, sans exécuter de JavaScript : Scrapfly ASP suffit # (asp=true, PAS de render_js), via la chaîne de secours de BaseConnector.get # (Oxylabs résidentiel → Scrapfly → Bright Data). # Le flux liste donne nom, adresse civique, ville/province/code postal, # lat/lng, fourchettes loyer/chambres/sdb/pi², politique animaux, téléphone # et l'URL de la fiche. La fiche propriété (rendu serveur, même anti-bot) # fournit description, galerie photo (resource.rentcafe.com) et les cartes # « plans d'étage » (nom, chambres, sdb, pi², « Starting at $X /Month ») — # visitée via le cache BD self.detail() (clé = hash de la ligne du flux). # On émet UNE annonce PAR PLAN D'ÉTAGE (granularité type d'unité), avec un # repli « une annonce par propriété » si les cartes sont introuvables. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.killamreit.com" SEARCH_PAGE = f"{BASE}/apartments" # Gate expansion Ontario : le connecteur reste hors registre tant que la _ONTARIO = True # Rent-Ka: always on (ROC scope) # blob JSON du portefeuille dans la page de recherche (input caché) _PROP_BLOB_RE = re.compile( r"id=\"available_prop\"\s+value='(.*?)'", re.S) # cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. » _BED_RE = re.compile(r"(\d+)\s*Bed") _BATH_RE = re.compile(r"([\d.]+)\s*Bath") _SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I) _PRICE_RE = re.compile(r"\$\s*([\d,]+)") _FPID_RE = re.compile(r"#fp-modal-(\d+)") # disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété _DISCLAIMER_RE = re.compile( r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I) _BOILERPLATE_RE = re.compile( r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I) # IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR _AMENITY_FR = { "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement", "7": "Planchers de bois franc", "8": "Grands placards", "9": "Balcon/terrasse", "10": "Laveuse-sécheuse", "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant", "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires", "15": "Salle de réception", "16": "Accès contrôlé", "17": "Stationnement couvert", "18": "Garage", "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie", "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine", "25": "Salle d'entraînement", "26": "Terrain de basketball", "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux", "30": "Supports à vélos", "31": "Électroménagers écoénergétiques", "32": "Thermostat électronique", "33": "Bâtiment écologique", "34": "Transport en commun à proximité", "35": "Recyclage", } # villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa ») _CITY_FIX = { "kanata ottawa": ("Ottawa", "Kanata"), "kanata-ottawa": ("Ottawa", "Kanata"), } def _money(txt: str) -> float | None: m = _PRICE_RE.search(txt or "") return float(m.group(1).replace(",", "")) if m else None class KillamConnector(BaseConnector): source_id = "killam" request_delay = 1.2 # Cloudflare/Scrapfly : rester très poli disabled = False max_properties = 40 # garde-fou (29 propriétés ON aujourd'hui) max_images = 20 # -- portefeuille : blob embarqué de la page de recherche ------------------- def _properties(self) -> list[dict]: page = self.get(SEARCH_PAGE).text m = _PROP_BLOB_RE.search(page) if not m: raise RuntimeError("Killam : blob #available_prop introuvable " "(gabarit RentCafe modifié ?)") # doublement encodé : value='"[{"companyid":0,…}]"' return json.loads(json.loads(m.group(1))) def fetch(self) -> list[Listing]: props = self._properties() listings: list[Listing] = [] count = 0 for p in props: if (p.get("propertyState") or "").strip().upper() != "ON": continue # REIT national : Ontario seulement ici if count >= self.max_properties: break count += 1 try: listings.extend(self._listings(p)) except Exception: continue return listings # -- annonces d'une propriété (une par plan d'étage, repli propriété) ------- def _listings(self, p: dict) -> list[Listing]: pid = str(p.get("propertyid")) url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "") .strip().rstrip("?")) or SEARCH_PAGE name = (p.get("propertyName") or "").strip() # adresse complète : rue + ville + « ON » + code postal city_raw = (p.get("propertyCity") or "").strip() city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, "")) street = (p.get("propertyAddress") or "").strip() postal = (p.get("propertyZipCode") or "").strip() full_addr = ", ".join(x for x in (street, city) if x) + ", ON" if postal: full_addr += f" {postal}" # coordonnées GPS du flux (finalize() valide la bbox Ontario) try: lat = float(p.get("propertyLat")) or None lng = float(p.get("propertyLng")) or None except (TypeError, ValueError): lat = lng = None # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true} pets = None try: pol = json.loads(p.get("bPetPolicy") or "{}") if pol.get("bNoPetsAllowed") is True: pets = "non" elif pol.get("bCats") and pol.get("bDogs"): pets = "oui" elif pol.get("bCats") or pol.get("bDogs"): pets = "conditions" except (ValueError, TypeError): pass # commodités : IDs RentCafe -> libellés français amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or []) if a in _AMENITY_FR] # disponibilité : le flux n'expose qu'un booléen d'occupation + une # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé occupied = p.get("IsFullyOccupied") is True availability = ("Aucune unité disponible" if occupied else "Unités disponibles") avail_date = None mad = str(p.get("dtMinUnitAvailable") or "").strip() md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad) if md: avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}" details: dict = {} phone = (p.get("phone") or "").strip() if phone: details["contact"] = {"phone": phone} # fiche propriété (description, galerie, plans d'étage) — via le cache # BD : revisitée seulement quand la ligne du flux change feed_key = hashlib.sha1("|".join(str(x) for x in ( p.get("propertyMinRent"), p.get("propertyMaxRent"), p.get("propertyMinBed"), p.get("propertyMaxBed"), p.get("propertyMinArea"), p.get("propertyMaxArea"), p.get("IsFullyOccupied"), mad, p.get("propertyThumb"), )).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_detail(url)) desc = d.get("description") or "" images = list(d.get("images") or []) thumb = (p.get("propertyThumb") or "").strip() if thumb and thumb not in images: images.insert(0, thumb) images = images[: self.max_images] common = dict( source=self.source_id, url=url, address=full_addr, sector=sector, city=city, province="ON", availability=availability, availability_date=avail_date, pets=pets, description=desc, amenities=amenities[:25], images=images, lat=lat, lng=lng, ) # une annonce par plan d'étage (prix/chambres/sdb/pi² précis) out: list[Listing] = [] for fp in d.get("floorplans") or []: price = fp.get("price") beds = fp.get("beds") out.append(Listing( external_id=f"{pid}-{fp['fpid']}", title=f"{name} — {fp['name']}" if fp.get("name") else name, unit_type=("Studio" if beds == 0 else normalize_unit_type( f"{int(beds)} chambres") if beds is not None else ""), bedrooms=beds, bathrooms=fp.get("baths"), price=price, price_label=(f"À partir de {price:.0f} $ /mois" if price is not None else ""), area_sqft=fp.get("sqft"), details=dict(details), **common, )) if out: return out # repli : une annonce par propriété (fourchettes du flux liste) price = None try: price = float(p.get("propertyMinRent") or 0) or None except (TypeError, ValueError): pass price_label = "" if price is not None: try: pmax = float(p.get("propertyMaxRent") or 0) except (TypeError, ValueError): pmax = 0.0 price_label = (f"À partir de {price:.0f} $" if pmax and pmax != price else f"{price:.0f} $ /mois") beds = baths = None try: bmin, bmax = float(p.get("propertyMinBed") or -1), \ float(p.get("propertyMaxBed") or -1) if bmin >= 0 and bmin == bmax: beds = bmin if float(p.get("propertyMinBath") or 0) > 0: baths = float(p["propertyMinBath"]) except (TypeError, ValueError): pass area = None try: v = float(p.get("propertyMinArea") or 0) if 80 <= v <= 20000: area = v except (TypeError, ValueError): pass return [Listing( external_id=pid, title=name, unit_type=("Studio" if beds == 0 else normalize_unit_type( f"{int(beds)} chambres") if beds is not None else ""), bedrooms=beds, bathrooms=baths, price=price, price_label=price_label, area_sqft=area, details=details, **common, )] # -- fiche propriété : description + galerie + plans d'étage ---------------- def _fetch_detail(self, url: str) -> dict: out: dict = {"description": "", "images": [], "floorplans": []} try: page = self.get(url).text except Exception: return out soup = BeautifulSoup(page, "html.parser") # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. / # Starting at $X /Month » (modal #fp-modal- = identifiant stable) fp_imgs: set[str] = set() for card in soup.select(".card"): txt = card.get_text(" ", strip=True) if "Bed" not in txt and "Studio" not in txt: continue ref = card.find(attrs={"data-target": _FPID_RE}) mid = _FPID_RE.search(ref["data-target"]) if ref else None if not mid: continue h = card.find(["h2", "h3", "h4"]) mb = _BED_RE.search(txt) beds = (float(mb.group(1)) if mb else 0.0 if "Studio" in txt else None) mba = _BATH_RE.search(txt) msq = _SQFT_RE.search(txt) sqft = float(msq.group(1).replace(",", "")) if msq else None self._append_fp(out, { "fpid": mid.group(1), "name": h.get_text(" ", strip=True) if h else "", "beds": beds, "baths": float(mba.group(1)) if mba else None, "sqft": sqft if sqft and 80 <= sqft <= 20000 else None, "price": _money(txt), }) for img in card.find_all("img"): src = img.get("src") or img.get("data-src") or "" if src: fp_imgs.add(src) # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage images: list[str] = [] for img in soup.find_all("img"): src = (img.get("src") or img.get("data-src") or "").strip() if ("resource.rentcafe.com" not in src or src in fp_imgs or img.find_parent(class_=re.compile(r"ysi-logo"))): continue if not img.find_parent(class_=re.compile(r"ysi-picture")): continue # variante généreuse de la transformation Cloudinary src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?", "c_limit,w_1200", src) if src not in images: images.append(src) out["images"] = images[: self.max_images] # description : paragraphes de présentation (sans le boilerplate # consentement/cookies ni le disclaimer promo) paras = [] for ptag in soup.find_all("p"): t = ptag.get_text(" ", strip=True) if len(t) < 150 or _BOILERPLATE_RE.search(t): continue t = _DISCLAIMER_RE.sub("", t).strip() if t and t not in paras: paras.append(t) out["description"] = " ".join(paras)[:900] return out @staticmethod def _append_fp(out: dict, fp: dict) -> None: """Ajoute un plan d'étage en dédoublonnant sur son identifiant.""" if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}: out["floorplans"].append(fp)