spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/aalto.py : connecteur Aalto Suites (aaltosuites.ca — Zibi, Hull)5# Tours locatives Aalto et Aalto II de Dream, premiers immeubles6# résidentiels du quartier Zibi (rive québécoise, secteur Hull de7# Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le8# site). Site RentCafe/Yardi (gabarit « ritz ») derrière Cloudflare (4039# en direct) : rendu via Firecrawl comme realstar.py/osgoode.py.10# La page /floorplans (française) publie une carte par PLAN : nom11# (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc,12# prix « à partir de $1,520.00/mois » et image du plan -> une annonce par13# plan. Aucun décompte d'unités disponibles publié -> availability vide.14# NB : le Crawl-delay 10 de zibi.ca ne s'applique qu'à zibi.ca (jamais15# requêté ici) ; aaltosuites.ca n'impose aucun délai (2 rendus par sync).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import os20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import FIRECRAWL_API, BaseConnector2627BASE = "https://www.aaltosuites.ca"28FLOORPLANS_URL = f"{BASE}/floorplans"2930# adresse du complexe publiée par le site (lien Google Maps du pied de page)31ADDRESS = "10, rue Jos-Montferrand, Gatineau"323334def _slugify(s: str) -> str:35 s = strip_accents(s.lower())36 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")373839class AaltoConnector(BaseConnector):40 source_id = "aalto"41 request_delay = 2.042 max_plans = 604344 # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) -----------45 def _rendered(self, url: str, wait_ms: int = 9000) -> str:46 key = os.environ.get("FIRECRAWL_API_KEY", "")47 # via self.session : l'enregistreur de fixtures capture la réponse48 resp = self.session.post(49 FIRECRAWL_API,50 json={"url": url, "formats": ["html"], "waitFor": wait_ms},51 headers={"Authorization": f"Bearer {key}"},52 timeout=150,53 )54 resp.raise_for_status()55 return (resp.json().get("data") or {}).get("html", "")5657 @staticmethod58 def _unit_type(label: str) -> str:59 """« studio / 1 SdB » -> Studio ; « 2 Chambres à coucher / 2 SdB »60 ou « 1 chambre / 1 SdB » -> N½ par la couche commune."""61 t = strip_accents(label.lower())62 if "studio" in t:63 return "Studio"64 m = re.match(r"^(\d+)\s*chambre", t)65 if m:66 return normalize_unit_type(f"{m.group(1)} chambres")67 return ""6869 # -- fetch -----------------------------------------------------------------70 def fetch(self) -> list[Listing]:71 # description du complexe : premier paragraphe substantiel de l'accueil72 blurb = ""73 try:74 home = BeautifulSoup(self._rendered(BASE + "/", 8000),75 "html.parser")76 for p in home.find_all("p"):77 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))78 if len(t) > 100:79 blurb = t[:600]80 break81 except Exception:82 pass8384 html = self._rendered(FLOORPLANS_URL, 10000)85 soup = BeautifulSoup(html, "html.parser")86 cards = soup.select("div[id^='fp-container-']")87 if not cards: # rendu incomplet : une seconde chance88 html = self._rendered(FLOORPLANS_URL, 15000)89 soup = BeautifulSoup(html, "html.parser")90 cards = soup.select("div[id^='fp-container-']")9192 listings: dict[str, Listing] = {}93 for card in cards[: self.max_plans]:94 try:95 h2 = card.select_one("h2.property-title")96 name = re.sub(r"\s+", " ",97 h2.get_text(" ", strip=True)) if h2 else ""98 if not name:99 continue100 ext = _slugify(name)101 if not ext or ext in listings:102 continue103104 # « studio / 1 SdB » ● « 483 pc »105 typo = sqft_txt = ""106 for span in card.select(".property-details span"):107 t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))108 if re.search(r"(?i)sdb|chambre|studio", t):109 typo = typo or t110 elif re.search(r"\d\s*pc\b", t):111 sqft_txt = sqft_txt or t112 area = None113 m = re.search(r"([\d\s,]{2,7})\s*pc", sqft_txt)114 if m:115 v = float(m.group(1).replace(" ", "").replace(",", ""))116 if 80 <= v <= 20000:117 area = v118119 # « à partir de $1,520.00 /mois »120 price = None121 price_label = ""122 amt = card.select_one(".pricing-amount")123 if amt:124 raw = amt.get_text(" ", strip=True)125 pm = re.search(r"\$?([\d,]+)(?:\.\d{2})?", raw)126 if pm:127 price = float(pm.group(1).replace(",", ""))128 price_label = f"à partir de {raw}/mois"129130 img_el = card.select_one("img[src*='resource.rentcafe.com']")131 images = [img_el["src"]] if img_el and img_el.get("src") else []132133 building = name.split("|")[0].strip()134 desc_bits = [x for x in [typo, sqft_txt, building] if x]135 if blurb:136 desc_bits.append(blurb)137138 listings[ext] = Listing(139 source=self.source_id,140 external_id=ext,141 url=FLOORPLANS_URL,142 title=name,143 address=ADDRESS,144 sector="Hull", # quartier Zibi, rive québécoise145 city="Gatineau",146 unit_type=self._unit_type(typo),147 price=price,148 price_label=price_label,149 availability="", # aucun décompte d'unités publié150 area_sqft=area,151 description=" — ".join(desc_bits)[:900],152 images=images,153 )154 except Exception:155 continue156 return list(listings.values())157