# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/realstar.py : connecteur Realstar (realstar.ca) # Site protégé par Cloudflare (403 pour les robots) et rendu côté client # (moteur RentCafe/Yardi) : tout passe par Firecrawl avec attente de rendu. # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse, # lits/sdb/pi², fourchette de prix, téléphone, vignette) ; # 2) fiche de chaque propriété couverte (Grand Montréal, Gatineau, # Sherbrooke) -> galerie photos, description, points forts ; # 3) fiche /floorplans -> plans structurés (type, chambres, pi², prix, # nombre d'unités disponibles) — disponibilité et prix réels. # Une annonce par propriété (uid stables). Les pages détail passent par # self.detail(...) (cache BD) : Firecrawl n'est rappelé que si la carte # liste a changé. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import FIRECRAWL_API, BaseConnector SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Quebec" # Ville du chemin /apartments/qc// -> (ville affichée, secteur) _GM_CITIES = { "montreal": ("Montréal", ""), "cote-saint-luc": ("Côte-Saint-Luc", ""), "brossard": ("Brossard", ""), "pointe-claire": ("Pointe-Claire", ""), "boisbriand": ("Boisbriand", ""), # Rive-Nord proche "sainte-therese": ("Sainte-Thérèse", ""), # Rive-Nord proche "laval": ("Laval", ""), "longueuil": ("Longueuil", ""), # Expansion provinciale (2026-08) "gatineau": ("Gatineau", ""), "hull": ("Gatineau", "Hull"), "sherbrooke": ("Sherbrooke", ""), } _BED_TYPES = {"0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"} _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I) _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?") class _BudgetReached(Exception): """Plafond de requêtes Firecrawl atteint pour cette synchronisation.""" class RealstarConnector(BaseConnector): source_id = "realstar" request_delay = 1.0 max_properties = 12 # garde-fou (2 appels Firecrawl par propriété) max_images = 25 max_renders = 20 # plafond d'appels Firecrawl par sync (hors cache) # -- Firecrawl avec attente de rendu (SPA + Cloudflare) ------------------- def _rendered(self, url: str, wait_ms: int = 9000) -> str: # Clé absente : on tente quand même (le rejeu des fixtures intercepte # self.session ; en direct, Firecrawl répondra 401 -> erreur claire). 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", "") def fetch(self) -> list[Listing]: self._renders = 0 html = self._rendered(SEARCH_URL, 10000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("li.property-box") if not cards: # rendu incomplet : une seconde chance html = self._rendered(SEARCH_URL, 15000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("li.property-box") listings: list[Listing] = [] seen: set[str] = set() count = 0 for card in cards: try: a = card.select_one("a[href*='/apartments/qc/']") if not a: continue # autre province url = (a.get("href") or "").split("?")[0] url = url.replace("http://", "https://") m = re.search(r"/apartments/qc/([a-z0-9\-.]+)/([a-z0-9\-]+)", url) if not m or url in seen: continue seen.add(url) city_slug, slug = m.group(1), m.group(2) if city_slug not in _GM_CITIES: continue # ville QC non répertoriée dans _GM_CITIES if count >= self.max_properties: break count += 1 listings.append( self._property_listing(card, url, city_slug, slug)) except Exception: continue return listings def _property_listing(self, card, url: str, city_slug: str, slug: str) -> Listing: city, sector = _GM_CITIES[city_slug] name = "" fav = card.select_one("[data-property]") if fav: name = (fav.get("data-property") or "").strip() if not name: h = card.select_one(".property-name a") if h: name = h.get_text(" ", strip=True) name = re.sub(r"\s*opens in a new tab\s*", "", name).strip() name = name or slug.replace("-", " ").title() addr_el = card.select_one(".card-prop-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" meta = card.select_one(".card-bed-bath-rent") beds = baths = sqft = "" if meta: items = [li.get_text(" ", strip=True) for li in meta.select("li")] for it in items: if "Bed" in it: beds = it elif "Bath" in it: baths = it elif "Sq" in it: sqft = it unit_type = "" bm = re.match(r"^(\d)(?:\s|-)?.*Bed", beds or "") if bm and "-" not in beds.split("Bed")[0]: unit_type = _BED_TYPES.get(bm.group(1), "") # Fourchette de prix « $1,645.00 - $2,630.00 » card_text = card.get_text(" ", strip=True) price = None price_label = "" pm = re.search(r"\$[\d,]+(?:\.\d{2})?(?:(?:\s*(?:-|to))+\s*" r"\$[\d,]+(?:\.\d{2})?)?", card_text) if pm: price_label = re.sub(r"\s*to\s*-\s*", " - ", pm.group(0)) first = price_label.split("-")[0].replace("$", "").replace( ",", "").replace("to", "").strip() try: price = float(first) except ValueError: price = parse_price(price_label) if "-" in price_label: price_label = "À partir de " + price_label # Téléphone du bureau de location (lien tel: structuré de la carte) phone = "" tel = card.select_one("a[href^='tel:']") if tel: tm = re.search(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})", tel.get("href", "")) if tm: phone = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" # Vignette de la carte images: list[str] = [] img = card.select_one("img[src*='rentcafe']") if img and img.get("src"): images.append(img["src"]) # Pages détail (fiche + plans) via cache BD : Firecrawl seulement si la # carte liste a changé (prix/dispo inclus dans le hash). key = hashlib.sha1( f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}" .encode("utf-8")).hexdigest() try: payload = self.detail(slug, key, lambda: self._fetch_detail(url)) except _BudgetReached: payload = {} except Exception: payload = {} desc = payload.get("description", "") amenities = list(payload.get("amenities") or []) for im in (payload.get("images") or []): if im not in images: images.append(im) # Plans structurés -> disponibilité, prix « à partir de », superficie availability = "" area_sqft = None plans = payload.get("floorplans") or [] avail_plans = [p for p in plans if p.get("available", 0) > 0] if plans: total = sum(p.get("available", 0) for p in avail_plans) if total > 0: availability = (f"{total} unité(s) disponible(s) — " + ", ".join(p["name"] for p in avail_plans[:6])) prices = [p["price"] for p in avail_plans if p.get("price") and 100 <= p["price"] <= 20000] if prices: price = min(prices) price_label = (f"À partir de {price:,.0f} $/mois" .replace(",", " ") if len(avail_plans) > 1 or len(prices) > 1 else f"{price:,.0f} $/mois".replace(",", " ")) if len(avail_plans) == 1 and avail_plans[0].get("sqft"): # une seule unité type disponible : sa superficie est fiable area_sqft = avail_plans[0]["sqft"] if avail_plans[0].get("unit_type"): unit_type = avail_plans[0]["unit_type"] # Résumé des plans disponibles dans la description (texte fidèle) plan_bits = [] for p in avail_plans[:8]: seg = p["name"] if p.get("sqft"): seg += f" ({p['sqft']:.0f} pi²)" if p.get("price"): seg += f" : {p['price']:,.0f} $/mois".replace(",", " ") plan_bits.append(seg) details: dict = {} if phone: details["contact"] = {"phone": phone} bits = [b for b in [beds, baths, sqft] if b] desc_parts = ([desc] if desc else []) + bits if plan_bits: desc_parts.append("Disponibles : " + " ; ".join(plan_bits)) return Listing( source=self.source_id, external_id=slug, url=url, title=name, address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area_sqft, description=" — ".join(desc_parts)[:900], amenities=amenities, details=details, images=images[: self.max_images], ) # -- pages détail (fiche propriété + plans) -------------------------------- def _fetch_detail(self, url: str) -> dict: """2 rendus Firecrawl : fiche (photos, description, points forts) et /floorplans (plans structurés). Appelé seulement hors cache.""" if self._renders + 2 > self.max_renders: raise _BudgetReached() self._renders += 2 payload: dict = {"description": "", "amenities": [], "images": [], "floorplans": []} try: ph = self._rendered(url, 8000) psoup = BeautifulSoup(ph, "html.parser") for im in psoup.select("img[src*='resource.rentcafe.com']"): src = im.get("src", "") if src and not _SKIP_IMG.search(src) \ and src not in payload["images"]: payload["images"].append(src) # description : premiers paragraphes substantiels paras = [p.get_text(" ", strip=True) for p in psoup.find_all("p")] paras = [p for p in paras if len(p) > 80] if paras: payload["description"] = " ".join(paras[:2])[:600] # points forts de la propriété (courtes mentions après le titre) text = psoup.get_text("\n", strip=True) hm = re.search(r"Points forts de la propri[ée]t[ée]\n(.*?)\n" r"(?:Photos|Emplacement|Votre)", text, re.S) if hm: amenities = [] for t in hm.group(1).split("\n"): t = t.strip() if 2 < len(t) < 50 and t not in amenities: amenities.append(t) payload["amenities"] = amenities[:15] except Exception: pass try: fh = self._rendered(url.rstrip("/") + "/floorplans", 10000) payload["floorplans"] = self._parse_floorplans(fh) except Exception: pass return payload @staticmethod def _parse_floorplans(html: str) -> list[dict]: """Cartes de plans RentCafe : nom (« 4 ½ D »), chambres, pi², prix, nombre d'unités disponibles (structuré : .fp-availability).""" soup = BeautifulSoup(html, "html.parser") plans: list[dict] = [] for cont in soup.select("div[id^='fp-container-']"): try: name_el = cont.select_one("span[data-selenium-id$='Name']") name = name_el.get_text(" ", strip=True) if name_el else "" if not name: continue avail = 0 av_el = cont.select_one(".fp-availability") if av_el: am = re.search(r"(\d+)", av_el.get_text(" ", strip=True)) if am: avail = int(am.group(1)) sqft = None sq_el = cont.select_one("span[data-selenium-id$='SqFt']") if sq_el: sm = re.search(r"([\d,]{2,})\s*Pi", sq_el.get_text(" ", strip=True), re.I) if sm: v = float(sm.group(1).replace(",", "")) if 80 <= v <= 20000: sqft = v price = None pm = _PRICE_RE.search(cont.get_text(" ", strip=True)) if pm: v = float(pm.group(0).replace("$", "").replace(",", "")) if 100 <= v <= 20000: price = v unit_type = "" um = re.match(r"^\s*(\d)\s*½", name) if um: n = int(um.group(1)) unit_type = "6½+" if n >= 6 else f"{n}½" elif re.match(r"(?i)^\s*studio", name): unit_type = "Studio" plans.append({"name": name, "available": avail, "sqft": sqft, "price": price, "unit_type": unit_type}) except Exception: continue return plans