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/royal_laval.py : connecteur Gestion Immobilière Royal5# (gestionroyal.com) — gestionnaire de Laval-des-Rapides et Montréal6# (~16 annonces actives). WordPress + thème immobilier Houzez rendu7# serveur : la page /a-louer/ (+ /a-louer/page/N/) affiche des cartes8# .item-listing-wrap complètes (post ID data-hz-id, prix, adresse, type9# n½ en étiquette, galerie data-images). La page détail /property/…10# fournit description et ville (bloc « Ville | … ») via le cache detail().11# Granularité UNITÉ (une annonce par logement).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import json16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing21from .base import BaseConnector2223BASE = "https://gestionroyal.com"24LIST_URL = f"{BASE}/a-louer/"25MAX_PAGES = 102627HZ_ID_RE = re.compile(r"^hz-(\d+)$")28UNIT_LABEL_RE = re.compile(r"^\s*\d\s*(?:1/2|½)\s*$")29PRICE_RE = re.compile(r"\$?\s*([\d, ]{3,9})\s*\$?(?:\s*/\s*mois)?", re.I)30CITY_RE = re.compile(31 r"\b(Laval|Montréal|Montreal|Saint-Léonard|Longueuil|Terrebonne)\b", re.I)323334class RoyalLavalConnector(BaseConnector):35 source_id = "royal_laval"36 request_delay = 0.83738 def fetch(self) -> list[Listing]:39 listings: list[Listing] = []40 seen: set[str] = set()41 url = LIST_URL42 for _ in range(MAX_PAGES):43 try:44 html = self.get(url).text45 except Exception:46 break47 soup = BeautifulSoup(html, "html.parser")48 for card in soup.select(".item-listing-wrap"):49 lst = self._from_card(card)50 if lst is not None and lst.external_id not in seen:51 seen.add(lst.external_id)52 listings.append(lst)53 nxt = soup.select_one('a[href*="/a-louer/page/"]')54 nxt_url = nxt.get("href") if nxt else ""55 if not nxt_url or nxt_url == url:56 break57 url = nxt_url58 return listings5960 def _from_card(self, card) -> Listing | None:61 m = HZ_ID_RE.match(card.get("data-hz-id") or "")62 if not m:63 return None64 post_id = m.group(1) # ID WordPress : stable6566 statuses = {a.get_text(strip=True) for a in card.select(".label-status")}67 if "À Louer" not in statuses:68 return None # vendu/loué6970 link = card.select_one('.item-title a[href*="/property/"]') \71 or card.select_one('a[href*="/property/"]')72 if link is None:73 return None74 url = link.get("href") or LIST_URL75 title = link.get_text(" ", strip=True)7677 addr_el = card.select_one(".item-address")78 address = addr_el.get_text(" ", strip=True) if addr_el else ""79 # adresses Houzez très verbeuses (géocodeur complet) : tronquer aux80 # deux premiers segments (rue + quartier)81 short_addr = ", ".join(p.strip() for p in address.split(",")[:2])8283 unit_type = ""84 for lbl in card.select(".hz-label"):85 t = lbl.get_text(strip=True)86 if UNIT_LABEL_RE.match(t):87 unit_type = t88 break8990 price = None91 price_label = ""92 pr = card.select_one(".item-price")93 if pr is not None:94 price_label = pr.get_text(" ", strip=True)95 pm = PRICE_RE.search(price_label)96 if pm:97 try:98 val = float(pm.group(1).replace(",", "").replace(" ", ""))99 if 300 <= val <= 20000:100 price = val101 except ValueError:102 pass103104 if price is None:105 # certaines cartes n'ont pas d'.item-price : le loyer est alors106 # dans le titre (« Condo à louer, Laval … | 1 975$ »)107 tm = re.search(r"(\d[\d ,]{2,7})\s*\$(?:\s*/?\s*mois)?", title)108 if tm:109 try:110 val = float(re.sub(r"[ , ]", "", tm.group(1)))111 if 300 <= val <= 20000:112 price = val113 price_label = price_label or tm.group(0).strip()114 except ValueError:115 pass116117 beds = None118 bel = card.select_one(".h-beds .hz-figure")119 if bel is not None:120 try:121 beds = float(bel.get_text(strip=True))122 except ValueError:123 pass124 baths = None125 bael = card.select_one(".h-baths .hz-figure")126 if bael is not None:127 try:128 baths = float(bael.get_text(strip=True))129 except ValueError:130 pass131132 images: list[str] = []133 try:134 images = [i["image"] for i in json.loads(card.get("data-images") or "[]")135 if isinstance(i, dict) and i.get("image")][:15]136 except (ValueError, TypeError):137 pass138 if not images:139 img = card.select_one("img")140 if img is not None and (img.get("src") or "").startswith("http"):141 images = [img["src"]]142143 # ville : détectée dans l'adresse/le titre, précisée par la page détail144 city = ""145 cm = CITY_RE.search(f"{address} {title}")146 if cm:147 city = cm.group(1)148149 detail = self.detail(post_id, f"{title}|{price_label}|{address}",150 lambda: self._detail(url))151 description = detail.get("description", "")152 city = detail.get("city") or city153 sector = detail.get("sector", "")154155 return Listing(156 source=self.source_id,157 external_id=post_id,158 url=url,159 title=title or short_addr,160 address=short_addr,161 sector=sector,162 city=city,163 unit_type=unit_type,164 bedrooms=beds,165 bathrooms=baths,166 price=price,167 price_label=price_label,168 description=description,169 images=images,170 )171172 # -- page détail /property/… (description + ville) -------------------------173 def _detail(self, url: str) -> dict:174 payload: dict = {}175 try:176 html = self.get(url).text177 except Exception:178 return payload179 soup = BeautifulSoup(html, "html.parser")180 desc = soup.select_one("#property-description-wrap .block-content-wrap")181 if desc is not None:182 payload["description"] = desc.get_text("\n", strip=True)[:4000]183 # bloc « Address » Houzez : liste Ville / Région / State184 for li in soup.select(".block-content-wrap li, .detail-city"):185 txt = li.get_text(" ", strip=True)186 m = re.match(r"^Ville\s+(.+)$", txt)187 if m:188 payload["city"] = m.group(1).strip()189 m = re.match(r"^(?:Quartier|Secteur)\s+(.+)$", txt)190 if m:191 payload["sector"] = m.group(1).strip()192 return payload193