# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/metcap.py : MetCap Living (metcap.com) # Server-rendered WordPress site. Every page carries the full city menu: # «province-search-results?province=&city=» links. Rent-Ka scans # every province except Québec (ids verified live 2026-08-27: 113=ON # 20 cities, 117=BC 8, 119=NS 7, 121=NB 1, 213=AB 4). Result pages list # buildings (lat/lng in the onclick=centerMap attribute) and their unit # types («Toronto 2 Bedrooms from $1,819»). /apartment/... pages give the # structured detail: «Suite Details» table (status, beds, baths, sqft), # «Building Amenities», «Rent Includes», «Pet Friendly» lists, leasing # office contact, description and unit photos; /property/... pages give # the building photo gallery. Detail pages go through self.detail() (DB # cache). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re import urllib.parse from bs4 import BeautifulSoup from ..schema import Listing, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.metcap.com" MENU_URL = f"{BASE}/province/ontario?lang=en" # any page carries the menu # MetCap province ids -> province code (Québec 115 excluded) _PROVINCE_IDS = {"113": "ON", "117": "BC", "119": "NS", "121": "NB", "213": "AB"} # Former Toronto boroughs -> (display city, sector); other cities pass through _CITY_NORM = { "north york": ("Toronto", "North York"), "scarborough": ("Toronto", "Scarborough"), "etobicoke": ("Toronto", "Etobicoke"), "east york": ("Toronto", "East York"), } _TYPE_MAP = [ (re.compile(r"bachelor|studio", re.I), "Studio"), (re.compile(r"1\s*bed", re.I), "1 bedroom"), (re.compile(r"2\s*bed", re.I), "2 bedrooms"), (re.compile(r"3\s*bed", re.I), "3 bedrooms"), (re.compile(r"4\s*bed", re.I), "4 bedrooms"), ] _SKIP_IMG = re.compile(r"logo|icon|favicon|header|/map/|walk\.sc|sharethis", re.I) _LATLNG_RE = re.compile(r"\{\s*lat:\s*(-?[\d.]+)\s*,\s*" r"lon:\s*(-?[\d.]+)\s*\}") _PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b") _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b") # «Rent Includes» (structured English text) -> canonical inclusion keys _INCLUDES_MAP = [ (re.compile(r"heat", re.I), "heating"), (re.compile(r"hydro|electric", re.I), "electricity"), (re.compile(r"hot\s*water", re.I), "hot_water"), (re.compile(r"internet|wi-?fi", re.I), "internet"), (re.compile(r"cable", re.I), "cable"), ] class _CapAtteint(Exception): """Plafond de requêtes détail atteint pour cette synchronisation.""" class MetcapConnector(BaseConnector): source_id = "metcap" request_delay = 0.6 max_units = 400 # safety cap on unit pages (all provinces) max_images = 25 max_real_details = 200 # real detail requests per sync (cache hits free) def fetch(self) -> list[Listing]: self._real_details = 0 self._unit_count = 0 listings: list[Listing] = [] # One page carries the whole city menu: collect (province_id, city) html = self.get(MENU_URL).text targets: list[tuple[str, str, str]] = [] # (province_id, city, prov) seen = set() for href in re.findall(r'href="(/province-search-results\?[^"]+)"', html): q = urllib.parse.parse_qs(urllib.parse.urlparse( href.replace("&", "&")).query) pid = (q.get("province") or [""])[0] prov = _PROVINCE_IDS.get(pid) if not prov: continue # Québec (115) and unknown ids are skipped city = (q.get("city") or [""])[0] if city and (pid, city) not in seen: seen.add((pid, city)) targets.append((pid, city, prov)) for pid, city_name, prov in targets: try: self._scan_city(pid, city_name, prov, listings) except Exception: continue return listings def _scan_city(self, province_id: str, city_name: str, province: str, listings: list[Listing]) -> None: """Scan one province-search-results page (buildings + unit links).""" key = strip_accents(city_name.lower()).replace(".", "").strip() city, sector = _CITY_NORM.get(key, (city_name, "")) page = self.get( f"{BASE}/province-search-results?lang=en" f"&province={province_id}" f"&city={urllib.parse.quote(city_name)}").text soup = BeautifulSoup(page, "html.parser") for item in soup.select(".province-results__item"): try: block = item.select_one(".province-results__content") if not block: continue h2a = block.select_one("h2 a[href^='/property/']") if not h2a: continue address = h2a.get_text(" ", strip=True) prop_path = h2a.get("href", "").split("?")[0] # building lat/lng: onclick="centerMap(..., {lat, lon})" lat = lng = None lm = _LATLNG_RE.search(item.get("onclick", "") or "") if lm: lat, lng = float(lm.group(1)), float(lm.group(2)) spans = block.select("p span.d-block") prop_name = "" if spans and not spans[0].find("a"): prop_name = spans[0].get_text(" ", strip=True) for a in block.select("a[href^='/apartment/']"): if self._unit_count >= self.max_units: break self._unit_count += 1 text = a.get_text(" ", strip=True) lst = self._unit_listing( a.get("href", ""), text, address, prop_name, prop_path, city, sector, lat, lng, province=province) if lst: listings.append(lst) except Exception: continue # -- pages détail (via cache BD self.detail) ------------------------------- def _gallery(self, prop_path: str) -> list[str]: """Galerie photo de la fiche immeuble (partagée entre unités).""" def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 imgs: list[str] = [] ph = self.get(f"{BASE}{prop_path}?lang=en").text for u in re.findall( r'https://www\.metcap\.com/wp-content/uploads/' r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', ph): if not _SKIP_IMG.search(u) and u not in imgs: imgs.append(u) return {"images": imgs[: self.max_images]} try: return self.detail(f"property:{prop_path}", prop_path, _fetch).get("images") or [] except Exception: return [] def _unit_detail(self, slug: str, url: str, card_key: str) -> dict: """Fiche unité : tableau Suite Details, listes sidebar, contact, description, intersection et photos d'unité.""" def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # Tableau « Suite Details » : Price/Status/Beds/Baths/Sq. Ft suites = [] table = soup.select_one("table.table-listing") if table: for tr in table.select("tbody tr"): row = {td.get("data-title", "").strip(): td.get_text(" ", strip=True) for td in tr.select("td") if td.get("data-title")} if row: suites.append(row) out["suites"] = suites # Listes structurées de la barre latérale def _ul(titre: str) -> list[str]: h = soup.find("h2", string=re.compile( rf"^\s*{titre}\s*$", re.I)) ul = h.find_next_sibling("ul") if h else None return ([li.get_text(" ", strip=True) for li in ul.select("li")] if ul else []) out["building_amenities"] = _ul("Building Amenities") out["rent_includes"] = _ul("Rent Includes") out["pet_friendly"] = _ul("Pet Friendly") out["local_amenities"] = _ul("Local Amenities") # Contact du bureau de location contact = soup.select_one(".listing-contact") if contact: ctxt = contact.get_text(" ", strip=True) pm = _PHONE_RE.search(ctxt) if pm: out["phone"] = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}" em = _EMAIL_RE.search(ctxt) if em: out["email"] = em.group(0) # Description (partie anglaise, avant l'avis de non-responsabilité) dm = re.search(r"

