# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/aalto.py : connecteur Aalto Suites (aaltosuites.ca — Zibi, Hull) # Tours locatives Aalto et Aalto II de Dream, premiers immeubles # résidentiels du quartier Zibi (rive québécoise, secteur Hull de # Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le # site). Site RentCafe/Yardi (gabarit « ritz ») derrière Cloudflare (403 # en direct) : rendu via Firecrawl comme realstar.py/osgoode.py. # La page /floorplans (française) publie une carte par PLAN : nom # (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc, # prix « à partir de $1,520.00/mois » et image du plan -> une annonce par # plan. Aucun décompte d'unités disponibles publié -> availability vide. # NB : le Crawl-delay 10 de zibi.ca ne s'applique qu'à zibi.ca (jamais # requêté ici) ; aaltosuites.ca n'impose aucun délai (2 rendus par sync). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import FIRECRAWL_API, BaseConnector BASE = "https://www.aaltosuites.ca" FLOORPLANS_URL = f"{BASE}/floorplans" # adresse du complexe publiée par le site (lien Google Maps du pied de page) ADDRESS = "10, rue Jos-Montferrand, Gatineau" def _slugify(s: str) -> str: s = strip_accents(s.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class AaltoConnector(BaseConnector): source_id = "aalto" request_delay = 2.0 max_plans = 60 # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) ----------- def _rendered(self, url: str, wait_ms: int = 9000) -> str: key = os.environ.get("FIRECRAWL_API_KEY", "") # via self.session : l'enregistreur de fixtures capture la réponse resp = self.session.post( FIRECRAWL_API, json={"url": url, "formats": ["html"], "waitFor": wait_ms}, headers={"Authorization": f"Bearer {key}"}, timeout=150, ) resp.raise_for_status() return (resp.json().get("data") or {}).get("html", "") @staticmethod def _unit_type(label: str) -> str: """« studio / 1 SdB » -> Studio ; « 2 Chambres à coucher / 2 SdB » ou « 1 chambre / 1 SdB » -> N½ par la couche commune.""" t = strip_accents(label.lower()) if "studio" in t: return "Studio" m = re.match(r"^(\d+)\s*chambre", t) if m: return normalize_unit_type(f"{m.group(1)} chambres") return "" # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: # description du complexe : premier paragraphe substantiel de l'accueil blurb = "" try: home = BeautifulSoup(self._rendered(BASE + "/", 8000), "html.parser") for p in home.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if len(t) > 100: blurb = t[:600] break except Exception: pass html = self._rendered(FLOORPLANS_URL, 10000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("div[id^='fp-container-']") if not cards: # rendu incomplet : une seconde chance html = self._rendered(FLOORPLANS_URL, 15000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("div[id^='fp-container-']") listings: dict[str, Listing] = {} for card in cards[: self.max_plans]: try: h2 = card.select_one("h2.property-title") name = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else "" if not name: continue ext = _slugify(name) if not ext or ext in listings: continue # « studio / 1 SdB » ● « 483 pc » typo = sqft_txt = "" for span in card.select(".property-details span"): t = re.sub(r"\s+", " ", span.get_text(" ", strip=True)) if re.search(r"(?i)sdb|chambre|studio", t): typo = typo or t elif re.search(r"\d\s*pc\b", t): sqft_txt = sqft_txt or t area = None m = re.search(r"([\d\s,]{2,7})\s*pc", sqft_txt) if m: v = float(m.group(1).replace(" ", "").replace(",", "")) if 80 <= v <= 20000: area = v # « à partir de $1,520.00 /mois » price = None price_label = "" amt = card.select_one(".pricing-amount") if amt: raw = amt.get_text(" ", strip=True) pm = re.search(r"\$?([\d,]+)(?:\.\d{2})?", raw) if pm: price = float(pm.group(1).replace(",", "")) price_label = f"à partir de {raw}/mois" img_el = card.select_one("img[src*='resource.rentcafe.com']") images = [img_el["src"]] if img_el and img_el.get("src") else [] building = name.split("|")[0].strip() desc_bits = [x for x in [typo, sqft_txt, building] if x] if blurb: desc_bits.append(blurb) listings[ext] = Listing( source=self.source_id, external_id=ext, url=FLOORPLANS_URL, title=name, address=ADDRESS, sector="Hull", # quartier Zibi, rive québécoise city="Gatineau", unit_type=self._unit_type(typo), price=price, price_label=price_label, availability="", # aucun décompte d'unités publié area_sqft=area, description=" — ".join(desc_bits)[:900], images=images, ) except Exception: continue return list(listings.values())