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/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# -----------------------------------------------------------------------------26from __future__ import annotations2728import hashlib29import json30import os31import re3233from bs4 import BeautifulSoup3435from ..schema import Listing, normalize_unit_type, strip_accents36from .base import BaseConnector3738BASE = "https://www.accommod8u.com"39SEARCH_PAGE = f"{BASE}/residential/apartments"4041# Rent-Ka: connector always active.42_ONTARIO = True # Rent-Ka: always on (ROC scope)4344# blob JSON du portefeuille (input caché RentCafe, doublement encodé)45_PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S)4647# cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. »48_BED_RE = re.compile(r"([\d.]+)\s*Bed\b")49_BATH_RE = re.compile(r"([\d.]+)\s*Bath")50_SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I)51_PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)")525354class Accommod8uConnector(BaseConnector):55 source_id = "accommod8u"56 request_delay = 1.5 # tout passe par Scrapfly ASP : rester très poli57 disabled = False58 max_properties = 20 # garde-fou (10 immeubles au 2026-08)59 max_images = 156061 # -- portefeuille : blob embarqué de la page de recherche --------------------62 def _properties(self) -> list[dict]:63 page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True)64 m = _PROP_BLOB_RE.search(page or "")65 if not m:66 raise RuntimeError("Accommod8u : blob #available_prop introuvable "67 "(gabarit RentCafe modifié ou échec ASP)")68 return json.loads(json.loads(m.group(1)))6970 def fetch(self) -> list[Listing]:71 listings: list[Listing] = []72 count = 073 for p in self._properties():74 if (p.get("propertyState") or "").strip().upper() != "ON":75 continue76 if p.get("IsFullyOccupied") is True:77 continue # complet (« Fully Leased ») : rien à publier78 if count >= self.max_properties:79 break80 count += 181 try:82 listings.extend(self._listings(p))83 except Exception:84 continue85 return listings8687 # -- annonces d'un immeuble (une par plan d'étage disponible) ----------------88 def _listings(self, p: dict) -> list[Listing]:89 pid = str(p.get("propertyid"))90 name = (p.get("propertyName") or "").strip()91 # microsite RentCafe de l'immeuble (sans le paramètre de tracking)92 site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")93 .split("?")[0].rstrip("/"))94 url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE9596 city = (p.get("propertyCity") or "").strip()97 street = (p.get("propertyAddress") or "").strip()98 postal = (p.get("propertyZipCode") or "").strip()99 address = ", ".join(x for x in (street, city) if x) + ", ON"100 if postal:101 address += f" {postal}"102103 try:104 lat = float(p.get("propertyLat")) or None105 lng = float(p.get("propertyLng")) or None106 except (TypeError, ValueError):107 lat = lng = None108109 # politique animaux structurée du flux110 pets = None111 try:112 pol = p.get("bPetPolicy") or {}113 if isinstance(pol, str):114 pol = json.loads(pol)115 if pol.get("bNoPetsAllowed") is True:116 pets = "non"117 elif pol.get("bCats") and pol.get("bDogs"):118 pets = "oui"119 elif pol.get("bCats") or pol.get("bDogs"):120 pets = "conditions"121 except (ValueError, TypeError):122 pass123124 details: dict = {}125 if (p.get("phone") or "").strip():126 details["contact"] = {"phone": p["phone"].strip()}127128 # page /floorplans du microsite via le cache BD : revisitée seulement129 # quand la ligne du flux change (loyers, occupation, vignette)130 feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in (131 "propertyMinRent", "propertyMaxRent", "propertyMinBed",132 "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable",133 "propertyThumb", "PropertySiteUrl",134 )).encode("utf-8")).hexdigest()135 d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url))136137 thumb = (p.get("propertyThumb") or "").strip()138 base_images = [thumb] if thumb else []139140 common = dict(141 source=self.source_id, url=url, address=address, city=city,142 province="ON", pets=pets, lat=lat, lng=lng,143 )144145 out: list[Listing] = []146 for fp in d.get("floorplans") or []:147 if fp.get("price") is None:148 continue # « Call for details » sans lien Availability :149 # aucune unité annoncée disponible — rien d'inventé150 images = [u for u in base_images + (fp.get("images") or [])151 if u][: self.max_images]152 beds = fp.get("beds")153 slug = re.sub(r"[^a-z0-9]+", "-",154 strip_accents((fp.get("name") or "").lower())155 ).strip("-")156 out.append(Listing(157 external_id=f"{pid}-{slug or 'u'}",158 title=f"{name} — {fp['name']}" if fp.get("name") else name,159 unit_type=self._unit_type(fp.get("name") or "", beds),160 bedrooms=beds,161 bathrooms=fp.get("baths"),162 price=fp["price"],163 price_label=f"À partir de {fp['price']:.0f} $ /mois",164 availability="Unités disponibles",165 area_sqft=fp.get("sqft"),166 details=dict(details),167 images=images,168 **common,169 ))170 if out:171 return out172173 # repli : une annonce par immeuble avec le loyer plancher du flux174 price = None175 try:176 price = float(p.get("propertyMinRent") or 0) or None177 except (TypeError, ValueError):178 pass179 if price is None:180 return [] # ni plan disponible ni loyer affiché : rien181 beds = None182 try:183 bmin = float(p.get("propertyMinBed") or -1)184 if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1):185 beds = bmin186 except (TypeError, ValueError):187 pass188 return [Listing(189 external_id=pid,190 title=name,191 unit_type=("Studio" if beds == 0 else normalize_unit_type(192 f"{int(beds)} chambres") if beds is not None else ""),193 bedrooms=beds,194 price=price,195 price_label=f"À partir de {price:.0f} $ /mois",196 availability="Unités disponibles",197 details=details,198 images=base_images[: self.max_images],199 **common,200 )]201202 @staticmethod203 def _unit_type(fp_name: str, beds: float | None) -> str:204 """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc."""205 ut = normalize_unit_type(fp_name)206 if ut and ut != fp_name.strip():207 return ut208 if beds == 0:209 return "Studio"210 if beds is not None:211 return normalize_unit_type(f"{int(beds)} chambres")212 return ut213214 # -- page /floorplans du microsite RentCafe ----------------------------------215 def _fetch_floorplans(self, url: str) -> dict:216 out: dict = {"floorplans": []}217 if not url.startswith("http"):218 return out219 try:220 page = self.get_scrapfly(url, render_js=False, asp=True)221 except Exception:222 return out223 if not page:224 return out225 soup = BeautifulSoup(page, "html.parser")226 seen: set[str] = set()227 for card in soup.select(".fp-container"):228 txt = card.get_text(" ", strip=True)229 h = card.find(["h2", "h3", "h4"])230 fp_name = h.get_text(" ", strip=True) if h else ""231 if not fp_name:232 # premier segment avant « N Bed » (gabarit compact)233 fp_name = re.split(r"\d+\s*Bed", txt)[0].strip()234 if not fp_name or fp_name.lower() in seen:235 continue236 seen.add(fp_name.lower())237 mb = _BED_RE.search(txt)238 mba = _BATH_RE.search(txt)239 msq = _SQFT_RE.search(txt)240 sqft = float(msq.group(1).replace(",", "")) if msq else None241 # prix seulement si des unités sont disponibles (lien Availability)242 price = None243 has_avail = card.find(244 "a", href=re.compile(r"/floorplans/")) is not None245 mp = _PRICE_RE.search(txt)246 if mp and has_avail:247 price = float(mp.group(1).replace(",", ""))248 images = []249 for img in card.find_all("img"):250 src = (img.get("src") or img.get("data-src") or "").strip()251 if src and "resource.rentcafe.com" in src \252 and src not in images:253 src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",254 "c_limit,w_1200", src)255 images.append(src)256 out["floorplans"].append({257 "name": fp_name,258 "beds": (float(mb.group(1)) if mb259 else 0.0 if "Studio" in txt else None),260 "baths": float(mba.group(1)) if mba else None,261 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,262 "price": price,263 "images": images[:5],264 })265 return out266