Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gwlra.py : connecteur GWL Realty Advisors Residential5# (gwlraresidential.com — gestionnaire pancanadien, plateforme RentCafe/Yardi6# derrière Cloudflare : tout passe par Scrapfly ASP).7# Découverte : sitemap.xml → pages /apartments/qc/<ville>/<slug>/floorplans8# (immeubles du Québec seulement). Chaque page floorplans (rendu serveur)9# expose les plans « div.fp-container » (id fp-container-<fpid>) avec le10# prix « à partir de $X /mois » — seuls les plans avec prix sont retenus11# (les autres = liste d'attente) — plus un JSON-LD ApartmentComplex12# (adresse civique, animaux, chambres/sdb/pi² par plan). Une annonce par13# plan d'étage disponible, external_id = <slug>-<fpid>.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re19import time2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.gwlraresidential.com"27SITEMAP_URL = f"{BASE}/sitemap.xml"2829FLOORPLAN_PATH_RE = re.compile(30 r"apartments/qc/([a-z0-9-]+)/([a-z0-9-]+)/floorplans")31FPID_RE = re.compile(r"fp-container-(\d+)")32LDJSON_RE = re.compile(33 r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S)34TITLE_RE = re.compile(r"<title>\s*Plans d.étage au (.+?) à ", re.S)35PRICE_RE = re.compile(r"à partir de\s*\|?\s*(\$[\d,\s]+(?:\.\d{2})?)", re.I)36BED_RE = re.compile(r"(\d+)\s*\|\s*Chambre", re.I)37BATH_RE = re.compile(r"([\d.]+)\s*\|\s*Salle", re.I)3839# ville du chemin d'URL -> nom d'affichage40_CITIES = {"montreal": "Montréal", "laval": "Laval", "quebec": "Québec",41 "longueuil": "Longueuil", "brossard": "Brossard"}424344class GwlraConnector(BaseConnector):45 source_id = "gwlra"46 request_delay = 1.047 use_detail_cache = False # tout passe par Scrapfly, pages fraîches4849 def fetch(self) -> list[Listing]:50 # Un raté transitoire (Scrapfly vide, vu 2026-08-30 ~04:20) produisait51 # un faux « 0 trouvé ok » (médiane 1) : retry du sitemap (3 essais,52 # backoff 5/10 s) puis erreur franche plutôt qu'un zéro silencieux.53 pages: dict[str, tuple[str, str]] = {} # slug -> (ville, chemin)54 for attempt in range(3):55 try:56 sitemap = self.get_scrapfly(SITEMAP_URL, render_js=False)57 except Exception:58 if attempt == 2:59 raise60 sitemap = ""61 for city, slug in FLOORPLAN_PATH_RE.findall(sitemap):62 pages.setdefault(slug, (city, f"apartments/qc/{city}/{slug}"63 "/floorplans"))64 if pages:65 break66 if attempt < 2:67 time.sleep(5 * (attempt + 1))68 if not pages:69 raise RuntimeError(70 "sitemap sans page floorplans QC après 3 essais")7172 listings: list[Listing] = []73 errors = 074 last_exc: Exception | None = None75 for slug, (city_slug, path) in sorted(pages.items()):76 try:77 listings.extend(self._building(slug, city_slug,78 f"{BASE}/{path}"))79 except Exception as exc:80 errors += 181 last_exc = exc82 # tous les immeubles en erreur et rien récolté : erreur franche83 if not listings and last_exc is not None and errors == len(pages):84 raise last_exc85 return listings8687 def _building(self, slug: str, city_slug: str, url: str) -> list[Listing]:88 # le sitemap contient des immeubles retirés (404) : vérifier le statut.89 # Les ratés transitoires (5xx, contenu vide) sont retentés 3 fois puis90 # remontés en erreur franche — seul un 404/410 vaut « immeuble retiré ».91 # Vu 2026-09-06 : Scrapfly renvoie parfois un rendu PARTIEL (200, ~7 Ko92 # de shell GTM/OneTrust sans grille de plans ni JSON-LD) → faux « 093 # trouvé ok ». Une page floorplans complète contient toujours le94 # JSON-LD ApartmentComplex (rendu serveur), même sans dispo : exiger95 # fp-container OU ApartmentComplex avant d'accepter le HTML.96 html = ""97 for attempt in range(3):98 result = self.scrapfly(url, render_js=True)99 status = result.get("status_code")100 if status in (404, 410):101 return []102 html = result.get("content") or ""103 complete = "fp-container" in html or "ApartmentComplex" in html104 if status == 200 and html and complete:105 break106 if attempt < 2:107 time.sleep(5 * (attempt + 1))108 else:109 raise RuntimeError(110 f"page floorplans {url} en échec après 3 essais "111 f"(statut {status!r}, contenu "112 f"{'vide' if not html else 'partiel/incomplet'})")113 soup = BeautifulSoup(html, "html.parser")114115 # JSON-LD ApartmentComplex : nom, adresse, animaux, détail des plans116 # (extraction regex sur le HTML brut : le rendu Scrapfly est parfois117 # capricieux avec les <script> dans BeautifulSoup)118 name, address, pets = "", "", None119 hero = ""120 plans: dict[str, dict] = {} # nom du plan -> détail JSON-LD121 blobs = [s.string or s.get_text() for s in122 soup.find_all("script", type="application/ld+json")]123 blobs += LDJSON_RE.findall(html)124 for raw in blobs:125 try:126 d = json.loads(raw or "", strict=False)127 except (ValueError, TypeError):128 continue129 if not isinstance(d, dict) or "ApartmentComplex" not in \130 str(d.get("@type", "")):131 continue132 name = (d.get("name") or "").strip()133 adr = d.get("address") or {}134 street = (adr.get("streetAddress") or "").strip()135 loc = (adr.get("addressLocality") or "").strip()136 postal = (adr.get("postalCode") or "").strip()137 address = ", ".join(x for x in (street, loc) if x)138 if address and postal:139 address += f", QC {postal}"140 if isinstance(d.get("petsAllowed"), bool):141 pets = "oui" if d["petsAllowed"] else "non"142 for fp in d.get("accommodationFloorPlan") or []:143 if isinstance(fp, dict) and fp.get("name"):144 plans[fp["name"].strip().lower()] = fp145 if isinstance(d.get("image"), str):146 hero = d["image"]147 break148149 if not name: # repli : <title> de la page150 tm = TITLE_RE.search(html)151 if tm:152 name = tm.group(1).strip()153 city = _CITIES.get(city_slug, city_slug.replace("-", " ").title())154155 listings: list[Listing] = []156 for div in soup.select("div.fp-container[id]"):157 m = FPID_RE.search(div.get("id", ""))158 if not m:159 continue160 fpid = m.group(1)161 text = div.get_text(" | ", strip=True).replace("\xa0", " ")162 pm = PRICE_RE.search(text)163 if not pm: # pas de prix = liste d'attente164 continue165 fp_name = ""166 h = div.find(["h2", "h3", "h4", "strong"])167 if h:168 fp_name = h.get_text(" ", strip=True)169 if not fp_name:170 fp_name = text.split(" | ", 1)[0]171172 bedrooms = bathrooms = None173 area = None174 bm = BED_RE.search(text)175 if bm:176 bedrooms = float(bm.group(1))177 am = BATH_RE.search(text)178 if am:179 try:180 bathrooms = float(am.group(1))181 except ValueError:182 pass183 fp = plans.get(fp_name.strip().lower())184 if fp:185 if bedrooms is None and fp.get("numberOfBedrooms") is not None:186 bedrooms = float(fp["numberOfBedrooms"])187 if bathrooms is None and \188 fp.get("numberOfFullBathrooms") is not None:189 bathrooms = float(fp["numberOfFullBathrooms"])190 size = fp.get("floorSize") or {}191 if isinstance(size, dict) and size.get("minValue"):192 area = float(size["minValue"])193194 images = []195 for img in div.find_all("img"):196 u = img.get("src") or img.get("data-src") or ""197 if u.startswith("http"):198 images.append(u)199 if hero:200 images.append(hero)201202 price_label = f"À partir de {pm.group(1)}/mois"203 listings.append(Listing(204 source=self.source_id,205 external_id=f"{slug}-{fpid}",206 url=url,207 title=f"{name or slug} — {fp_name}",208 address=address,209 sector="",210 city=city,211 unit_type=normalize_unit_type(fp_name),212 bedrooms=bedrooms,213 bathrooms=bathrooms,214 price=parse_price(pm.group(1)),215 price_label=price_label,216 availability="Disponible",217 area_sqft=area,218 pets=pets,219 images=list(dict.fromkeys(images))[:10],220 ))221 return listings222