Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/minto.py : Minto Apartments (mintoapartments.com)5# Server-rendered «projects» pages per region. Rent-Ka scans Ottawa, the6# GTA, Calgary and Vancouver (verified live 2026-08-27; Montréal is7# Rent-Ka's). Each property page (main.html) lists its suite types in8# .projects-apartamets-unit-card cards: name (h4), availability9# («Available now» / «Available September 17» / «Not available»), price10# ($X - $Y), sqft (span icon-svg-column), baths, per-type photo gallery11# (JS `lightboxImages…` arrays). The Contact section gives the full12# address, the neighbourhood («Neighbourhood:») and phone/email; the13# Features sections list the amenities (checkmark items).14# One listing per available suite type.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import os19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price, strip_accents24from .base import BaseConnector2526BASE = "https://www.mintoapartments.com"2728LIGHTBOX_RE = re.compile(r'var\s+lightboxImages(\d+)\s*=\s*\[(.*?)\];', re.S)29IMG_SRC_RE = re.compile(r"src:\s*'([^']+)'")30_PHONE_RE = re.compile(r"tel:([\d\-() .]{7,20})")31_EMAIL_RE = re.compile(r"mailto:([\w.+-]+@[\w-]+\.[\w.]+)")3233# region -> (projects page, default city, province)34LIST_URLS = [35 ("ottawa", f"{BASE}/ottawa/apartment-rentals/projects.html", "Ottawa", "ON"),36 ("gta", f"{BASE}/gta/apartment-rentals/projects.html", "Toronto", "ON"),37 ("calgary", f"{BASE}/calgary/apartment-rentals/projects.html",38 "Calgary", "AB"),39 ("vancouver", f"{BASE}/vancouver/apartment-rentals/projects.html",40 "Vancouver", "BC"),41]42# /<region>/<subpath>/<slug>/main.html — the GTA subpath carries the sector43# (e.g. North-York-apartment-rentals); Ottawa's is simply apartment-rentals44PROJECT_RE = re.compile(45 r'https?://www\.mintoapartments\.com/(ottawa|gta|calgary|vancouver)/'46 r'([A-Za-z0-9\-]+)/([A-Za-z0-9\-]+)/main\.html')4748# GTA URL area (lowercase, without -apartment-rentals) -> (city, sector)49_AREAS = {50 "ottawa": ("Ottawa", ""),51 "toronto": ("Toronto", ""),52 "downtown-toronto": ("Toronto", "Downtown"),53 "etobicoke": ("Toronto", "Etobicoke"),54 "north-york": ("Toronto", "North York"),55 "mississauga": ("Mississauga", ""),56 "oakville": ("Oakville", ""),57 "calgary": ("Calgary", ""),58 "vancouver": ("Vancouver", ""),59}606162class MintoConnector(BaseConnector):63 source_id = "minto"64 request_delay = 0.665 max_projects = 80 # safety cap (all regions)66 max_images = 256768 def fetch(self) -> list[Listing]:69 listings: list[Listing] = []70 n = 071 seen: set[tuple[str, str]] = set()72 for region, list_url, default_city, prov in LIST_URLS:73 try:74 html = self.get(list_url).text75 except Exception:76 continue77 for r, mid, slug in dict.fromkeys(PROJECT_RE.findall(html)):78 if r != region or (region, slug) in seen:79 continue80 # exclude short-term furnished (furnished-apartments)81 if not mid.endswith("apartment-rentals"):82 continue83 seen.add((region, slug))84 if n >= self.max_projects:85 break86 n += 187 area = (re.sub(r"-?apartment-rentals$", "", mid)88 .strip("-").lower() or region)89 city, sector = _AREAS.get(90 area, (area.replace("-", " ").title() or default_city, ""))91 try:92 listings.extend(self._project_listings(93 slug, region=region, subpath=mid,94 on_city=city, on_sector=sector, province=prov))95 except Exception:96 continue97 return listings9899 def _project_listings(self, slug: str, region: str = "ottawa",100 subpath: str = "apartment-rentals",101 on_city: str = "", on_sector: str = "",102 province: str = "ON") -> list[Listing]:103 url = f"{BASE}/{region}/{subpath}/{slug}/main.html"104 html = self.get(url).text105 soup = BeautifulSoup(html, "html.parser")106107 name = slug.replace("-", " ").strip()108 h1 = soup.find("h1")109 if h1 and h1.get_text(strip=True):110 name = h1.get_text(" ", strip=True)111 t = soup.find("title")112 title_text = t.get_text(strip=True) if t else ""113114 # Neighbourhood: «Neighbourhood: Wellington West» (Contact section)115 # takes priority; otherwise city/sector derived from the projects URL116 sector = ""117 nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html)118 if nm:119 sector = nm.group(1).strip()120 city = on_city121 sector = sector or on_sector122 if sector == city:123 sector = ""124125 # Adresse complète (section Contact : rue + ville + code postal)126 address = ""127 am = re.search(128 r'font-weight:\s*normal">\s*([^<]{8,140})</p>', html)129 if am:130 address = re.sub(r"\s*\n\s*", ", ", am.group(1).strip())131 address = re.sub(r"\s+", " ", address)132 if not address:133 am = re.search(134 r'\d{2,5},?\s+(?:chemin|chem\.|avenue|rue|boulevard|c[ôo]te)'135 r'[^<>"{}]{3,60}', html, re.I)136 if am:137 address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")138 if address:139 address = re.sub(r",\s*,", ",", address) # «Drive,, Etobicoke»140 if not re.search(rf"\b{province}\b", address):141 address = f"{address}, {province}"142143 # Contact location (téléphone / courriel) -> details.contact144 details: dict = {}145 contact: dict = {}146 pm = _PHONE_RE.search(html)147 if pm:148 contact["phone"] = pm.group(1).strip()149 em = _EMAIL_RE.search(html)150 if em:151 contact["email"] = em.group(1)152 if contact:153 details["contact"] = contact154155 # Photos de la propriété (carrousel d'entête, repli des galeries)156 hero = [u for u in re.findall(157 r'https://media\.minto\.com/(?:dev/)?slideshows/[^"\'\s]+'158 r'\.(?:jpg|jpeg|png|webp)', html)]159 hero = list(dict.fromkeys(hero))[:8]160161 # Commodités : items à coche des sections « Building features » /162 # « Suite features » (l'astérisque « * » = dans certaines suites)163 amenities: list[str] = []164 for span in soup.select('li span[class*="icon-svg-check-mark"]'):165 li = span.find_parent("li")166 txt = li.get_text(" ", strip=True) if li else ""167 if txt and txt not in amenities and len(txt) < 60:168 amenities.append(txt)169 amenities = amenities[:30]170171 og = soup.find("meta", attrs={"name": "description"})172 desc = (og.get("content", "").strip()[:600] if og else "")173174 listings: list[Listing] = []175 for card in soup.select(".projects-apartamets-unit-card"):176 try:177 h4 = card.select_one("h4.h-h3-minto")178 if not h4:179 continue180 suite_name = h4.get_text(" ", strip=True)181 if not suite_name or len(suite_name) > 70:182 continue183184 # Disponibilité par type de suite (« Available now »,185 # « Available September 17 », « Not available »)186 av_el = card.select_one('[class*="btn-availa"]')187 availability = (av_el.get_text(" ", strip=True)188 if av_el else "")189 if re.match(r"^not\s+available$", availability, re.I):190 continue # type de suite non offert actuellement191192 # Prix « $1,215 - $1,305 »193 ph5 = card.select_one("h5.h-h3-minto")194 price_txt = ph5.get_text(" ", strip=True) if ph5 else ""195 pmatch = re.search(r'\$[\d,]+(?:\s*-\s*\$[\d,]+)?', price_txt)196 if not pmatch:197 continue # carte non tarifée198 price_label = re.sub(r"\s+", " ", pmatch.group(0))199 price = parse_price(200 price_label.split("-")[0].replace("$", "")201 .replace(",", "") + " $")202203 # Superficie structurée « 425 - 560 » SQ FT (min de la plage)204 area_sqft = None205 sq_icon = card.select_one("span.icon-svg-column")206 if sq_icon:207 sib = sq_icon.find_next_sibling("span")208 if sib:209 nums = [float(n.replace(",", "")) for n in210 re.findall(r"[\d,]+", sib.get_text())]211 nums = [n for n in nums if 80 <= n <= 20000]212 if nums:213 area_sqft = min(nums)214215 bm = re.search(r'([\d.]+)\s*Bathroom',216 card.get_text(" ", strip=True))217218 # Galerie du type de suite (tableau lightbox de la carte)219 imgs: list[str] = []220 gm = LIGHTBOX_RE.search(str(card))221 if gm:222 imgs = IMG_SRC_RE.findall(gm.group(2))223 imgs = list(dict.fromkeys(imgs))[: self.max_images] or hero224225 suite_slug = re.sub(r"[^a-z0-9]+", "-",226 strip_accents(suite_name.lower())).strip("-")227 bits = [f"{bm.group(1)} bath" if bm else ""]228 listings.append(Listing(229 source=self.source_id,230 external_id=f"{slug}-{suite_slug}",231 url=url,232 title=f"{name} — {suite_name}",233 address=address,234 sector=sector,235 city=city,236 province=province,237 unit_type=normalize_unit_type(suite_name),238 price=price,239 price_label=f"{price_label} /month",240 availability=availability,241 area_sqft=area_sqft,242 description=" — ".join(243 x for x in [desc] + bits if x)[:600],244 amenities=amenities,245 details=dict(details),246 images=imgs,247 ))248 except Exception:249 continue250 return listings251