# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/pro_urbain.py : connecteur Gestion Pro-Urbain # (gestionpro-urbain.com). Gestionnaire locatif de Lanaudière / Rive-Nord / # Grand Montréal (Charlemagne, Repentigny, Berthierville, Saint-Paul, # Saint-Jacques, Saint-Charles-Borromée…). Site October CMS (plugin maison # « nerd/realestate ») rendu serveur : /fr/proprietes liste des cartes par # UNITÉ (adresse, ville, code postal, prix/mois, type n½, sdb, chambres, # stationnements, disponibilité), page /fr/propriete/ pour la # description, les inclusions (puces « • ») et les photos (via cache # detail). Granularité UNITÉ, external_id = slug de la fiche. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://gestionpro-urbain.com" LIST_URL = f"{BASE}/fr/proprietes" TYPE_RE = re.compile(r"^\d\s*(?:½|1/2)$") COUNT_RE = re.compile(r"^\d+(?:[.,]\d)?$") AVAIL_RE = re.compile(r"Disponibilit[ée]\s*:\s*([^|<]{2,40})", re.I) IMG_RE = re.compile( r"https://gestionpro-urbain\.com/storage/app/uploads/[^\"'\s\\)]+?" r"\.(?:jpg|jpeg|png|webp)", re.I) BULLET_RE = re.compile(r"•\s*([^|<>•]{3,90})") class ProUrbainConnector(BaseConnector): source_id = "pro_urbain" request_delay = 0.7 def _detail_payload(self, url: str) -> dict: """Description, inclusions et photos de la fiche /fr/propriete/…""" try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") payload: dict = {} images = [u for u in dict.fromkeys(IMG_RE.findall(html))][:20] if images: payload["images"] = images # bloc Description : premier paragraphe substantiel après le titre desc = "" node = soup.find(string=re.compile(r"^\s*Description\s*$")) if node is not None: for p in node.find_all_next(["p", "div"]): t = p.get_text(" ", strip=True) if len(t) > 60: desc = t break if "Caractéristiques" in t: break if desc: payload["description"] = desc[:1500] text = soup.get_text("|", strip=True) amenities = [] for b in BULLET_RE.findall(text): b = b.strip(" .") if b and b not in amenities: amenities.append(b) if amenities: payload["amenities"] = amenities[:25] return payload def fetch(self) -> list[Listing]: listings: list[Listing] = [] # Échec du GET de la liste = erreur transitoire à propager (journalisée # en échec par ingest), pas un « succès » à 0 annonce. html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") seen: set[str] = set() for card in soup.select("div.mygrid"): link = card.select_one("a[href*='/fr/propriete/']") if link is None: continue url = link["href"] slug = url.rstrip("/").rsplit("/", 1)[-1] if not slug or slug in seen: continue seen.add(slug) head = card.select_one(".gsMain h2") head_txt = head.get_text(" ", strip=True) if head else "" if head_txt and not head_txt.lower().startswith("appartement"): continue # commercial/autre : hors sujet am = AVAIL_RE.search(head_txt) availability = f"Disponibilité : {am.group(1).strip()}" if am else "" addr_p = card.select_one(".gsMain p") address = city = postal = "" if addr_p is not None: parts = [x.strip() for x in addr_p.get_text("|", strip=True).split("|") if x.strip()] if parts: address = parts[0] if len(parts) > 1: city = parts[1] if len(parts) > 2: postal = parts[2] price = None pr = card.select_one(".currentPrice") if pr is not None: digits = re.sub(r"[^\d]", "", pr.get_text()) if digits: price = float(digits) # pictogrammes du pied de carte : type n½, sdb, chambres, autos unit_type, bathrooms, bedrooms, parking = "", None, None, None for ic in card.select(".gsFooter i"): img = ic.find("img") span = ic.find("span") if img is None or span is None: continue src = img.get("src", "") val = span.get_text(" ", strip=True) if "room-svgrepo" in src and TYPE_RE.match(val): unit_type = normalize_unit_type(val) elif "bathtub" in src and COUNT_RE.match(val): bathrooms = float(val.replace(",", ".")) elif "bed-svgrepo" in src and COUNT_RE.match(val): bedrooms = float(val) elif "car-svgrepo" in src and COUNT_RE.match(val): parking = int(val) img = card.select_one(".gsHead img") cover = [img["src"]] if img is not None and img.get("src") else [] key = f"{price or 0:.0f}-{availability}-{unit_type}" payload = self.detail(slug, key, lambda u=url: self._detail_payload(u)) details: dict = {} if parking is not None: details["parking"] = parking if postal: details["postal_code"] = postal listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=(f"{unit_type} — {address}, {city}" if unit_type else f"{address}, {city}"), address=address, city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=availability, description=payload.get("description", ""), amenities=payload.get("amenities", []), details=details, images=payload.get("images") or cover, )) return listings