Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : 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# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée (zéro impact prod).42_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4344# blob JSON du portefeuille dans la page de recherche (input caché)45_PROP_BLOB_RE = re.compile(46 r"id=\"available_prop\"\s+value='(.*?)'", re.S)4748# cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. »49_BED_RE = re.compile(r"(\d+)\s*Bed")50_BATH_RE = re.compile(r"([\d.]+)\s*Bath")51_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)52_PRICE_RE = re.compile(r"\$\s*([\d,]+)")53_FPID_RE = re.compile(r"#fp-modal-(\d+)")54# disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété55_DISCLAIMER_RE = re.compile(56 r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I)57_BOILERPLATE_RE = re.compile(58 r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I)5960# IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR61_AMENITY_FR = {62 "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement",63 "7": "Planchers de bois franc", "8": "Grands placards",64 "9": "Balcon/terrasse", "10": "Laveuse-sécheuse",65 "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant",66 "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires",67 "15": "Salle de réception", "16": "Accès contrôlé",68 "17": "Stationnement couvert", "18": "Garage",69 "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie",70 "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine",71 "25": "Salle d'entraînement", "26": "Terrain de basketball",72 "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux",73 "30": "Supports à vélos", "31": "Électroménagers écoénergétiques",74 "32": "Thermostat électronique", "33": "Bâtiment écologique",75 "34": "Transport en commun à proximité", "35": "Recyclage",76}7778# villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa »)79_CITY_FIX = {80 "kanata ottawa": ("Ottawa", "Kanata"),81 "kanata-ottawa": ("Ottawa", "Kanata"),82}838485def _money(txt: str) -> float | None:86 m = _PRICE_RE.search(txt or "")87 return float(m.group(1).replace(",", "")) if m else None888990class KillamConnector(BaseConnector):91 source_id = "killam"92 request_delay = 1.2 # Cloudflare/Scrapfly : rester très poli93 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)94 max_properties = 40 # garde-fou (29 propriétés ON aujourd'hui)95 max_images = 209697 # -- portefeuille : blob embarqué de la page de recherche -------------------98 def _properties(self) -> list[dict]:99 page = self.get(SEARCH_PAGE).text100 m = _PROP_BLOB_RE.search(page)101 if not m:102 raise RuntimeError("Killam : blob #available_prop introuvable "103 "(gabarit RentCafe modifié ?)")104 # doublement encodé : value='"[{"companyid":0,…}]"'105 return json.loads(json.loads(m.group(1)))106107 def fetch(self) -> list[Listing]:108 props = self._properties()109 listings: list[Listing] = []110 count = 0111 for p in props:112 if (p.get("propertyState") or "").strip().upper() != "ON":113 continue # REIT national : Ontario seulement ici114 if count >= self.max_properties:115 break116 count += 1117 try:118 listings.extend(self._listings(p))119 except Exception:120 continue121 return listings122123 # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------124 def _listings(self, p: dict) -> list[Listing]:125 pid = str(p.get("propertyid"))126 url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")127 .strip().rstrip("?")) or SEARCH_PAGE128 name = (p.get("propertyName") or "").strip()129130 # adresse complète : rue + ville + « ON » + code postal131 city_raw = (p.get("propertyCity") or "").strip()132 city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, ""))133 street = (p.get("propertyAddress") or "").strip()134 postal = (p.get("propertyZipCode") or "").strip()135 full_addr = ", ".join(x for x in (street, city) if x) + ", ON"136 if postal:137 full_addr += f" {postal}"138139 # coordonnées GPS du flux (finalize() valide la bbox Ontario)140 try:141 lat = float(p.get("propertyLat")) or None142 lng = float(p.get("propertyLng")) or None143 except (TypeError, ValueError):144 lat = lng = None145146 # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true}147 pets = None148 try:149 pol = json.loads(p.get("bPetPolicy") or "{}")150 if pol.get("bNoPetsAllowed") is True:151 pets = "non"152 elif pol.get("bCats") and pol.get("bDogs"):153 pets = "oui"154 elif pol.get("bCats") or pol.get("bDogs"):155 pets = "conditions"156 except (ValueError, TypeError):157 pass158159 # commodités : IDs RentCafe -> libellés français160 amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or [])161 if a in _AMENITY_FR]162163 # disponibilité : le flux n'expose qu'un booléen d'occupation + une164 # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé165 occupied = p.get("IsFullyOccupied") is True166 availability = ("Aucune unité disponible" if occupied167 else "Unités disponibles")168 avail_date = None169 mad = str(p.get("dtMinUnitAvailable") or "").strip()170 md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad)171 if md:172 avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}"173174 details: dict = {}175 phone = (p.get("phone") or "").strip()176 if phone:177 details["contact"] = {"phone": phone}178179 # fiche propriété (description, galerie, plans d'étage) — via le cache180 # BD : revisitée seulement quand la ligne du flux change181 feed_key = hashlib.sha1("|".join(str(x) for x in (182 p.get("propertyMinRent"), p.get("propertyMaxRent"),183 p.get("propertyMinBed"), p.get("propertyMaxBed"),184 p.get("propertyMinArea"), p.get("propertyMaxArea"),185 p.get("IsFullyOccupied"), mad, p.get("propertyThumb"),186 )).encode("utf-8")).hexdigest()187 d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))188189 desc = d.get("description") or ""190 images = list(d.get("images") or [])191 thumb = (p.get("propertyThumb") or "").strip()192 if thumb and thumb not in images:193 images.insert(0, thumb)194 images = images[: self.max_images]195196 common = dict(197 source=self.source_id, url=url, address=full_addr, sector=sector,198 city=city, province="ON", availability=availability,199 availability_date=avail_date, pets=pets, description=desc,200 amenities=amenities[:25], images=images, lat=lat, lng=lng,201 )202203 # une annonce par plan d'étage (prix/chambres/sdb/pi² précis)204 out: list[Listing] = []205 for fp in d.get("floorplans") or []:206 price = fp.get("price")207 beds = fp.get("beds")208 out.append(Listing(209 external_id=f"{pid}-{fp['fpid']}",210 title=f"{name} — {fp['name']}" if fp.get("name") else name,211 unit_type=("Studio" if beds == 0 else normalize_unit_type(212 f"{int(beds)} chambres") if beds is not None else ""),213 bedrooms=beds,214 bathrooms=fp.get("baths"),215 price=price,216 price_label=(f"À partir de {price:.0f} $ /mois"217 if price is not None else ""),218 area_sqft=fp.get("sqft"),219 details=dict(details),220 **common,221 ))222 if out:223 return out224225 # repli : une annonce par propriété (fourchettes du flux liste)226 price = None227 try:228 price = float(p.get("propertyMinRent") or 0) or None229 except (TypeError, ValueError):230 pass231 price_label = ""232 if price is not None:233 try:234 pmax = float(p.get("propertyMaxRent") or 0)235 except (TypeError, ValueError):236 pmax = 0.0237 price_label = (f"À partir de {price:.0f} $"238 if pmax and pmax != price else f"{price:.0f} $ /mois")239 beds = baths = None240 try:241 bmin, bmax = float(p.get("propertyMinBed") or -1), \242 float(p.get("propertyMaxBed") or -1)243 if bmin >= 0 and bmin == bmax:244 beds = bmin245 if float(p.get("propertyMinBath") or 0) > 0:246 baths = float(p["propertyMinBath"])247 except (TypeError, ValueError):248 pass249 area = None250 try:251 v = float(p.get("propertyMinArea") or 0)252 if 80 <= v <= 20000:253 area = v254 except (TypeError, ValueError):255 pass256 return [Listing(257 external_id=pid,258 title=name,259 unit_type=("Studio" if beds == 0 else normalize_unit_type(260 f"{int(beds)} chambres") if beds is not None else ""),261 bedrooms=beds,262 bathrooms=baths,263 price=price,264 price_label=price_label,265 area_sqft=area,266 details=details,267 **common,268 )]269270 # -- fiche propriété : description + galerie + plans d'étage ----------------271 def _fetch_detail(self, url: str) -> dict:272 out: dict = {"description": "", "images": [], "floorplans": []}273 try:274 page = self.get(url).text275 except Exception:276 return out277 soup = BeautifulSoup(page, "html.parser")278279 # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. /280 # Starting at $X /Month » (modal #fp-modal-<id> = identifiant stable)281 fp_imgs: set[str] = set()282 for card in soup.select(".card"):283 txt = card.get_text(" ", strip=True)284 if "Bed" not in txt and "Studio" not in txt:285 continue286 ref = card.find(attrs={"data-target": _FPID_RE})287 mid = _FPID_RE.search(ref["data-target"]) if ref else None288 if not mid:289 continue290 h = card.find(["h2", "h3", "h4"])291 mb = _BED_RE.search(txt)292 beds = (float(mb.group(1)) if mb293 else 0.0 if "Studio" in txt else None)294 mba = _BATH_RE.search(txt)295 msq = _SQFT_RE.search(txt)296 sqft = float(msq.group(1).replace(",", "")) if msq else None297 self._append_fp(out, {298 "fpid": mid.group(1),299 "name": h.get_text(" ", strip=True) if h else "",300 "beds": beds,301 "baths": float(mba.group(1)) if mba else None,302 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,303 "price": _money(txt),304 })305 for img in card.find_all("img"):306 src = img.get("src") or img.get("data-src") or ""307 if src:308 fp_imgs.add(src)309310 # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage311 images: list[str] = []312 for img in soup.find_all("img"):313 src = (img.get("src") or img.get("data-src") or "").strip()314 if ("resource.rentcafe.com" not in src or src in fp_imgs315 or img.find_parent(class_=re.compile(r"ysi-logo"))):316 continue317 if not img.find_parent(class_=re.compile(r"ysi-picture")):318 continue319 # variante généreuse de la transformation Cloudinary320 src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",321 "c_limit,w_1200", src)322 if src not in images:323 images.append(src)324 out["images"] = images[: self.max_images]325326 # description : paragraphes de présentation (sans le boilerplate327 # consentement/cookies ni le disclaimer promo)328 paras = []329 for ptag in soup.find_all("p"):330 t = ptag.get_text(" ", strip=True)331 if len(t) < 150 or _BOILERPLATE_RE.search(t):332 continue333 t = _DISCLAIMER_RE.sub("", t).strip()334 if t and t not in paras:335 paras.append(t)336 out["description"] = " ".join(paras)[:900]337 return out338339 @staticmethod340 def _append_fp(out: dict, fp: dict) -> None:341 """Ajoute un plan d'étage en dédoublonnant sur son identifiant."""342 if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}:343 out["floorplans"].append(fp)344