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/pro_urbain.py : connecteur Gestion Pro-Urbain5# (gestionpro-urbain.com). Gestionnaire locatif de Lanaudière / Rive-Nord /6# Grand Montréal (Charlemagne, Repentigny, Berthierville, Saint-Paul,7# Saint-Jacques, Saint-Charles-Borromée…). Site October CMS (plugin maison8# « nerd/realestate ») rendu serveur : /fr/proprietes liste des cartes par9# UNITÉ (adresse, ville, code postal, prix/mois, type n½, sdb, chambres,10# stationnements, disponibilité), page /fr/propriete/<slug> pour la11# description, les inclusions (puces « • ») et les photos (via cache12# detail). Granularité UNITÉ, external_id = slug de la fiche.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://gestionpro-urbain.com"24LIST_URL = f"{BASE}/fr/proprietes"2526TYPE_RE = re.compile(r"^\d\s*(?:½|1/2)$")27COUNT_RE = re.compile(r"^\d+(?:[.,]\d)?$")28AVAIL_RE = re.compile(r"Disponibilit[ée]\s*:\s*([^|<]{2,40})", re.I)29IMG_RE = re.compile(30 r"https://gestionpro-urbain\.com/storage/app/uploads/[^\"'\s\\)]+?"31 r"\.(?:jpg|jpeg|png|webp)", re.I)32BULLET_RE = re.compile(r"•\s*([^|<>•]{3,90})")333435class ProUrbainConnector(BaseConnector):36 source_id = "pro_urbain"37 request_delay = 0.73839 def _detail_payload(self, url: str) -> dict:40 """Description, inclusions et photos de la fiche /fr/propriete/…"""41 try:42 html = self.get(url).text43 except Exception:44 return {}45 soup = BeautifulSoup(html, "html.parser")46 payload: dict = {}47 images = [u for u in dict.fromkeys(IMG_RE.findall(html))][:20]48 if images:49 payload["images"] = images50 # bloc Description : premier paragraphe substantiel après le titre51 desc = ""52 node = soup.find(string=re.compile(r"^\s*Description\s*$"))53 if node is not None:54 for p in node.find_all_next(["p", "div"]):55 t = p.get_text(" ", strip=True)56 if len(t) > 60:57 desc = t58 break59 if "Caractéristiques" in t:60 break61 if desc:62 payload["description"] = desc[:1500]63 text = soup.get_text("|", strip=True)64 amenities = []65 for b in BULLET_RE.findall(text):66 b = b.strip(" .")67 if b and b not in amenities:68 amenities.append(b)69 if amenities:70 payload["amenities"] = amenities[:25]71 return payload7273 def fetch(self) -> list[Listing]:74 listings: list[Listing] = []75 # Échec du GET de la liste = erreur transitoire à propager (journalisée76 # en échec par ingest), pas un « succès » à 0 annonce.77 html = self.get(LIST_URL).text78 soup = BeautifulSoup(html, "html.parser")7980 seen: set[str] = set()81 for card in soup.select("div.mygrid"):82 link = card.select_one("a[href*='/fr/propriete/']")83 if link is None:84 continue85 url = link["href"]86 slug = url.rstrip("/").rsplit("/", 1)[-1]87 if not slug or slug in seen:88 continue89 seen.add(slug)9091 head = card.select_one(".gsMain h2")92 head_txt = head.get_text(" ", strip=True) if head else ""93 if head_txt and not head_txt.lower().startswith("appartement"):94 continue # commercial/autre : hors sujet95 am = AVAIL_RE.search(head_txt)96 availability = f"Disponibilité : {am.group(1).strip()}" if am else ""9798 addr_p = card.select_one(".gsMain p")99 address = city = postal = ""100 if addr_p is not None:101 parts = [x.strip() for x in102 addr_p.get_text("|", strip=True).split("|") if x.strip()]103 if parts:104 address = parts[0]105 if len(parts) > 1:106 city = parts[1]107 if len(parts) > 2:108 postal = parts[2]109110 price = None111 pr = card.select_one(".currentPrice")112 if pr is not None:113 digits = re.sub(r"[^\d]", "", pr.get_text())114 if digits:115 price = float(digits)116117 # pictogrammes du pied de carte : type n½, sdb, chambres, autos118 unit_type, bathrooms, bedrooms, parking = "", None, None, None119 for ic in card.select(".gsFooter i"):120 img = ic.find("img")121 span = ic.find("span")122 if img is None or span is None:123 continue124 src = img.get("src", "")125 val = span.get_text(" ", strip=True)126 if "room-svgrepo" in src and TYPE_RE.match(val):127 unit_type = normalize_unit_type(val)128 elif "bathtub" in src and COUNT_RE.match(val):129 bathrooms = float(val.replace(",", "."))130 elif "bed-svgrepo" in src and COUNT_RE.match(val):131 bedrooms = float(val)132 elif "car-svgrepo" in src and COUNT_RE.match(val):133 parking = int(val)134135 img = card.select_one(".gsHead img")136 cover = [img["src"]] if img is not None and img.get("src") else []137138 key = f"{price or 0:.0f}-{availability}-{unit_type}"139 payload = self.detail(slug, key,140 lambda u=url: self._detail_payload(u))141142 details: dict = {}143 if parking is not None:144 details["parking"] = parking145 if postal:146 details["postal_code"] = postal147 listings.append(Listing(148 source=self.source_id,149 external_id=slug,150 url=url,151 title=(f"{unit_type} — {address}, {city}"152 if unit_type else f"{address}, {city}"),153 address=address,154 city=city,155 unit_type=unit_type,156 bedrooms=bedrooms,157 bathrooms=bathrooms,158 price=price,159 price_label=f"{price:.0f} $ /mois" if price else "",160 availability=availability,161 description=payload.get("description", ""),162 amenities=payload.get("amenities", []),163 details=details,164 images=payload.get("images") or cover,165 ))166 return listings167