Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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