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/mostra.py : connecteur Mostra (condos locatifs gérés par Cogir)5# Couvre TOUS les sites Mostra dans un seul connecteur :6# 1. mostra.ca (ColdFusion) — Mostra Longueuil et Mostra Candiac : pages7# /fr/disponibilites-condo-<ville>.html découvertes depuis l'accueil,8# puis rejeu de l'AJAX officiel POST /inc/ajax/unite.cfm (mêmes champs9# que le bouton « Plus de résultats ») qui retourne UNIQUEMENT les10# unités disponibles : emplacement, no d'unité, pi², ch./sb., modèle11# (3 1/2…), plan PDF et loyer. Une annonce par unité disponible.12# 2. Sites satellites WordPress/Elementor (un immeuble chacun) — prix13# « à partir de » par type d'unité, rendus serveur :14# - mostracentropolis.ca (Laval, 550 unités, phases A/B) ;15# - mostramascouche.ca (/appartements/) ;16# - mostramaisonneuve.ca (/choisir-mon-unite/, Hochelaga-Maisonneuve) —17# les types marqués « liste d'attente » sont exclus (aucune dispo).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_price26from .base import BaseConnector2728BASE = "https://www.mostra.ca"29AJAX_URL = f"{BASE}/inc/ajax/unite.cfm"3031# Pages de disponibilités du site principal (découvertes depuis l'accueil)32DISPO_RE = re.compile(r'href="(?:/)?(fr/disponibilites-condo-([a-z-]+)\.html)"')33# Pages « emplacements » (fiche projet avec la galerie photo)34EMPL_RE = re.compile(35 r'href="(?:/)?(fr/emplacements-condos/\d+-mostra-([a-z-]+)\.html)"')36PROJET_RE = re.compile(r"projetID\s*=\s*'(\d+)'")37PAGE_RE = re.compile(r"var\s+p\s*=\s*'([^']+)'")38NBMAX_RE = re.compile(r'class="nombreMax"\s+value="(\d+)"')39CHSB_RE = re.compile(r"(\d+)\s*ch\.(?:\s*/\s*([\d.]+)\s*sb\.)?", re.I)40PHOTO_MAIN_RE = re.compile(r'(?:src|href)="(DATA/PHOTO/\d+_photo\.jpg)"', re.I)41BAD_IMG_RE = re.compile(r"logo|icon|favicon|plan|etage|-\d+x\d+\.|/20\d\d/0?\d/"42 r"[A-Z][a-z]+\.png", re.I)4344# Sites satellites : un immeuble par site, prix « à partir de » par type45SATELLITES = [46 {47 "slug": "centropolis",48 "url": "https://mostracentropolis.ca/",49 "name": "Mostra Centropolis",50 "address": "2255, boulevard Daniel-Johnson, Laval",51 "sector": "Centropolis",52 "city": "Laval",53 },54 {55 "slug": "mascouche",56 "url": "https://mostramascouche.ca/appartements/",57 "name": "Mostra Mascouche",58 "address": "Mascouche",59 "sector": "",60 "city": "Mascouche",61 },62 {63 "slug": "maisonneuve",64 "url": "https://mostramaisonneuve.ca/choisir-mon-unite/",65 "name": "Mostra Maisonneuve",66 "address": "5460, rue Sherbrooke Est, Montréal",67 "sector": "Hochelaga-Maisonneuve",68 "city": "Montréal",69 },70]7172# « 3 1/2 À partir de 1550$ » (Mascouche)73SAT_TYPE_RE = re.compile(74 r"(\d\s*(?:½|1/2))\s*\|\s*À partir de\s*([\d\s]+)\s*\$", re.I)75# « Appartements 3 1/2 à louer | à partir de 1 811$ [(liste d'attente)] »76# et « Studios à louer à partir de 1 401$ (liste d'attente) » (Maisonneuve)77SAT_LOUER_RE = re.compile(78 r"(Studios?|Appartements?\s*\d\s*(?:½|1/2))\s*à\s*louer\s*(?:\|\s*)?"79 r"à\s*partir\s*de\s*([\d\s]+)\s*\$\s*(\(liste d'attente\))?", re.I)80# « 3½ - 1 chambre | à louer | Phase A : 1 800 $ | Phase B : 1 540 $ »81SAT_PHASE_BLOC_RE = re.compile(82 r"(\d½)\s*-\s*\d\s*chambres?\s*\|\s*à louer((?:\s*\|\s*Phase\s+[A-Z]\s*:"83 r"\s*[\d\s]+\s*\$)+)", re.I)84SAT_PHASE_RE = re.compile(r"Phase\s+([A-Z])\s*:\s*([\d\s]+)\s*\$", re.I)85SAT_IMG_RE = re.compile(86 r'(?:src|data-lazy-src)="(https?://[^"]*/wp-content/uploads/'87 r'[^"]+\.(?:jpg|jpeg|webp))"', re.I)888990class MostraConnector(BaseConnector):91 source_id = "mostra"92 request_delay = 0.89394 # -- site principal (ColdFusion) : unités disponibles via l'AJAX officiel --95 def _fetch_main(self) -> list[Listing]:96 listings: list[Listing] = []97 try:98 home = self.get(f"{BASE}/").text99 except Exception:100 return listings101 pages = {} # slug ville -> chemin relatif102 for path, ville in DISPO_RE.findall(home):103 pages.setdefault(ville, path)104 empl = {} # slug ville -> page fiche projet105 for path, ville in EMPL_RE.findall(home):106 empl.setdefault(ville, path)107108 for ville, path in pages.items():109 try:110 page = self.get(f"{BASE}/{path}").text111 except Exception:112 continue113 pm = PROJET_RE.search(page)114 pp = PAGE_RE.search(page)115 nm = NBMAX_RE.search(page)116 if not (pm and pp):117 continue118 nombre_max = nm.group(1) if nm else "500"119120 # photos : galerie de la fiche projet (page « emplacements »)121 images: list[str] = []122 if ville in empl:123 try:124 fiche = self.get(f"{BASE}/{empl[ville]}").text125 images = [f"{BASE}/{u}" for u in dict.fromkeys(126 PHOTO_MAIN_RE.findall(fiche))][:12]127 except Exception:128 images = []129130 try:131 frag = self.post(AJAX_URL, data={132 "grandeur": 0, "chambre": 0, "exposition": 0, "prix": 0,133 "salledebain": 0, "typeunite": 0, "typeunitePage": 0,134 "emplacement": 0, "etage": 0,135 "projet": pm.group(1),136 "nombreAffichage": 500,137 "nombreMax": nombre_max,138 "page": pp.group(1),139 }).text140 except Exception:141 continue142 soup = BeautifulSoup(frag, "html.parser")143 for item in soup.select("div.uniteItem[data-id]"):144 try:145 uid = item["data-id"]146 cols = {}147 for i in range(1, 9):148 el = item.select_one(f".uniteItemCol.c{i}")149 cols[i] = el.get_text(" ", strip=True) if el else ""150 emplacement = cols[1]151 num = re.sub(r"^\s*Unité\s*", "", cols[2]).strip()152 sqft = cols[3]153 modele = cols[5] # « 4 1/2 »154 # loyer parfois sans « $ » (ex. « 2485 »)155 price = parse_price(cols[7])156 if price is None and re.fullmatch(r"[\d\s]{3,6}",157 cols[7].strip()):158 try:159 price = float(re.sub(r"\s", "", cols[7]))160 except ValueError:161 price = None162 bedrooms = bathrooms = None163 m = CHSB_RE.search(cols[4])164 if m:165 bedrooms = float(m.group(1))166 if m.group(2):167 bathrooms = float(m.group(2))168 amenities = []169 if re.match(r"^\d[\d\s]*$", sqft):170 amenities.append(f"{sqft} pi²")171 listings.append(Listing(172 source=self.source_id,173 external_id=f"{ville}-{uid}",174 url=f"{BASE}/{path}",175 title=f"Mostra {emplacement} — "176 f"{modele or 'unité'} · unité {num}",177 address=emplacement,178 sector="",179 city=emplacement,180 unit_type=normalize_unit_type(modele),181 bedrooms=bedrooms,182 bathrooms=bathrooms,183 price=price,184 price_label=cols[7],185 availability="Disponible",186 amenities=amenities,187 images=images,188 ))189 except Exception:190 continue191 return listings192193 # -- satellites WordPress : prix « à partir de » par type d'unité ----------194 def _fetch_satellite(self, cfg: dict) -> list[Listing]:195 listings: list[Listing] = []196 try:197 html = self.get(cfg["url"]).text198 except Exception:199 return listings200 soup = BeautifulSoup(html, "html.parser")201 text = soup.get_text(" | ", strip=True).replace("\xa0", " ")202 text = re.sub(r"\s+", " ", text)203204 images = [u for u in dict.fromkeys(SAT_IMG_RE.findall(html))205 if not BAD_IMG_RE.search(u)][:15]206207 seen: set[str] = set()208209 def add(unit_raw: str, price_label: str, extra: str = ""):210 ut = normalize_unit_type(unit_raw)211 key = re.sub(r"[^\w.]", "", f"{ut}{extra}".replace("½", ".5"))212 ext_id = f"{cfg['slug']}-{key or 'u'}"213 if ext_id in seen:214 return215 seen.add(ext_id)216 title = f"{cfg['name']} — {ut}" + (f" ({extra})" if extra else "")217 listings.append(Listing(218 source=self.source_id,219 external_id=ext_id,220 url=cfg["url"],221 title=title,222 address=cfg["address"],223 sector=cfg["sector"],224 city=cfg["city"],225 unit_type=ut,226 price=parse_price(price_label),227 price_label=price_label,228 availability="Disponible",229 images=images,230 ))231232 # Centropolis : blocs « 3½ - 1 chambre | à louer | Phase A : … »233 for m in SAT_PHASE_BLOC_RE.finditer(text):234 for phase, prix in SAT_PHASE_RE.findall(m.group(2)):235 add(m.group(1), f"À partir de {prix.strip()}$",236 extra=f"Phase {phase}")237 # Maisonneuve : « Appartements 3 1/2 à louer à partir de 1 811$ »238 for m in SAT_LOUER_RE.finditer(text):239 if m.group(3): # « (liste d'attente) » : pas de dispo240 continue241 unit_raw = "Studio" if m.group(1).lower().startswith("studio") \242 else m.group(1)243 add(unit_raw, f"À partir de {m.group(2).strip()}$")244 # Mascouche : « 3 1/2 | À partir de 1550$ »245 for m in SAT_TYPE_RE.finditer(text):246 add(m.group(1), f"À partir de {m.group(2).strip()}$")247 return listings248249 def fetch(self) -> list[Listing]:250 listings = self._fetch_main()251 for cfg in SATELLITES:252 try:253 listings.extend(self._fetch_satellite(cfg))254 except Exception:255 continue256 return listings257