# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/minto.py : Minto Apartments (mintoapartments.com) # Server-rendered «projects» pages per region. Rent-Ka scans Ottawa, the # GTA, Calgary and Vancouver (verified live 2026-08-27; Montréal is # Rent-Ka's). Each property page (main.html) lists its suite types in # .projects-apartamets-unit-card cards: name (h4), availability # («Available now» / «Available September 17» / «Not available»), price # ($X - $Y), sqft (span icon-svg-column), baths, per-type photo gallery # (JS `lightboxImages…` arrays). The Contact section gives the full # address, the neighbourhood («Neighbourhood:») and phone/email; the # Features sections list the amenities (checkmark items). # One listing per available suite type. # ----------------------------------------------------------------------------- from __future__ import annotations import os 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" 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.]+)") # region -> (projects page, default city, province) LIST_URLS = [ ("ottawa", f"{BASE}/ottawa/apartment-rentals/projects.html", "Ottawa", "ON"), ("gta", f"{BASE}/gta/apartment-rentals/projects.html", "Toronto", "ON"), ("calgary", f"{BASE}/calgary/apartment-rentals/projects.html", "Calgary", "AB"), ("vancouver", f"{BASE}/vancouver/apartment-rentals/projects.html", "Vancouver", "BC"), ] # ////main.html — the GTA subpath carries the sector # (e.g. North-York-apartment-rentals); Ottawa's is simply apartment-rentals PROJECT_RE = re.compile( r'https?://www\.mintoapartments\.com/(ottawa|gta|calgary|vancouver)/' r'([A-Za-z0-9\-]+)/([A-Za-z0-9\-]+)/main\.html') # GTA URL area (lowercase, without -apartment-rentals) -> (city, sector) _AREAS = { "ottawa": ("Ottawa", ""), "toronto": ("Toronto", ""), "downtown-toronto": ("Toronto", "Downtown"), "etobicoke": ("Toronto", "Etobicoke"), "north-york": ("Toronto", "North York"), "mississauga": ("Mississauga", ""), "oakville": ("Oakville", ""), "calgary": ("Calgary", ""), "vancouver": ("Vancouver", ""), } class MintoConnector(BaseConnector): source_id = "minto" request_delay = 0.6 max_projects = 80 # safety cap (all regions) max_images = 25 def fetch(self) -> list[Listing]: listings: list[Listing] = [] n = 0 seen: set[tuple[str, str]] = set() for region, list_url, default_city, prov in LIST_URLS: try: html = self.get(list_url).text except Exception: continue for r, mid, slug in dict.fromkeys(PROJECT_RE.findall(html)): if r != region or (region, slug) in seen: continue # exclude short-term furnished (furnished-apartments) if not mid.endswith("apartment-rentals"): continue seen.add((region, slug)) if n >= self.max_projects: break n += 1 area = (re.sub(r"-?apartment-rentals$", "", mid) .strip("-").lower() or region) city, sector = _AREAS.get( area, (area.replace("-", " ").title() or default_city, "")) try: listings.extend(self._project_listings( slug, region=region, subpath=mid, on_city=city, on_sector=sector, province=prov)) except Exception: continue return listings def _project_listings(self, slug: str, region: str = "ottawa", subpath: str = "apartment-rentals", on_city: str = "", on_sector: str = "", province: str = "ON") -> list[Listing]: url = f"{BASE}/{region}/{subpath}/{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 "" # Neighbourhood: «Neighbourhood: Wellington West» (Contact section) # takes priority; otherwise city/sector derived from the projects URL sector = "" nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html) if nm: sector = nm.group(1).strip() city = on_city sector = sector or on_sector 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})

', 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(",") if address: address = re.sub(r",\s*,", ",", address) # «Drive,, Etobicoke» if not re.search(rf"\b{province}\b", address): address = f"{address}, {province}" # 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)} bath" 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, province=province, unit_type=normalize_unit_type(suite_name), price=price, price_label=f"{price_label} /month", 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