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/prona.py : connecteur Gestion Prona (gestionprona.ca)5# Gestionnaire multi-régions : Cowansville, Saint-Jean-sur-Richelieu,6# Saint-Hubert, Candiac, Saint-Hyacinthe (Montérégie) + Montréal, Québec,7# Blainville. WordPress + thème immobilier Houzez rendu serveur (même8# plateforme qu'immeubles_bc) : la page /properties/ expose des cartes9# .item-listing-wrap (data-listid STABLE, titre, adresse taxonomique,10# prix, chambres/sdb). La fiche /property/<slug>/ (via cache BD) fournit11# la VILLE structurée (champ « Ville » du bloc adresse — la taxonomie de12# la carte liste mêle quartier/arrondissement/MRC), la description, les13# caractéristiques, le GPS et la galerie. Les locaux commerciaux/bureaux14# sont exclus.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://gestionprona.ca"27LIST_URL = f"{BASE}/properties/"2829_COMMERCIAL_RE = re.compile(30 r"commerc|bureau|\blocal\b|stationnement|garage|entrep[oô]t", re.I)31_MAP_LATLNG_RE = re.compile(32 r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?')33# suffixe de redimensionnement WordPress (« -592x444.jpg » -> pleine taille)34_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)35_IMG_RE = re.compile(36 r"https://gestionprona\.ca/wp-content/uploads/[^\"'\s\\)]+"37 r"\.(?:jpe?g|png|webp)", re.I)38_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I)394041def _clean_price_label(label: str) -> str:42 """'$1,450/mois' -> '$1450/mois' (virgule = milliers, pour parse_price)."""43 return re.sub(r"(\d),(\d{3})", r"\1\2", label)444546class PronaConnector(BaseConnector):47 source_id = "prona"48 request_delay = 0.649 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)5051 def fetch(self) -> list[Listing]:52 html = self.get(LIST_URL).text53 soup = BeautifulSoup(html, "html.parser")5455 listings: dict[str, Listing] = {}56 for card in soup.select("div.item-listing-wrap"):57 try:58 self._parse_card(card, listings)59 except Exception:60 continue6162 # fiches détail (cache BD) : ville structurée, description, GPS…63 self._fetched = 064 for lst in listings.values():65 key = hashlib.sha1(66 f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"67 .encode("utf-8")).hexdigest()[:16]68 try:69 payload = self.detail(lst.external_id, key,70 lambda u=lst.url: self._fetch_detail(u))71 except Exception:72 continue73 self._apply_detail(lst, payload)74 return list(listings.values())7576 # -- carte Houzez -----------------------------------------------------------77 def _parse_card(self, card, listings: dict[str, Listing]) -> None:78 link = card.select_one("h2.item-title a[href]")79 if not link:80 return81 url = link["href"]82 title = link.get_text(strip=True)83 m = re.search(r"/property/([^/]+)/?", url)84 slug = m.group(1) if m else ""85 listid_el = card.select_one("[data-listid]")86 ext_id = (listid_el.get("data-listid") if listid_el else "") or slug87 if not ext_id or str(ext_id) in listings:88 return89 if _COMMERCIAL_RE.search(title):90 return # local commercial : exclu9192 status_el = card.select_one(".label-status")93 availability = status_el.get_text(strip=True) if status_el else ""94 if re.search(r"lou[ée]", availability, re.I):95 return9697 addr_el = card.select_one("address.item-address")98 address = addr_el.get_text(" ", strip=True) if addr_el else ""99100 price_el = card.select_one("li.item-price")101 price_label = price_el.get_text(strip=True) if price_el else ""102103 amenities: list[str] = []104 beds = ""105 beds_el = card.select_one("li.h-beds .hz-figure")106 if beds_el:107 beds = beds_el.get_text(strip=True)108 if beds:109 amenities.append(f"{beds} chambre(s)")110 baths_el = card.select_one("li.h-baths .hz-figure")111 if baths_el and baths_el.get_text(strip=True):112 amenities.append(f"{baths_el.get_text(strip=True)} salle(s) de bain")113114 unit_type = ""115 if beds.isdigit():116 unit_type = "Studio" if beds == "0" else \117 normalize_unit_type(f"{beds} chambres")118 # le titre porte souvent le vrai type (« Grand 4.5 … »)119 m = re.search(r"\b(\d[½.]5?)\b", title)120 if m:121 t = normalize_unit_type(m.group(1))122 if re.match(r"^\d½$", t):123 unit_type = t124125 images: list[str] = []126 thumb = card.select_one("img.wp-post-image[src]")127 if thumb:128 images = [_SIZE_SUFFIX.sub("", thumb["src"])]129130 listings[str(ext_id)] = Listing(131 source=self.source_id,132 external_id=str(ext_id),133 url=url,134 title=title,135 address=address,136 unit_type=unit_type,137 price=parse_price(_clean_price_label(price_label)),138 price_label=price_label,139 availability=availability,140 amenities=amenities,141 images=images,142 )143144 # -- fiche détail (Houzez) ----------------------------------------------------145 def _fetch_detail(self, url: str) -> dict:146 """Ville structurée, adresse, description, caractéristiques, GPS."""147 if self._fetched >= self.max_details:148 raise RuntimeError("budget de fiches détail atteint")149 self._fetched += 1150 html = self.get(url).text151 soup = BeautifulSoup(html, "html.parser")152 out: dict = {}153154 # bloc adresse : lignes « <label> <valeur> » (Ville, Adresse…)155 for li in soup.select("#property-address-wrap li"):156 txt = li.get_text(" ", strip=True)157 m = re.match(r"^(Adresse|Ville)\s+(.+)$", txt)158 if m:159 out["ville" if m.group(1) == "Ville" else "adresse"] = \160 m.group(2).strip()161162 desc_el = soup.select_one("#property-description-wrap")163 if desc_el:164 txt = desc_el.get_text("\n", strip=True)165 txt = re.sub(r"^(La )?[Dd]escription\n", "", txt)166 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1200]167168 feats = [a.get_text(" ", strip=True)169 for a in soup.select("#property-features-wrap li")170 if a.get_text(strip=True)]171 # bloc « Détails » structuré (superficie, pièces…)172 for li in soup.select("#property-detail-wrap li"):173 txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))174 m = re.match(r"^Taille de la propriété:\s*([\d,\s]+)\s*sqft", txt)175 if m:176 try:177 out["area"] = float(m.group(1).replace(",", "")178 .replace(" ", ""))179 except ValueError:180 pass181 out["amenities"] = feats[:20]182183 m = _MAP_LATLNG_RE.search(html)184 if m:185 out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))186187 imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))188 if not _SKIP_IMG.search(u)]189 out["images"] = imgs[:20]190 return out191192 def _apply_detail(self, lst: Listing, d: dict) -> None:193 if not d:194 return195 if d.get("ville"):196 lst.city = d["ville"]197 if d.get("adresse"):198 lst.address = d["adresse"]199 if not lst.city and d.get("adresse"):200 # repli : sans champ « Ville », la 3e composante de l'adresse201 # complète Houzez est la municipalité (n°, rue, ville, MRC…)202 parts = [p.strip() for p in d["adresse"].split(",") if p.strip()]203 if len(parts) >= 3 and not re.search(r"\d", parts[2]):204 lst.city = parts[2]205 desc = d.get("description") or ""206 if desc and desc.strip() != lst.address.strip():207 lst.description = desc208 if d.get("amenities"):209 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))210 if d.get("area") and lst.area_sqft is None:211 if 80 <= d["area"] <= 20000:212 lst.area_sqft = d["area"]213 if d.get("lat") is not None and d.get("lng") is not None:214 lst.lat, lst.lng = d["lat"], d["lng"]215 if d.get("images"):216 lst.images = d["images"]217