# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/minto.py : connecteur Minto Apartments (mintoapartments.com) # Page « projects » Montréal rendue serveur : Rockhill, Haddon Hall, Le 4300, # Le Hill-Park. Chaque fiche propriété (main.html) liste ses types de suites # dans des cartes .projects-apartamets-unit-card : nom (h4), disponibilité # (« Available now » / « Available September 17 » / « Not available »), # prix ($X - $Y), pi² (span icon-svg-column), sdb, galerie photo par type # (tableaux JS `lightboxImages…`). La section Contact donne l'adresse # complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ; # les sections Features listent les commodités (items à coche). # Une annonce par type de suite disponible. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.mintoapartments.com" LIST_URL = f"{BASE}/montreal/apartment-rentals/projects.html" PROJECT_RE = re.compile( r'https?://www\.mintoapartments\.com/montreal/apartment-rentals/' r'([A-Za-z0-9\-]+)/main\.html') LIGHTBOX_RE = re.compile(r'var\s+lightboxImages(\d+)\s*=\s*\[(.*?)\];', re.S) IMG_SRC_RE = re.compile(r"src:\s*'([^']+)'") _PHONE_RE = re.compile(r"tel:([\d\-() .]{7,20})") _EMAIL_RE = re.compile(r"mailto:([\w.+-]+@[\w-]+\.[\w.]+)") # Secteurs connus recherchés dans le (repli si pas de # « Neighbourhood: ») ; certains sont des villes distinctes de l'île. _KNOWN_SECTORS = [ "Côte-des-Neiges", "Westmount", "Côte-Saint-Luc", "Mont-Royal", "Notre-Dame-de-Grâce", "NDG", "Griffintown", "Plateau", "Ville-Marie", "Downtown", ] _CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal", "cote-saint-luc": "Côte-Saint-Luc"} class MintoConnector(BaseConnector): source_id = "minto" request_delay = 0.6 max_projects = 12 # garde-fou max_images = 25 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text slugs = list(dict.fromkeys(PROJECT_RE.findall(html))) listings: list[Listing] = [] for slug in slugs[: self.max_projects]: try: listings.extend(self._project_listings(slug)) except Exception: continue return listings def _project_listings(self, slug: str) -> list[Listing]: url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") name = slug.replace("-", " ").strip() h1 = soup.find("h1") if h1 and h1.get_text(strip=True): name = h1.get_text(" ", strip=True) t = soup.find("title") title_text = t.get_text(strip=True) if t else "" # Quartier : « Neighbourhood: Shaughnessy Village » (section Contact), # sinon premier quartier connu mentionné dans le <title> sector = "" nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html) if nm: sector = nm.group(1).strip() else: for s in _KNOWN_SECTORS: if s.lower() in title_text.lower(): sector = "Centre-ville" if s == "Downtown" else s break city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal") if sector == city: sector = "" # Adresse complète (section Contact : rue + ville + code postal) address = "" am = re.search( r'font-weight:\s*normal">\s*([^<]{8,140})</p>', html) if am: address = re.sub(r"\s*\n\s*", ", ", am.group(1).strip()) address = re.sub(r"\s+", " ", address) if not address: am = re.search( r'\d{2,5},?\s+(?:chemin|chem\.|avenue|rue|boulevard|c[ôo]te)' r'[^<>"{}]{3,60}', html, re.I) if am: address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",") # Contact location (téléphone / courriel) -> details.contact details: dict = {} contact: dict = {} pm = _PHONE_RE.search(html) if pm: contact["phone"] = pm.group(1).strip() em = _EMAIL_RE.search(html) if em: contact["email"] = em.group(1) if contact: details["contact"] = contact # Photos de la propriété (carrousel d'entête, repli des galeries) hero = [u for u in re.findall( r'https://media\.minto\.com/(?:dev/)?slideshows/[^"\'\s]+' r'\.(?:jpg|jpeg|png|webp)', html)] hero = list(dict.fromkeys(hero))[:8] # Commodités : items à coche des sections « Building features » / # « Suite features » (l'astérisque « * » = dans certaines suites) amenities: list[str] = [] for span in soup.select('li span[class*="icon-svg-check-mark"]'): li = span.find_parent("li") txt = li.get_text(" ", strip=True) if li else "" if txt and txt not in amenities and len(txt) < 60: amenities.append(txt) amenities = amenities[:30] og = soup.find("meta", attrs={"name": "description"}) desc = (og.get("content", "").strip()[:600] if og else "") listings: list[Listing] = [] for card in soup.select(".projects-apartamets-unit-card"): try: h4 = card.select_one("h4.h-h3-minto") if not h4: continue suite_name = h4.get_text(" ", strip=True) if not suite_name or len(suite_name) > 70: continue # Disponibilité par type de suite (« Available now », # « Available September 17 », « Not available ») av_el = card.select_one('[class*="btn-availa"]') availability = (av_el.get_text(" ", strip=True) if av_el else "") if re.match(r"^not\s+available$", availability, re.I): continue # type de suite non offert actuellement # Prix « $1,215 - $1,305 » ph5 = card.select_one("h5.h-h3-minto") price_txt = ph5.get_text(" ", strip=True) if ph5 else "" pmatch = re.search(r'\$[\d,]+(?:\s*-\s*\$[\d,]+)?', price_txt) if not pmatch: continue # carte non tarifée price_label = re.sub(r"\s+", " ", pmatch.group(0)) price = parse_price( price_label.split("-")[0].replace("$", "") .replace(",", "") + " $") # Superficie structurée « 425 - 560 » SQ FT (min de la plage) area_sqft = None sq_icon = card.select_one("span.icon-svg-column") if sq_icon: sib = sq_icon.find_next_sibling("span") if sib: nums = [float(n.replace(",", "")) for n in re.findall(r"[\d,]+", sib.get_text())] nums = [n for n in nums if 80 <= n <= 20000] if nums: area_sqft = min(nums) bm = re.search(r'([\d.]+)\s*Bathroom', card.get_text(" ", strip=True)) # Galerie du type de suite (tableau lightbox de la carte) imgs: list[str] = [] gm = LIGHTBOX_RE.search(str(card)) if gm: imgs = IMG_SRC_RE.findall(gm.group(2)) imgs = list(dict.fromkeys(imgs))[: self.max_images] or hero suite_slug = re.sub(r"[^a-z0-9]+", "-", strip_accents(suite_name.lower())).strip("-") bits = [f"{bm.group(1)} sdb" if bm else ""] listings.append(Listing( source=self.source_id, external_id=f"{slug}-{suite_slug}", url=url, title=f"{name} — {suite_name}", address=address, sector=sector, city=city, unit_type=normalize_unit_type(suite_name), price=price, price_label=f"{price_label} /mois", availability=availability, area_sqft=area_sqft, description=" — ".join( x for x in [desc] + bits if x)[:600], amenities=amenities, details=dict(details), images=imgs, )) except Exception: continue return listings