# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mondev.py : connecteur Mondev (mondev.ca) # Grand constructeur-locateur montréalais (Ville-Marie, Sud-Ouest, # Griffintown, Plateau, LaSalle, etc.). Site WordPress/Elementor rendu # serveur : la page /apartments-and-condos-for-rent/ liste les immeubles # (une carte par immeuble, quartier dans l'URL), et chaque fiche immeuble # contient un « PLAN SELECTOR » avec, par typologie, « Starting at $X » ou # « not available ». Une annonce par (immeuble, typologie) disponible. # Prix « à partir de », adresse, description, commodités et galerie photos. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://mondev.ca" LIST_URL = f"{BASE}/apartments-and-condos-for-rent/" _BUILDING_RE = re.compile( r'href="(https://mondev\.ca/apartments-and-condos-for-rent/' r'([a-z0-9-]+)/([a-z0-9-]+)/)"') # Typologie (site anglophone) -> type normalisé Lou-Ka _TYPE_MAP = { "studio": "Studio", "1-bedroom": "3½", "2-bedroom": "4½", "3-bedroom": "5½", "4-bedroom": "6½", "penthouse": "Penthouse", "loft": "Loft", "townhouse": "Maison", } _PLAN_RE = re.compile( r"(Studio|\d-bedroom|Penthouse|Loft|Townhouse)\s*[-–]\s*" r"(?:Starting at\s*\$\s*([\d,]+)|not\s+available)", re.I) # Quartier (slug d'URL) -> nom d'affichage _ZONES = { "ahuntsic-cartierville": "Ahuntsic-Cartierville", "cote-des-neiges": "Côte-des-Neiges", "downtown": "Centre-ville", "griffintown": "Griffintown", "lasalle": "LaSalle", "little-burgundy": "Petite-Bourgogne", "old-montreal": "Vieux-Montréal", "park-extension": "Parc-Extension", "plateau-mont-royal": "Plateau-Mont-Royal", "quartier-des-spectacles": "Quartier des spectacles", "rosemont-la-petite-patrie": "Rosemont–La Petite-Patrie", "sud-ouest": "Sud-Ouest", "ville-marie": "Ville-Marie", "ville-saint-laurent": "Saint-Laurent", "villeray-saint-michel-parc-extension": "Villeray–Saint-Michel–Parc-Extension", } _ADDR_RE = re.compile( r"\d[\w\s.'’&,-]{3,70},\s*(?:Montr[ée]al|(?:Ville )?Saint-Laurent|LaSalle|" r"Verdun)\b[^<>\"|]{0,60}", re.I) # entête d'adresse dédiée : « 605 Rue Fullum, Montreal, QC, Canada H3L 0A9 » _ADDR_HEAD_RE = re.compile( r"^\d{2,5}\b.{5,90}(?:,\s*(?:QC|Qu[ée]bec)\b|,\s*Canada\b)", re.I) _PHONE_RE = re.compile(r'href="tel:\+?([\d\-() .]{7,20})"') _GALLERY_RE = re.compile( r'href="(https://mondev\.ca/wp-content/uploads/[^"]+?\.(?:jpg|jpeg|png|webp))"' r'[^>]*data-elementor-lightbox-slideshow="wl_property_gallery_photos[^"]*"') class MondevConnector(BaseConnector): source_id = "mondev" request_delay = 0.6 max_buildings = 45 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: index = self.get(LIST_URL).text except Exception: return listings buildings: dict[str, tuple[str, str]] = {} for url, zone, slug in _BUILDING_RE.findall(index): buildings.setdefault(url, (zone, slug)) for i, (url, (zone, slug)) in enumerate(buildings.items()): if i >= self.max_buildings: break try: listings.extend(self._parse_building(url, zone, slug)) except Exception: continue return listings def _parse_building(self, url: str, zone: str, slug: str) -> list[Listing]: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") h1 = soup.find("h1") name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ") name = re.sub(r"\s*[-–]\s*(Condo|Apartment)\s+Rentals?\s*$", "", name, flags=re.I).strip() sector = _ZONES.get(zone, zone.replace("-", " ").title()) # Adresse civique : entête dédiée (h1-h3 « n° …, QC/Canada »), sinon # premier segment de texte ressemblant à une adresse address = "" for h in soup.select("h1, h2, h3"): htxt = h.get_text(" ", strip=True) if len(htxt) < 120 and _ADDR_HEAD_RE.match(htxt): address = re.sub(r"\s+", " ", htxt).strip().rstrip(",") break body_txt = soup.get_text("|", strip=True) if not address: for seg in body_txt.split("|"): seg = seg.strip() if len(seg) > 120: continue am = _ADDR_RE.search(seg) if am: address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",") break # Description (meta Yoast) description = "" meta = soup.find("meta", attrs={"name": "description"}) if meta and meta.get("content"): description = meta["content"].strip()[:600] # Commodités (bloc AMENITIES — icônes structurées) amenities = [el.get_text(" ", strip=True) for el in soup.select(".wl_property_amenities li .name")] amenities = [a for a in dict.fromkeys(amenities) if a] # Animaux : icône « Pets » dédiée du bloc amenities (structuré) pets = "oui" if any(a.strip().lower() in ("pets", "pet friendly") for a in amenities) else None # Contact : lien tel: du bouton d'appel details: dict = {} pm = _PHONE_RE.search(html) if pm: digits = re.sub(r"\D", "", pm.group(1)) if len(digits) == 10: details["contact"] = { "phone": f"{digits[0:3]}-{digits[3:6]}-{digits[6:]}"} # Galerie photos (liens lightbox) images = list(dict.fromkeys(_GALLERY_RE.findall(html)))[:40] # PLAN SELECTOR : « Studio - Starting at $1,635 » / « not available » plan_txt = "" marker = soup.find(string=re.compile(r"^\s*PLAN SELECTOR\s*$", re.I)) if marker: widget = marker.find_parent(class_="elementor-widget") if widget: sib = widget.find_next_sibling(class_="elementor-widget") if sib: plan_txt = sib.get_text("\n", strip=True) if not plan_txt: plan_txt = body_txt.replace("|", "\n") results: list[Listing] = [] for m in _PLAN_RE.finditer(plan_txt): raw_type, raw_price = m.group(1), m.group(2) if not raw_price: continue # typologie non disponible type_key = raw_type.lower() unit_type = _TYPE_MAP.get(type_key, raw_type) try: price = float(re.sub(r"[^\d]", "", raw_price)) except ValueError: continue if not (100 <= price <= 20000): continue results.append(Listing( source=self.source_id, external_id=f"{zone}-{slug}-{type_key}", url=url, title=f"{name} — {unit_type}", address=address, sector=sector, city="Montréal", unit_type=unit_type, price=price, price_label=f"À partir de {int(price)} $/mois", availability="Disponible", pets=pets, description=description, amenities=amenities, details=dict(details), images=images, )) return results