# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/prona.py : connecteur Gestion Prona (gestionprona.ca) # Gestionnaire multi-régions : Cowansville, Saint-Jean-sur-Richelieu, # Saint-Hubert, Candiac, Saint-Hyacinthe (Montérégie) + Montréal, Québec, # Blainville. WordPress + thème immobilier Houzez rendu serveur (même # plateforme qu'immeubles_bc) : la page /properties/ expose des cartes # .item-listing-wrap (data-listid STABLE, titre, adresse taxonomique, # prix, chambres/sdb). La fiche /property// (via cache BD) fournit # la VILLE structurée (champ « Ville » du bloc adresse — la taxonomie de # la carte liste mêle quartier/arrondissement/MRC), la description, les # caractéristiques, le GPS et la galerie. Les locaux commerciaux/bureaux # sont exclus. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionprona.ca" LIST_URL = f"{BASE}/properties/" _COMMERCIAL_RE = re.compile( r"commerc|bureau|\blocal\b|stationnement|garage|entrep[oô]t", re.I) _MAP_LATLNG_RE = re.compile( r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?') # suffixe de redimensionnement WordPress (« -592x444.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _IMG_RE = re.compile( r"https://gestionprona\.ca/wp-content/uploads/[^\"'\s\\)]+" r"\.(?:jpe?g|png|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I) def _clean_price_label(label: str) -> str: """'$1,450/mois' -> '$1450/mois' (virgule = milliers, pour parse_price).""" return re.sub(r"(\d),(\d{3})", r"\1\2", label) class PronaConnector(BaseConnector): source_id = "prona" request_delay = 0.6 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("div.item-listing-wrap"): try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : ville structurée, description, GPS… self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}" .encode("utf-8")).hexdigest()[:16] try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte Houzez ----------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("h2.item-title a[href]") if not link: return url = link["href"] title = link.get_text(strip=True) m = re.search(r"/property/([^/]+)/?", url) slug = m.group(1) if m else "" listid_el = card.select_one("[data-listid]") ext_id = (listid_el.get("data-listid") if listid_el else "") or slug if not ext_id or str(ext_id) in listings: return if _COMMERCIAL_RE.search(title): return # local commercial : exclu status_el = card.select_one(".label-status") availability = status_el.get_text(strip=True) if status_el else "" if re.search(r"lou[ée]", availability, re.I): return addr_el = card.select_one("address.item-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" price_el = card.select_one("li.item-price") price_label = price_el.get_text(strip=True) if price_el else "" amenities: list[str] = [] beds = "" beds_el = card.select_one("li.h-beds .hz-figure") if beds_el: beds = beds_el.get_text(strip=True) if beds: amenities.append(f"{beds} chambre(s)") baths_el = card.select_one("li.h-baths .hz-figure") if baths_el and baths_el.get_text(strip=True): amenities.append(f"{baths_el.get_text(strip=True)} salle(s) de bain") unit_type = "" if beds.isdigit(): unit_type = "Studio" if beds == "0" else \ normalize_unit_type(f"{beds} chambres") # le titre porte souvent le vrai type (« Grand 4.5 … ») m = re.search(r"\b(\d[½.]5?)\b", title) if m: t = normalize_unit_type(m.group(1)) if re.match(r"^\d½$", t): unit_type = t images: list[str] = [] thumb = card.select_one("img.wp-post-image[src]") if thumb: images = [_SIZE_SUFFIX.sub("", thumb["src"])] listings[str(ext_id)] = Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, address=address, unit_type=unit_type, price=parse_price(_clean_price_label(price_label)), price_label=price_label, availability=availability, amenities=amenities, images=images, ) # -- fiche détail (Houzez) ---------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Ville structurée, adresse, description, caractéristiques, GPS.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # bloc adresse : lignes «