# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/accommod8u.py : connecteur Accommod8u (accommod8u.com) # Gros gestionnaire de Waterloo ON (~10 immeubles/villages au moment de # l'écriture : tours Albert/Lester/Sunview, THE LINQ, villages étudiants # Linden/Spring/Walnut au bail mensuel — longue durée, donc pertinent). # Site « corporate » Yardi RentCafe derrière Cloudflare : curl nu = 403, # Scrapfly asp=true suffit (rendu serveur, PAS de render_js). Comme Killam, # la page /residential/apartments embarque TOUT le portefeuille dans # l'input caché `#available_prop` (JSON doublement encodé) : nom, adresse, # ville/ON/code postal, lat/lng, fourchette de loyer, politique animaux, # téléphone, vignette, occupation et l'URL du MICROSITE RentCafe de chaque # immeuble (apartments-waterloo-228albert.com…). # La page /floorplans du microsite (même anti-bot, via self.detail() + # Scrapfly) donne les cartes plans d'étage : « N Bed / N Bath / N Sq. Ft. / # Starting at $X /Month » quand des unités sont disponibles, « Call for # details » sinon (carte sans lien « Availability » → écartée, rien # d'inventé). Une annonce PAR PLAN D'ÉTAGE DISPONIBLE, avec repli « une # annonce par immeuble » (loyer plancher du flux) si aucune carte n'a de # prix mais que l'immeuble n'est pas complet. Les villages étudiants # affichent des loyers à la chambre (~600 $) : le nom du plan (« Room », # « 4 Bedroom »…) donne le type d'unité, jamais de valeur inventée. # Immeubles IsFullyOccupied (ex. « Fir Village (Fully Leased) ») : ignorés. # Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://www.accommod8u.com" SEARCH_PAGE = f"{BASE}/residential/apartments" # Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" # blob JSON du portefeuille (input caché RentCafe, doublement encodé) _PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S) # cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. » _BED_RE = re.compile(r"([\d.]+)\s*Bed\b") _BATH_RE = re.compile(r"([\d.]+)\s*Bath") _SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I) _PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)") class Accommod8uConnector(BaseConnector): source_id = "accommod8u" request_delay = 1.5 # tout passe par Scrapfly ASP : rester très poli disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) max_properties = 20 # garde-fou (10 immeubles au 2026-08) max_images = 15 # -- portefeuille : blob embarqué de la page de recherche -------------------- def _properties(self) -> list[dict]: page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True) m = _PROP_BLOB_RE.search(page or "") if not m: raise RuntimeError("Accommod8u : blob #available_prop introuvable " "(gabarit RentCafe modifié ou échec ASP)") return json.loads(json.loads(m.group(1))) def fetch(self) -> list[Listing]: listings: list[Listing] = [] count = 0 for p in self._properties(): if (p.get("propertyState") or "").strip().upper() != "ON": continue if p.get("IsFullyOccupied") is True: continue # complet (« Fully Leased ») : rien à publier if count >= self.max_properties: break count += 1 try: listings.extend(self._listings(p)) except Exception: continue return listings # -- annonces d'un immeuble (une par plan d'étage disponible) ---------------- def _listings(self, p: dict) -> list[Listing]: pid = str(p.get("propertyid")) name = (p.get("propertyName") or "").strip() # microsite RentCafe de l'immeuble (sans le paramètre de tracking) site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "") .split("?")[0].rstrip("/")) url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE city = (p.get("propertyCity") or "").strip() street = (p.get("propertyAddress") or "").strip() postal = (p.get("propertyZipCode") or "").strip() address = ", ".join(x for x in (street, city) if x) + ", ON" if postal: address += f" {postal}" try: lat = float(p.get("propertyLat")) or None lng = float(p.get("propertyLng")) or None except (TypeError, ValueError): lat = lng = None # politique animaux structurée du flux pets = None try: pol = p.get("bPetPolicy") or {} if isinstance(pol, str): pol = json.loads(pol) 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 details: dict = {} if (p.get("phone") or "").strip(): details["contact"] = {"phone": p["phone"].strip()} # page /floorplans du microsite via le cache BD : revisitée seulement # quand la ligne du flux change (loyers, occupation, vignette) feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in ( "propertyMinRent", "propertyMaxRent", "propertyMinBed", "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable", "propertyThumb", "PropertySiteUrl", )).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url)) thumb = (p.get("propertyThumb") or "").strip() base_images = [thumb] if thumb else [] common = dict( source=self.source_id, url=url, address=address, city=city, province="ON", pets=pets, lat=lat, lng=lng, ) out: list[Listing] = [] for fp in d.get("floorplans") or []: if fp.get("price") is None: continue # « Call for details » sans lien Availability : # aucune unité annoncée disponible — rien d'inventé images = [u for u in base_images + (fp.get("images") or []) if u][: self.max_images] beds = fp.get("beds") slug = re.sub(r"[^a-z0-9]+", "-", strip_accents((fp.get("name") or "").lower()) ).strip("-") out.append(Listing( external_id=f"{pid}-{slug or 'u'}", title=f"{name} — {fp['name']}" if fp.get("name") else name, unit_type=self._unit_type(fp.get("name") or "", beds), bedrooms=beds, bathrooms=fp.get("baths"), price=fp["price"], price_label=f"À partir de {fp['price']:.0f} $ /mois", availability="Unités disponibles", area_sqft=fp.get("sqft"), details=dict(details), images=images, **common, )) if out: return out # repli : une annonce par immeuble avec le loyer plancher du flux price = None try: price = float(p.get("propertyMinRent") or 0) or None except (TypeError, ValueError): pass if price is None: return [] # ni plan disponible ni loyer affiché : rien beds = None try: bmin = float(p.get("propertyMinBed") or -1) if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1): beds = bmin 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, price=price, price_label=f"À partir de {price:.0f} $ /mois", availability="Unités disponibles", details=details, images=base_images[: self.max_images], **common, )] @staticmethod def _unit_type(fp_name: str, beds: float | None) -> str: """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc.""" ut = normalize_unit_type(fp_name) if ut and ut != fp_name.strip(): return ut if beds == 0: return "Studio" if beds is not None: return normalize_unit_type(f"{int(beds)} chambres") return ut # -- page /floorplans du microsite RentCafe ---------------------------------- def _fetch_floorplans(self, url: str) -> dict: out: dict = {"floorplans": []} if not url.startswith("http"): return out try: page = self.get_scrapfly(url, render_js=False, asp=True) except Exception: return out if not page: return out soup = BeautifulSoup(page, "html.parser") seen: set[str] = set() for card in soup.select(".fp-container"): txt = card.get_text(" ", strip=True) h = card.find(["h2", "h3", "h4"]) fp_name = h.get_text(" ", strip=True) if h else "" if not fp_name: # premier segment avant « N Bed » (gabarit compact) fp_name = re.split(r"\d+\s*Bed", txt)[0].strip() if not fp_name or fp_name.lower() in seen: continue seen.add(fp_name.lower()) mb = _BED_RE.search(txt) mba = _BATH_RE.search(txt) msq = _SQFT_RE.search(txt) sqft = float(msq.group(1).replace(",", "")) if msq else None # prix seulement si des unités sont disponibles (lien Availability) price = None has_avail = card.find( "a", href=re.compile(r"/floorplans/")) is not None mp = _PRICE_RE.search(txt) if mp and has_avail: price = float(mp.group(1).replace(",", "")) images = [] for img in card.find_all("img"): src = (img.get("src") or img.get("data-src") or "").strip() if src and "resource.rentcafe.com" in src \ and src not in images: src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?", "c_limit,w_1200", src) images.append(src) out["floorplans"].append({ "name": fp_name, "beds": (float(mb.group(1)) if mb else 0.0 if "Studio" in txt else None), "baths": float(mba.group(1)) if mba else None, "sqft": sqft if sqft and 80 <= sqft <= 20000 else None, "price": price, "images": images[:5], }) return out