spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/mondev.py : connecteur Mondev (mondev.ca)5# Grand constructeur-locateur montréalais (Ville-Marie, Sud-Ouest,6# Griffintown, Plateau, LaSalle, etc.). Site WordPress/Elementor rendu7# serveur : la page /apartments-and-condos-for-rent/ liste les immeubles8# (une carte par immeuble, quartier dans l'URL), et chaque fiche immeuble9# contient un « PLAN SELECTOR » avec, par typologie, « Starting at $X » ou10# « not available ». Une annonce par (immeuble, typologie) disponible.11# Prix « à partir de », adresse, description, commodités et galerie photos.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing20from .base import BaseConnector2122BASE = "https://mondev.ca"23LIST_URL = f"{BASE}/apartments-and-condos-for-rent/"2425_BUILDING_RE = re.compile(26 r'href="(https://mondev\.ca/apartments-and-condos-for-rent/'27 r'([a-z0-9-]+)/([a-z0-9-]+)/)"')2829# Typologie (site anglophone) -> type normalisé Lou-Ka30_TYPE_MAP = {31 "studio": "Studio",32 "1-bedroom": "3½",33 "2-bedroom": "4½",34 "3-bedroom": "5½",35 "4-bedroom": "6½",36 "penthouse": "Penthouse",37 "loft": "Loft",38 "townhouse": "Maison",39}4041_PLAN_RE = re.compile(42 r"(Studio|\d-bedroom|Penthouse|Loft|Townhouse)\s*[-–]\s*"43 r"(?:Starting at\s*\$\s*([\d,]+)|not\s+available)", re.I)4445# Quartier (slug d'URL) -> nom d'affichage46_ZONES = {47 "ahuntsic-cartierville": "Ahuntsic-Cartierville",48 "cote-des-neiges": "Côte-des-Neiges",49 "downtown": "Centre-ville",50 "griffintown": "Griffintown",51 "lasalle": "LaSalle",52 "little-burgundy": "Petite-Bourgogne",53 "old-montreal": "Vieux-Montréal",54 "park-extension": "Parc-Extension",55 "plateau-mont-royal": "Plateau-Mont-Royal",56 "quartier-des-spectacles": "Quartier des spectacles",57 "rosemont-la-petite-patrie": "Rosemont–La Petite-Patrie",58 "sud-ouest": "Sud-Ouest",59 "ville-marie": "Ville-Marie",60 "ville-saint-laurent": "Saint-Laurent",61 "villeray-saint-michel-parc-extension": "Villeray–Saint-Michel–Parc-Extension",62}6364_ADDR_RE = re.compile(65 r"\d[\w\s.'’&,-]{3,70},\s*(?:Montr[ée]al|(?:Ville )?Saint-Laurent|LaSalle|"66 r"Verdun)\b[^<>\"|]{0,60}", re.I)67# entête d'adresse dédiée : « 605 Rue Fullum, Montreal, QC, Canada H3L 0A9 »68_ADDR_HEAD_RE = re.compile(69 r"^\d{2,5}\b.{5,90}(?:,\s*(?:QC|Qu[ée]bec)\b|,\s*Canada\b)", re.I)70_PHONE_RE = re.compile(r'href="tel:\+?([\d\-() .]{7,20})"')71_GALLERY_RE = re.compile(72 r'href="(https://mondev\.ca/wp-content/uploads/[^"]+?\.(?:jpg|jpeg|png|webp))"'73 r'[^>]*data-elementor-lightbox-slideshow="wl_property_gallery_photos[^"]*"')747576class MondevConnector(BaseConnector):77 source_id = "mondev"78 request_delay = 0.679 max_buildings = 45 # garde-fou de crawl8081 def fetch(self) -> list[Listing]:82 listings: list[Listing] = []83 try:84 index = self.get(LIST_URL).text85 except Exception:86 return listings8788 buildings: dict[str, tuple[str, str]] = {}89 for url, zone, slug in _BUILDING_RE.findall(index):90 buildings.setdefault(url, (zone, slug))9192 for i, (url, (zone, slug)) in enumerate(buildings.items()):93 if i >= self.max_buildings:94 break95 try:96 listings.extend(self._parse_building(url, zone, slug))97 except Exception:98 continue99 return listings100101 def _parse_building(self, url: str, zone: str, slug: str) -> list[Listing]:102 html = self.get(url).text103 soup = BeautifulSoup(html, "html.parser")104105 h1 = soup.find("h1")106 name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ")107 name = re.sub(r"\s*[-–]\s*(Condo|Apartment)\s+Rentals?\s*$", "", name,108 flags=re.I).strip()109110 sector = _ZONES.get(zone, zone.replace("-", " ").title())111112 # Adresse civique : entête dédiée (h1-h3 « n° …, QC/Canada »), sinon113 # premier segment de texte ressemblant à une adresse114 address = ""115 for h in soup.select("h1, h2, h3"):116 htxt = h.get_text(" ", strip=True)117 if len(htxt) < 120 and _ADDR_HEAD_RE.match(htxt):118 address = re.sub(r"\s+", " ", htxt).strip().rstrip(",")119 break120 body_txt = soup.get_text("|", strip=True)121 if not address:122 for seg in body_txt.split("|"):123 seg = seg.strip()124 if len(seg) > 120:125 continue126 am = _ADDR_RE.search(seg)127 if am:128 address = re.sub(r"\s+", " ",129 am.group(0)).strip().rstrip(",")130 break131132 # Description (meta Yoast)133 description = ""134 meta = soup.find("meta", attrs={"name": "description"})135 if meta and meta.get("content"):136 description = meta["content"].strip()[:600]137138 # Commodités (bloc AMENITIES — icônes structurées)139 amenities = [el.get_text(" ", strip=True)140 for el in soup.select(".wl_property_amenities li .name")]141 amenities = [a for a in dict.fromkeys(amenities) if a]142143 # Animaux : icône « Pets » dédiée du bloc amenities (structuré)144 pets = "oui" if any(a.strip().lower() in ("pets", "pet friendly")145 for a in amenities) else None146147 # Contact : lien tel: du bouton d'appel148 details: dict = {}149 pm = _PHONE_RE.search(html)150 if pm:151 digits = re.sub(r"\D", "", pm.group(1))152 if len(digits) == 10:153 details["contact"] = {154 "phone": f"{digits[0:3]}-{digits[3:6]}-{digits[6:]}"}155156 # Galerie photos (liens lightbox)157 images = list(dict.fromkeys(_GALLERY_RE.findall(html)))[:40]158159 # PLAN SELECTOR : « Studio - Starting at $1,635 » / « not available »160 plan_txt = ""161 marker = soup.find(string=re.compile(r"^\s*PLAN SELECTOR\s*$", re.I))162 if marker:163 widget = marker.find_parent(class_="elementor-widget")164 if widget:165 sib = widget.find_next_sibling(class_="elementor-widget")166 if sib:167 plan_txt = sib.get_text("\n", strip=True)168 if not plan_txt:169 plan_txt = body_txt.replace("|", "\n")170171 results: list[Listing] = []172 for m in _PLAN_RE.finditer(plan_txt):173 raw_type, raw_price = m.group(1), m.group(2)174 if not raw_price:175 continue # typologie non disponible176 type_key = raw_type.lower()177 unit_type = _TYPE_MAP.get(type_key, raw_type)178 try:179 price = float(re.sub(r"[^\d]", "", raw_price))180 except ValueError:181 continue182 if not (100 <= price <= 20000):183 continue184 results.append(Listing(185 source=self.source_id,186 external_id=f"{zone}-{slug}-{type_key}",187 url=url,188 title=f"{name} — {unit_type}",189 address=address,190 sector=sector,191 city="Montréal",192 unit_type=unit_type,193 price=price,194 price_label=f"À partir de {int(price)} $/mois",195 availability="Disponible",196 pets=pets,197 description=description,198 amenities=amenities,199 details=dict(details),200 images=images,201 ))202 return results203