# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/royal_laval.py : connecteur Gestion Immobilière Royal # (gestionroyal.com) — gestionnaire de Laval-des-Rapides et Montréal # (~16 annonces actives). WordPress + thème immobilier Houzez rendu # serveur : la page /a-louer/ (+ /a-louer/page/N/) affiche des cartes # .item-listing-wrap complètes (post ID data-hz-id, prix, adresse, type # n½ en étiquette, galerie data-images). La page détail /property/… # fournit description et ville (bloc « Ville | … ») via le cache detail(). # Granularité UNITÉ (une annonce par logement). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://gestionroyal.com" LIST_URL = f"{BASE}/a-louer/" MAX_PAGES = 10 HZ_ID_RE = re.compile(r"^hz-(\d+)$") UNIT_LABEL_RE = re.compile(r"^\s*\d\s*(?:1/2|½)\s*$") PRICE_RE = re.compile(r"\$?\s*([\d, ]{3,9})\s*\$?(?:\s*/\s*mois)?", re.I) CITY_RE = re.compile( r"\b(Laval|Montréal|Montreal|Saint-Léonard|Longueuil|Terrebonne)\b", re.I) class RoyalLavalConnector(BaseConnector): source_id = "royal_laval" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() url = LIST_URL for _ in range(MAX_PAGES): try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") for card in soup.select(".item-listing-wrap"): lst = self._from_card(card) if lst is not None and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) nxt = soup.select_one('a[href*="/a-louer/page/"]') nxt_url = nxt.get("href") if nxt else "" if not nxt_url or nxt_url == url: break url = nxt_url return listings def _from_card(self, card) -> Listing | None: m = HZ_ID_RE.match(card.get("data-hz-id") or "") if not m: return None post_id = m.group(1) # ID WordPress : stable statuses = {a.get_text(strip=True) for a in card.select(".label-status")} if "À Louer" not in statuses: return None # vendu/loué link = card.select_one('.item-title a[href*="/property/"]') \ or card.select_one('a[href*="/property/"]') if link is None: return None url = link.get("href") or LIST_URL title = link.get_text(" ", strip=True) addr_el = card.select_one(".item-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" # adresses Houzez très verbeuses (géocodeur complet) : tronquer aux # deux premiers segments (rue + quartier) short_addr = ", ".join(p.strip() for p in address.split(",")[:2]) unit_type = "" for lbl in card.select(".hz-label"): t = lbl.get_text(strip=True) if UNIT_LABEL_RE.match(t): unit_type = t break price = None price_label = "" pr = card.select_one(".item-price") if pr is not None: price_label = pr.get_text(" ", strip=True) pm = PRICE_RE.search(price_label) if pm: try: val = float(pm.group(1).replace(",", "").replace(" ", "")) if 300 <= val <= 20000: price = val except ValueError: pass if price is None: # certaines cartes n'ont pas d'.item-price : le loyer est alors # dans le titre (« Condo à louer, Laval … | 1 975$ ») tm = re.search(r"(\d[\d ,]{2,7})\s*\$(?:\s*/?\s*mois)?", title) if tm: try: val = float(re.sub(r"[ , ]", "", tm.group(1))) if 300 <= val <= 20000: price = val price_label = price_label or tm.group(0).strip() except ValueError: pass beds = None bel = card.select_one(".h-beds .hz-figure") if bel is not None: try: beds = float(bel.get_text(strip=True)) except ValueError: pass baths = None bael = card.select_one(".h-baths .hz-figure") if bael is not None: try: baths = float(bael.get_text(strip=True)) except ValueError: pass images: list[str] = [] try: images = [i["image"] for i in json.loads(card.get("data-images") or "[]") if isinstance(i, dict) and i.get("image")][:15] except (ValueError, TypeError): pass if not images: img = card.select_one("img") if img is not None and (img.get("src") or "").startswith("http"): images = [img["src"]] # ville : détectée dans l'adresse/le titre, précisée par la page détail city = "" cm = CITY_RE.search(f"{address} {title}") if cm: city = cm.group(1) detail = self.detail(post_id, f"{title}|{price_label}|{address}", lambda: self._detail(url)) description = detail.get("description", "") city = detail.get("city") or city sector = detail.get("sector", "") return Listing( source=self.source_id, external_id=post_id, url=url, title=title or short_addr, address=short_addr, sector=sector, city=city, unit_type=unit_type, bedrooms=beds, bathrooms=baths, price=price, price_label=price_label, description=description, images=images, ) # -- page détail /property/… (description + ville) ------------------------- def _detail(self, url: str) -> dict: payload: dict = {} try: html = self.get(url).text except Exception: return payload soup = BeautifulSoup(html, "html.parser") desc = soup.select_one("#property-description-wrap .block-content-wrap") if desc is not None: payload["description"] = desc.get_text("\n", strip=True)[:4000] # bloc « Address » Houzez : liste Ville / Région / State for li in soup.select(".block-content-wrap li, .detail-city"): txt = li.get_text(" ", strip=True) m = re.match(r"^Ville\s+(.+)$", txt) if m: payload["city"] = m.group(1).strip() m = re.match(r"^(?:Quartier|Secteur)\s+(.+)$", txt) if m: payload["sector"] = m.group(1).strip() return payload