Description

(.*?)(?:]+>", " ", dm.group(1)) dtxt = re.sub(r"\s+", " ", dtxt).strip() dtxt = re.split(r"The safest way|Disclaimer", dtxt)[0] out["description"] = dtxt.strip()[:600] # Intersection (en-tête de fiche) txt = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) im = re.search(r"Intersection:\s*([^|]{3,60}?)\s{0,2}Suite", txt) if im: out["intersection"] = im.group(1).strip() # Photos de l'unité (carrousel span data-bg) imgs = re.findall( r'data-bg="(https://www\.metcap\.com/wp-content/uploads/' r'[^"]+\.(?:jpg|jpeg|png|webp))"', html) out["images"] = [u for u in dict.fromkeys(imgs) if not _SKIP_IMG.search(u)][: self.max_images] return out try: return self.detail(slug, card_key, _fetch) except Exception: return {} # -- construction d'une annonce -------------------------------------------- def _unit_listing(self, href: str, card_text: str, address: str, prop_name: str, prop_path: str, city: str, sector: str, lat: float | None, lng: float | None, province: str = "ON") -> Listing | None: path = href.split("?")[0] slug = path.rstrip("/").split("/")[-1] if not slug: return None url = f"{BASE}{path}?lang=en" unit_type = "" for rx, ut in _TYPE_MAP: if rx.search(card_text): unit_type = ut break price = parse_price( card_text.replace("from $", "").replace(",", "") + " $") price_label = "" pm = re.search(r'from \$[\d,.]+', card_text) if pm: price_label = pm.group(0).replace("from", "From") + " /month" # Fiche unité (cache BD, clé = contenu de la carte liste) card_key = hashlib.sha1( f"{card_text}|{address}".encode("utf-8")).hexdigest()[:16] det = self._unit_detail(slug, url, card_key) # Tableau Suite Details : statut, superficie (structurés à la source) availability = "" area_sqft: float | None = None bits: list[str] = [] suites = det.get("suites") or [] row = next((r for r in suites if (r.get("Status") or "").lower() == "available"), suites[0] if suites else None) if row: availability = row.get("Status") or "" sq = re.sub(r"[^\d.]", "", row.get("Sq. Ft") or "") try: v = float(sq) if 80 <= v <= 20000: area_sqft = v except ValueError: pass beds, baths = row.get("Beds") or "", row.get("Baths") or "" if beds or baths: bits.append(" — ".join(x for x in [ f"{beds} bed" if beds else "", f"{baths} bath" if baths else ""] if x)) if det.get("intersection") and not sector: bits.append(f"Intersection: {det['intersection']}") # Commodités brutes (immeuble + inclusions), fidèles à la source amenities = list(dict.fromkeys( (det.get("building_amenities") or []) + (det.get("rent_includes") or [])))[:25] # Inclusions structurées (« Rent Includes ») et animaux (« Pet Friendly ») details: dict = {} inclusions: dict = {} for item in det.get("rent_includes") or []: for rx, cle in _INCLUDES_MAP: if rx.search(item): inclusions[cle] = True if inclusions: details["inclusions"] = inclusions pets = None pf = " ".join(det.get("pet_friendly") or []).strip().lower() if pf.startswith("yes"): pets = "oui" elif pf.startswith("no"): pets = "non" contact = {k: det[k] for k in ("phone", "email") if det.get(k)} if contact: details["contact"] = contact # Photos : unité d'abord, sinon galerie de l'immeuble images = det.get("images") or [] if not images: images = self._gallery(prop_path) desc = det.get("description") or "" title_type = re.sub(r"\s*from \$[\d,.].*$", "", card_text).strip() title = (f"{prop_name} — {title_type}" if prop_name else f"{address} — {title_type}") if address and not re.search(rf",\s*{province}\b", address): address = f"{address}, {province}" return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, province=province, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area_sqft, pets=pets, description=" — ".join([desc] + bits if desc else bits)[:600], amenities=amenities, details=details, images=images, lat=lat, lng=lng, )