SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.6 KB · 251 lines python
Raw Blame History
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