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/accommod8u.py : connecteur Accommod8u (accommod8u.com)5# Gros gestionnaire de Waterloo ON (~10 immeubles/villages au moment de6# l'écriture : tours Albert/Lester/Sunview, THE LINQ, villages étudiants7# Linden/Spring/Walnut au bail mensuel — longue durée, donc pertinent).8# Site « corporate » Yardi RentCafe derrière Cloudflare : curl nu = 403,9# Scrapfly asp=true suffit (rendu serveur, PAS de render_js). Comme Killam,10# la page /residential/apartments embarque TOUT le portefeuille dans11# l'input caché `#available_prop` (JSON doublement encodé) : nom, adresse,12# ville/ON/code postal, lat/lng, fourchette de loyer, politique animaux,13# téléphone, vignette, occupation et l'URL du MICROSITE RentCafe de chaque14# immeuble (apartments-waterloo-228albert.com…).15# La page /floorplans du microsite (même anti-bot, via self.detail() +16# Scrapfly) donne les cartes plans d'étage : « N Bed / N Bath / N Sq. Ft. /17# Starting at $X /Month » quand des unités sont disponibles, « Call for18# details » sinon (carte sans lien « Availability » → écartée, rien19# d'inventé). Une annonce PAR PLAN D'ÉTAGE DISPONIBLE, avec repli « une20# annonce par immeuble » (loyer plancher du flux) si aucune carte n'a de21# prix mais que l'immeuble n'est pas complet. Les villages étudiants22# affichent des loyers à la chambre (~600 $) : le nom du plan (« Room »,23# « 4 Bedroom »…) donne le type d'unité, jamais de valeur inventée.24# Immeubles IsFullyOccupied (ex. « Fir Village (Fully Leased) ») : ignorés.25# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.26# -----------------------------------------------------------------------------27from __future__ import annotations2829import hashlib30import json31import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type, strip_accents37from .base import BaseConnector3839BASE = "https://www.accommod8u.com"40SEARCH_PAGE = f"{BASE}/residential/apartments"4142# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent43_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4445# blob JSON du portefeuille (input caché RentCafe, doublement encodé)46_PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S)4748# cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. »49_BED_RE = re.compile(r"([\d.]+)\s*Bed\b")50_BATH_RE = re.compile(r"([\d.]+)\s*Bath")51_SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I)52_PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)")535455class Accommod8uConnector(BaseConnector):56 source_id = "accommod8u"57 request_delay = 1.5 # tout passe par Scrapfly ASP : rester très poli58 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)59 max_properties = 20 # garde-fou (10 immeubles au 2026-08)60 max_images = 156162 # -- portefeuille : blob embarqué de la page de recherche --------------------63 def _properties(self) -> list[dict]:64 page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True)65 m = _PROP_BLOB_RE.search(page or "")66 if not m:67 raise RuntimeError("Accommod8u : blob #available_prop introuvable "68 "(gabarit RentCafe modifié ou échec ASP)")69 return json.loads(json.loads(m.group(1)))7071 def fetch(self) -> list[Listing]:72 listings: list[Listing] = []73 count = 074 for p in self._properties():75 if (p.get("propertyState") or "").strip().upper() != "ON":76 continue77 if p.get("IsFullyOccupied") is True:78 continue # complet (« Fully Leased ») : rien à publier79 if count >= self.max_properties:80 break81 count += 182 try:83 listings.extend(self._listings(p))84 except Exception:85 continue86 return listings8788 # -- annonces d'un immeuble (une par plan d'étage disponible) ----------------89 def _listings(self, p: dict) -> list[Listing]:90 pid = str(p.get("propertyid"))91 name = (p.get("propertyName") or "").strip()92 # microsite RentCafe de l'immeuble (sans le paramètre de tracking)93 site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")94 .split("?")[0].rstrip("/"))95 url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE9697 city = (p.get("propertyCity") or "").strip()98 street = (p.get("propertyAddress") or "").strip()99 postal = (p.get("propertyZipCode") or "").strip()100 address = ", ".join(x for x in (street, city) if x) + ", ON"101 if postal:102 address += f" {postal}"103104 try:105 lat = float(p.get("propertyLat")) or None106 lng = float(p.get("propertyLng")) or None107 except (TypeError, ValueError):108 lat = lng = None109110 # politique animaux structurée du flux111 pets = None112 try:113 pol = p.get("bPetPolicy") or {}114 if isinstance(pol, str):115 pol = json.loads(pol)116 if pol.get("bNoPetsAllowed") is True:117 pets = "non"118 elif pol.get("bCats") and pol.get("bDogs"):119 pets = "oui"120 elif pol.get("bCats") or pol.get("bDogs"):121 pets = "conditions"122 except (ValueError, TypeError):123 pass124125 details: dict = {}126 if (p.get("phone") or "").strip():127 details["contact"] = {"phone": p["phone"].strip()}128129 # page /floorplans du microsite via le cache BD : revisitée seulement130 # quand la ligne du flux change (loyers, occupation, vignette)131 feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in (132 "propertyMinRent", "propertyMaxRent", "propertyMinBed",133 "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable",134 "propertyThumb", "PropertySiteUrl",135 )).encode("utf-8")).hexdigest()136 d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url))137138 thumb = (p.get("propertyThumb") or "").strip()139 base_images = [thumb] if thumb else []140141 common = dict(142 source=self.source_id, url=url, address=address, city=city,143 province="ON", pets=pets, lat=lat, lng=lng,144 )145146 out: list[Listing] = []147 for fp in d.get("floorplans") or []:148 if fp.get("price") is None:149 continue # « Call for details » sans lien Availability :150 # aucune unité annoncée disponible — rien d'inventé151 images = [u for u in base_images + (fp.get("images") or [])152 if u][: self.max_images]153 beds = fp.get("beds")154 slug = re.sub(r"[^a-z0-9]+", "-",155 strip_accents((fp.get("name") or "").lower())156 ).strip("-")157 out.append(Listing(158 external_id=f"{pid}-{slug or 'u'}",159 title=f"{name} — {fp['name']}" if fp.get("name") else name,160 unit_type=self._unit_type(fp.get("name") or "", beds),161 bedrooms=beds,162 bathrooms=fp.get("baths"),163 price=fp["price"],164 price_label=f"À partir de {fp['price']:.0f} $ /mois",165 availability="Unités disponibles",166 area_sqft=fp.get("sqft"),167 details=dict(details),168 images=images,169 **common,170 ))171 if out:172 return out173174 # repli : une annonce par immeuble avec le loyer plancher du flux175 price = None176 try:177 price = float(p.get("propertyMinRent") or 0) or None178 except (TypeError, ValueError):179 pass180 if price is None:181 return [] # ni plan disponible ni loyer affiché : rien182 beds = None183 try:184 bmin = float(p.get("propertyMinBed") or -1)185 if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1):186 beds = bmin187 except (TypeError, ValueError):188 pass189 return [Listing(190 external_id=pid,191 title=name,192 unit_type=("Studio" if beds == 0 else normalize_unit_type(193 f"{int(beds)} chambres") if beds is not None else ""),194 bedrooms=beds,195 price=price,196 price_label=f"À partir de {price:.0f} $ /mois",197 availability="Unités disponibles",198 details=details,199 images=base_images[: self.max_images],200 **common,201 )]202203 @staticmethod204 def _unit_type(fp_name: str, beds: float | None) -> str:205 """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc."""206 ut = normalize_unit_type(fp_name)207 if ut and ut != fp_name.strip():208 return ut209 if beds == 0:210 return "Studio"211 if beds is not None:212 return normalize_unit_type(f"{int(beds)} chambres")213 return ut214215 # -- page /floorplans du microsite RentCafe ----------------------------------216 def _fetch_floorplans(self, url: str) -> dict:217 out: dict = {"floorplans": []}218 if not url.startswith("http"):219 return out220 try:221 page = self.get_scrapfly(url, render_js=False, asp=True)222 except Exception:223 return out224 if not page:225 return out226 soup = BeautifulSoup(page, "html.parser")227 seen: set[str] = set()228 for card in soup.select(".fp-container"):229 txt = card.get_text(" ", strip=True)230 h = card.find(["h2", "h3", "h4"])231 fp_name = h.get_text(" ", strip=True) if h else ""232 if not fp_name:233 # premier segment avant « N Bed » (gabarit compact)234 fp_name = re.split(r"\d+\s*Bed", txt)[0].strip()235 if not fp_name or fp_name.lower() in seen:236 continue237 seen.add(fp_name.lower())238 mb = _BED_RE.search(txt)239 mba = _BATH_RE.search(txt)240 msq = _SQFT_RE.search(txt)241 sqft = float(msq.group(1).replace(",", "")) if msq else None242 # prix seulement si des unités sont disponibles (lien Availability)243 price = None244 has_avail = card.find(245 "a", href=re.compile(r"/floorplans/")) is not None246 mp = _PRICE_RE.search(txt)247 if mp and has_avail:248 price = float(mp.group(1).replace(",", ""))249 images = []250 for img in card.find_all("img"):251 src = (img.get("src") or img.get("data-src") or "").strip()252 if src and "resource.rentcafe.com" in src \253 and src not in images:254 src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",255 "c_limit,w_1200", src)256 images.append(src)257 out["floorplans"].append({258 "name": fp_name,259 "beds": (float(mb.group(1)) if mb260 else 0.0 if "Studio" in txt else None),261 "baths": float(mba.group(1)) if mba else None,262 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,263 "price": price,264 "images": images[:5],265 })266 return out267