# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gwlra.py : connecteur GWL Realty Advisors Residential # (gwlraresidential.com — gestionnaire pancanadien, plateforme RentCafe/Yardi # derrière Cloudflare : tout passe par Scrapfly ASP). # Découverte : sitemap.xml → pages /apartments/qc///floorplans # (immeubles du Québec seulement). Chaque page floorplans (rendu serveur) # expose les plans « div.fp-container » (id fp-container-) avec le # prix « à partir de $X /mois » — seuls les plans avec prix sont retenus # (les autres = liste d'attente) — plus un JSON-LD ApartmentComplex # (adresse civique, animaux, chambres/sdb/pi² par plan). Une annonce par # plan d'étage disponible, external_id = -. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.gwlraresidential.com" SITEMAP_URL = f"{BASE}/sitemap.xml" FLOORPLAN_PATH_RE = re.compile( r"apartments/qc/([a-z0-9-]+)/([a-z0-9-]+)/floorplans") FPID_RE = re.compile(r"fp-container-(\d+)") LDJSON_RE = re.compile( r']*type="application/ld\+json"[^>]*>(.*?)', re.S) TITLE_RE = re.compile(r"\s*Plans d.étage au (.+?) à ", re.S) PRICE_RE = re.compile(r"à partir de\s*\|?\s*(\$[\d,\s]+(?:\.\d{2})?)", re.I) BED_RE = re.compile(r"(\d+)\s*\|\s*Chambre", re.I) BATH_RE = re.compile(r"([\d.]+)\s*\|\s*Salle", re.I) # ville du chemin d'URL -> nom d'affichage _CITIES = {"montreal": "Montréal", "laval": "Laval", "quebec": "Québec", "longueuil": "Longueuil", "brossard": "Brossard"} class GwlraConnector(BaseConnector): source_id = "gwlra" request_delay = 1.0 use_detail_cache = False # tout passe par Scrapfly, pages fraîches def fetch(self) -> list[Listing]: # Un raté transitoire (Scrapfly vide, vu 2026-08-30 ~04:20) produisait # un faux « 0 trouvé ok » (médiane 1) : retry du sitemap (3 essais, # backoff 5/10 s) puis erreur franche plutôt qu'un zéro silencieux. pages: dict[str, tuple[str, str]] = {} # slug -> (ville, chemin) for attempt in range(3): try: sitemap = self.get_scrapfly(SITEMAP_URL, render_js=False) except Exception: if attempt == 2: raise sitemap = "" for city, slug in FLOORPLAN_PATH_RE.findall(sitemap): pages.setdefault(slug, (city, f"apartments/qc/{city}/{slug}" "/floorplans")) if pages: break if attempt < 2: time.sleep(5 * (attempt + 1)) if not pages: raise RuntimeError( "sitemap sans page floorplans QC après 3 essais") listings: list[Listing] = [] errors = 0 last_exc: Exception | None = None for slug, (city_slug, path) in sorted(pages.items()): try: listings.extend(self._building(slug, city_slug, f"{BASE}/{path}")) except Exception as exc: errors += 1 last_exc = exc # tous les immeubles en erreur et rien récolté : erreur franche if not listings and last_exc is not None and errors == len(pages): raise last_exc return listings def _building(self, slug: str, city_slug: str, url: str) -> list[Listing]: # le sitemap contient des immeubles retirés (404) : vérifier le statut. # Les ratés transitoires (5xx, contenu vide) sont retentés 3 fois puis # remontés en erreur franche — seul un 404/410 vaut « immeuble retiré ». # Vu 2026-09-06 : Scrapfly renvoie parfois un rendu PARTIEL (200, ~7 Ko # de shell GTM/OneTrust sans grille de plans ni JSON-LD) → faux « 0 # trouvé ok ». Une page floorplans complète contient toujours le # JSON-LD ApartmentComplex (rendu serveur), même sans dispo : exiger # fp-container OU ApartmentComplex avant d'accepter le HTML. html = "" for attempt in range(3): result = self.scrapfly(url, render_js=True) status = result.get("status_code") if status in (404, 410): return [] html = result.get("content") or "" complete = "fp-container" in html or "ApartmentComplex" in html if status == 200 and html and complete: break if attempt < 2: time.sleep(5 * (attempt + 1)) else: raise RuntimeError( f"page floorplans {url} en échec après 3 essais " f"(statut {status!r}, contenu " f"{'vide' if not html else 'partiel/incomplet'})") soup = BeautifulSoup(html, "html.parser") # JSON-LD ApartmentComplex : nom, adresse, animaux, détail des plans # (extraction regex sur le HTML brut : le rendu Scrapfly est parfois # capricieux avec les <script> dans BeautifulSoup) name, address, pets = "", "", None hero = "" plans: dict[str, dict] = {} # nom du plan -> détail JSON-LD blobs = [s.string or s.get_text() for s in soup.find_all("script", type="application/ld+json")] blobs += LDJSON_RE.findall(html) for raw in blobs: try: d = json.loads(raw or "", strict=False) except (ValueError, TypeError): continue if not isinstance(d, dict) or "ApartmentComplex" not in \ str(d.get("@type", "")): continue name = (d.get("name") or "").strip() adr = d.get("address") or {} street = (adr.get("streetAddress") or "").strip() loc = (adr.get("addressLocality") or "").strip() postal = (adr.get("postalCode") or "").strip() address = ", ".join(x for x in (street, loc) if x) if address and postal: address += f", QC {postal}" if isinstance(d.get("petsAllowed"), bool): pets = "oui" if d["petsAllowed"] else "non" for fp in d.get("accommodationFloorPlan") or []: if isinstance(fp, dict) and fp.get("name"): plans[fp["name"].strip().lower()] = fp if isinstance(d.get("image"), str): hero = d["image"] break if not name: # repli : <title> de la page tm = TITLE_RE.search(html) if tm: name = tm.group(1).strip() city = _CITIES.get(city_slug, city_slug.replace("-", " ").title()) listings: list[Listing] = [] for div in soup.select("div.fp-container[id]"): m = FPID_RE.search(div.get("id", "")) if not m: continue fpid = m.group(1) text = div.get_text(" | ", strip=True).replace("\xa0", " ") pm = PRICE_RE.search(text) if not pm: # pas de prix = liste d'attente continue fp_name = "" h = div.find(["h2", "h3", "h4", "strong"]) if h: fp_name = h.get_text(" ", strip=True) if not fp_name: fp_name = text.split(" | ", 1)[0] bedrooms = bathrooms = None area = None bm = BED_RE.search(text) if bm: bedrooms = float(bm.group(1)) am = BATH_RE.search(text) if am: try: bathrooms = float(am.group(1)) except ValueError: pass fp = plans.get(fp_name.strip().lower()) if fp: if bedrooms is None and fp.get("numberOfBedrooms") is not None: bedrooms = float(fp["numberOfBedrooms"]) if bathrooms is None and \ fp.get("numberOfFullBathrooms") is not None: bathrooms = float(fp["numberOfFullBathrooms"]) size = fp.get("floorSize") or {} if isinstance(size, dict) and size.get("minValue"): area = float(size["minValue"]) images = [] for img in div.find_all("img"): u = img.get("src") or img.get("data-src") or "" if u.startswith("http"): images.append(u) if hero: images.append(hero) price_label = f"À partir de {pm.group(1)}/mois" listings.append(Listing( source=self.source_id, external_id=f"{slug}-{fpid}", url=url, title=f"{name or slug} — {fp_name}", address=address, sector="", city=city, unit_type=normalize_unit_type(fp_name), bedrooms=bedrooms, bathrooms=bathrooms, price=parse_price(pm.group(1)), price_label=price_label, availability="Disponible", area_sqft=area, pets=pets, images=list(dict.fromkeys(images))[:10], )) return listings