# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mostra.py : connecteur Mostra (condos locatifs gérés par Cogir) # Couvre TOUS les sites Mostra dans un seul connecteur : # 1. mostra.ca (ColdFusion) — Mostra Longueuil et Mostra Candiac : pages # /fr/disponibilites-condo-.html découvertes depuis l'accueil, # puis rejeu de l'AJAX officiel POST /inc/ajax/unite.cfm (mêmes champs # que le bouton « Plus de résultats ») qui retourne UNIQUEMENT les # unités disponibles : emplacement, no d'unité, pi², ch./sb., modèle # (3 1/2…), plan PDF et loyer. Une annonce par unité disponible. # 2. Sites satellites WordPress/Elementor (un immeuble chacun) — prix # « à partir de » par type d'unité, rendus serveur : # - mostracentropolis.ca (Laval, 550 unités, phases A/B) ; # - mostramascouche.ca (/appartements/) ; # - mostramaisonneuve.ca (/choisir-mon-unite/, Hochelaga-Maisonneuve) — # les types marqués « liste d'attente » sont exclus (aucune dispo). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.mostra.ca" AJAX_URL = f"{BASE}/inc/ajax/unite.cfm" # Pages de disponibilités du site principal (découvertes depuis l'accueil) DISPO_RE = re.compile(r'href="(?:/)?(fr/disponibilites-condo-([a-z-]+)\.html)"') # Pages « emplacements » (fiche projet avec la galerie photo) EMPL_RE = re.compile( r'href="(?:/)?(fr/emplacements-condos/\d+-mostra-([a-z-]+)\.html)"') PROJET_RE = re.compile(r"projetID\s*=\s*'(\d+)'") PAGE_RE = re.compile(r"var\s+p\s*=\s*'([^']+)'") NBMAX_RE = re.compile(r'class="nombreMax"\s+value="(\d+)"') CHSB_RE = re.compile(r"(\d+)\s*ch\.(?:\s*/\s*([\d.]+)\s*sb\.)?", re.I) PHOTO_MAIN_RE = re.compile(r'(?:src|href)="(DATA/PHOTO/\d+_photo\.jpg)"', re.I) BAD_IMG_RE = re.compile(r"logo|icon|favicon|plan|etage|-\d+x\d+\.|/20\d\d/0?\d/" r"[A-Z][a-z]+\.png", re.I) # Sites satellites : un immeuble par site, prix « à partir de » par type SATELLITES = [ { "slug": "centropolis", "url": "https://mostracentropolis.ca/", "name": "Mostra Centropolis", "address": "2255, boulevard Daniel-Johnson, Laval", "sector": "Centropolis", "city": "Laval", }, { "slug": "mascouche", "url": "https://mostramascouche.ca/appartements/", "name": "Mostra Mascouche", "address": "Mascouche", "sector": "", "city": "Mascouche", }, { "slug": "maisonneuve", "url": "https://mostramaisonneuve.ca/choisir-mon-unite/", "name": "Mostra Maisonneuve", "address": "5460, rue Sherbrooke Est, Montréal", "sector": "Hochelaga-Maisonneuve", "city": "Montréal", }, ] # « 3 1/2 À partir de 1550$ » (Mascouche) SAT_TYPE_RE = re.compile( r"(\d\s*(?:½|1/2))\s*\|\s*À partir de\s*([\d\s]+)\s*\$", re.I) # « Appartements 3 1/2 à louer | à partir de 1 811$ [(liste d'attente)] » # et « Studios à louer à partir de 1 401$ (liste d'attente) » (Maisonneuve) SAT_LOUER_RE = re.compile( r"(Studios?|Appartements?\s*\d\s*(?:½|1/2))\s*à\s*louer\s*(?:\|\s*)?" r"à\s*partir\s*de\s*([\d\s]+)\s*\$\s*(\(liste d'attente\))?", re.I) # « 3½ - 1 chambre | à louer | Phase A : 1 800 $ | Phase B : 1 540 $ » SAT_PHASE_BLOC_RE = re.compile( r"(\d½)\s*-\s*\d\s*chambres?\s*\|\s*à louer((?:\s*\|\s*Phase\s+[A-Z]\s*:" r"\s*[\d\s]+\s*\$)+)", re.I) SAT_PHASE_RE = re.compile(r"Phase\s+([A-Z])\s*:\s*([\d\s]+)\s*\$", re.I) SAT_IMG_RE = re.compile( r'(?:src|data-lazy-src)="(https?://[^"]*/wp-content/uploads/' r'[^"]+\.(?:jpg|jpeg|webp))"', re.I) class MostraConnector(BaseConnector): source_id = "mostra" request_delay = 0.8 # -- site principal (ColdFusion) : unités disponibles via l'AJAX officiel -- def _fetch_main(self) -> list[Listing]: listings: list[Listing] = [] try: home = self.get(f"{BASE}/").text except Exception: return listings pages = {} # slug ville -> chemin relatif for path, ville in DISPO_RE.findall(home): pages.setdefault(ville, path) empl = {} # slug ville -> page fiche projet for path, ville in EMPL_RE.findall(home): empl.setdefault(ville, path) for ville, path in pages.items(): try: page = self.get(f"{BASE}/{path}").text except Exception: continue pm = PROJET_RE.search(page) pp = PAGE_RE.search(page) nm = NBMAX_RE.search(page) if not (pm and pp): continue nombre_max = nm.group(1) if nm else "500" # photos : galerie de la fiche projet (page « emplacements ») images: list[str] = [] if ville in empl: try: fiche = self.get(f"{BASE}/{empl[ville]}").text images = [f"{BASE}/{u}" for u in dict.fromkeys( PHOTO_MAIN_RE.findall(fiche))][:12] except Exception: images = [] try: frag = self.post(AJAX_URL, data={ "grandeur": 0, "chambre": 0, "exposition": 0, "prix": 0, "salledebain": 0, "typeunite": 0, "typeunitePage": 0, "emplacement": 0, "etage": 0, "projet": pm.group(1), "nombreAffichage": 500, "nombreMax": nombre_max, "page": pp.group(1), }).text except Exception: continue soup = BeautifulSoup(frag, "html.parser") for item in soup.select("div.uniteItem[data-id]"): try: uid = item["data-id"] cols = {} for i in range(1, 9): el = item.select_one(f".uniteItemCol.c{i}") cols[i] = el.get_text(" ", strip=True) if el else "" emplacement = cols[1] num = re.sub(r"^\s*Unité\s*", "", cols[2]).strip() sqft = cols[3] modele = cols[5] # « 4 1/2 » # loyer parfois sans « $ » (ex. « 2485 ») price = parse_price(cols[7]) if price is None and re.fullmatch(r"[\d\s]{3,6}", cols[7].strip()): try: price = float(re.sub(r"\s", "", cols[7])) except ValueError: price = None bedrooms = bathrooms = None m = CHSB_RE.search(cols[4]) if m: bedrooms = float(m.group(1)) if m.group(2): bathrooms = float(m.group(2)) amenities = [] if re.match(r"^\d[\d\s]*$", sqft): amenities.append(f"{sqft} pi²") listings.append(Listing( source=self.source_id, external_id=f"{ville}-{uid}", url=f"{BASE}/{path}", title=f"Mostra {emplacement} — " f"{modele or 'unité'} · unité {num}", address=emplacement, sector="", city=emplacement, unit_type=normalize_unit_type(modele), bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=cols[7], availability="Disponible", amenities=amenities, images=images, )) except Exception: continue return listings # -- satellites WordPress : prix « à partir de » par type d'unité ---------- def _fetch_satellite(self, cfg: dict) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(cfg["url"]).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") text = soup.get_text(" | ", strip=True).replace("\xa0", " ") text = re.sub(r"\s+", " ", text) images = [u for u in dict.fromkeys(SAT_IMG_RE.findall(html)) if not BAD_IMG_RE.search(u)][:15] seen: set[str] = set() def add(unit_raw: str, price_label: str, extra: str = ""): ut = normalize_unit_type(unit_raw) key = re.sub(r"[^\w.]", "", f"{ut}{extra}".replace("½", ".5")) ext_id = f"{cfg['slug']}-{key or 'u'}" if ext_id in seen: return seen.add(ext_id) title = f"{cfg['name']} — {ut}" + (f" ({extra})" if extra else "") listings.append(Listing( source=self.source_id, external_id=ext_id, url=cfg["url"], title=title, address=cfg["address"], sector=cfg["sector"], city=cfg["city"], unit_type=ut, price=parse_price(price_label), price_label=price_label, availability="Disponible", images=images, )) # Centropolis : blocs « 3½ - 1 chambre | à louer | Phase A : … » for m in SAT_PHASE_BLOC_RE.finditer(text): for phase, prix in SAT_PHASE_RE.findall(m.group(2)): add(m.group(1), f"À partir de {prix.strip()}$", extra=f"Phase {phase}") # Maisonneuve : « Appartements 3 1/2 à louer à partir de 1 811$ » for m in SAT_LOUER_RE.finditer(text): if m.group(3): # « (liste d'attente) » : pas de dispo continue unit_raw = "Studio" if m.group(1).lower().startswith("studio") \ else m.group(1) add(unit_raw, f"À partir de {m.group(2).strip()}$") # Mascouche : « 3 1/2 | À partir de 1550$ » for m in SAT_TYPE_RE.finditer(text): add(m.group(1), f"À partir de {m.group(2).strip()}$") return listings def fetch(self) -> list[Listing]: listings = self._fetch_main() for cfg in SATELLITES: try: listings.extend(self._fetch_satellite(cfg)) except Exception: continue return listings