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/boardwalk.py : Boardwalk REIT (bwalk.com)5# Server-rendered city pages (HubSpot CMS). The parser reads the fr-ca6# pages because their labels are stable and identical across every city7# («À partir de … $», «Superficie (pi ca)», «c. à c.», «sdb», «liste8# d'attente») — the label language does not matter, finalize() normalizes9# everything to the English canon. Property pages carry suite-type cards10# (price, sqft, beds/baths, status) plus the full address and phone in the11# header (p.address-and-phone). Suite pages are only visited for their12# specific photos, through the self.detail() cache.13# Pan-Canadian REIT — Rent-Ka covers the whole portfolio outside Québec14# (verified live 2026-08-27): AB (Calgary 48, Edmonton 73, Red Deer 9,15# Grande Prairie 11, Fort McMurray 9, Banff 2, Airdrie 1, Spruce Grove 1),16# SK (Saskatoon 16, Regina 16), ON (London 14, Kitchener 4), BC (Victoria 3).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import os22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://www.bwalk.com"3031# City pages (fr-ca slugs verified live) -> (city, province). The sector32# comes from the 2nd URL segment of each property33# (e.g. /…-a-london/carling/<property>).34CITY_PAGES = {35 f"{BASE}/fr-ca/appartements-a-louer-a-calgary": ("Calgary", "AB"),36 f"{BASE}/fr-ca/appartements-a-louer-a-edmonton": ("Edmonton", "AB"),37 f"{BASE}/fr-ca/appartements-a-louer-a-red-deer": ("Red Deer", "AB"),38 f"{BASE}/fr-ca/appartements-a-louer-a-grande-prairie": ("Grande Prairie", "AB"),39 f"{BASE}/fr-ca/appartements-a-louer-a-fort-mcmurray": ("Fort McMurray", "AB"),40 f"{BASE}/fr-ca/appartements-a-louer-a-banff": ("Banff", "AB"),41 f"{BASE}/fr-ca/appartements-a-louer-a-airdrie": ("Airdrie", "AB"),42 f"{BASE}/fr-ca/appartements-a-louer-a-spruce-grove": ("Spruce Grove", "AB"),43 f"{BASE}/fr-ca/appartements-a-louer-a-saskatoon": ("Saskatoon", "SK"),44 f"{BASE}/fr-ca/appartements-a-louer-a-regina": ("Regina", "SK"),45 f"{BASE}/fr-ca/appartements-a-louer-a-london": ("London", "ON"),46 f"{BASE}/fr-ca/appartements-a-louer-a-kitchener": ("Kitchener", "ON"),47 f"{BASE}/fr-ca/appartements-a-louer-a-victoria": ("Victoria", "BC"),48}4950_POSTAL_RE = re.compile(r",\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*$")515253def _addr_prov(address: str, prov: str) -> str:54 """Suffix the province code to the address (before a trailing postal code)."""55 if not address or re.search(rf"\b{prov}\b", address):56 return address57 m = _POSTAL_RE.search(address)58 if m:59 return f"{address[: m.start()].rstrip(', ')}, {prov}, {m.group(1)}"60 return f"{address}, {prov}"61PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)')62# superficie : « 900 », « 648 - 760 », « 1 408 » (séparateur de milliers) —63# le nombre de chambres qui suit (« 3 c. à c. ») ne doit pas être aspiré64AREA_RE = re.compile(65 r'Superficie\s*\(pi\s*ca\)\s*:?\s*([\d\s\u00a0-]+?)'66 r'\s*(?:\d+\s*c\.\s*à\s*c\.|\||sdb|[A-Za-z]|$)')67BEDS_RE = re.compile(r'(\d+)\s*c\.\s*à\s*c\.')68BATHS_RE = re.compile(r'(\d+)\s*sdb')69PHONE_RE = re.compile(r'\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})')707172_BED_TYPES = {1: "1 bedroom", 2: "2 bedrooms", 3: "3 bedrooms", 4: "4 bedrooms"}737475def _suite_type(name: str) -> str:76 """«3 1/2 Pièces» -> 1 bedroom ; «Maison en Rangée…» -> Townhouse ;77 «2 Chambres» -> 2 bedrooms."""78 low = name.lower()79 if "maison" in low or "rang" in low or "townhouse" in low:80 return "Townhouse"81 m = re.search(r"(\d)\s*chambre", low)82 if m:83 return _BED_TYPES.get(int(m.group(1)), "")84 return normalize_unit_type(name)858687class BoardwalkConnector(BaseConnector):88 source_id = "boardwalk"89 request_delay = 0.690 max_per_city = 80 # per-city safety cap (Edmonton has 73 properties)91 max_images = 259293 def fetch(self) -> list[Listing]:94 listings: list[Listing] = []95 seen: set[str] = set()96 for city_url, (city, prov) in CITY_PAGES.items():97 try:98 html = self.get(city_url).text99 except Exception:100 continue101 prop_re = re.compile(102 r'href="(' + re.escape(city_url[len(BASE):]) +103 r'/[a-z0-9\-]+/[a-z0-9\-]+)"')104 count = 0105 for path in dict.fromkeys(prop_re.findall(html)):106 if path in seen:107 continue108 seen.add(path)109 if count >= self.max_per_city:110 break111 count += 1112 # sector derived from the URL (e.g. white-oaks -> White Oaks)113 sector = (path.rstrip("/").split("/")[-2]114 .replace("-", " ").title())115 try:116 listings.extend(self._property_listings(117 path, city, sector, province=prov))118 except Exception:119 continue120121 # Unicité des external_id (deux cartes peuvent pointer vers la même122 # page de type de suite avec des prix différents)123 used: dict[str, int] = {}124 for lst in listings:125 n = used.get(lst.external_id, 0)126 used[lst.external_id] = n + 1127 if n:128 lst.external_id = f"{lst.external_id}-{n + 1}"129 return listings130131 def _property_listings(self, path: str, city: str,132 default_sector: str,133 province: str = "ON") -> list[Listing]:134 url = BASE + path135 prop_slug = path.rstrip("/").split("/")[-1]136 html = self.get(url).text137 soup = BeautifulSoup(html, "html.parser")138139 t = soup.find("title")140 name = (t.get_text(strip=True).split("|")[0].strip()141 if t else prop_slug.replace("-", " "))142143 # Adresse complète + téléphone : en-tête <p class="address-and-phone">144 address = ""145 phone = ""146 ap = soup.select_one("p.address-and-phone")147 if ap:148 ap_text = re.sub(r"\s+", " ", ap.get_text(" ", strip=True))149 pm_ = PHONE_RE.search(ap_text)150 if pm_:151 phone = "-".join(pm_.groups())152 ap_text = ap_text[: pm_.start()].strip(" ,")153 address = ap_text154 if not address:155 # repli : texte alt des photos « photo de la propriété pour le … »156 am = re.search(157 r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html)158 if am:159 address = am.group(1).strip()160 address = _addr_prov(address, province)161162 # Galerie photo (swiper)163 images: list[str] = []164 for img in soup.select("img[src]"):165 src = img.get("src", "")166 if re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",167 src) and src.startswith("http"):168 if src not in images:169 images.append(src)170 images = images[: self.max_images]171172 # Description de la propriété173 desc = ""174 dh = soup.find(string=re.compile("Description de la propriété"))175 if dh:176 sec = dh.find_parent()177 nxt = sec.find_next("p") if sec else None178 if nxt:179 desc = nxt.get_text(" ", strip=True)[:600]180 if not desc:181 og = soup.find("meta", attrs={"property": "og:description"})182 if og and og.get("content"):183 desc = og["content"].strip()[:600]184185 # Commodités (« Caractéristiques et espaces d'agrément »)186 amenities: list[str] = []187 ah = soup.find(string=re.compile("Caractéristiques et espaces"))188 if ah:189 cont = ah.find_parent()190 for _ in range(3):191 if cont is None:192 break193 lis = cont.find_all("li")194 if lis:195 break196 cont = cont.parent197 if cont:198 for li in cont.find_all("li")[:25]:199 txt = li.get_text(" ", strip=True)200 if txt and len(txt) < 50 and txt not in amenities:201 amenities.append(txt)202203 # Cartes de types de suites : <h4 class="highlight-suite"><a href=...>204 # La carte porte prix, superficie (pi ca), c. à c. et sdb ; le groupe205 # parent (div.unit_type) porte le statut (« N logements disponibles »206 # ou « liste d'attente »). Plus besoin de visiter la fiche de suite207 # pour ces champs — elle ne sert qu'aux photos (via self.detail).208 listings: list[Listing] = []209 for h4 in soup.select("h4.highlight-suite"):210 try:211 a = h4.find("a")212 if not a:213 continue214 suite_name = a.get_text(" ", strip=True)215 suite_href = a.get("href") or ""216 suite_url = (BASE + suite_href217 if suite_href.startswith("/") else suite_href)218 card = h4.parent219 card_text = re.sub(r"\s+", " ", card.get_text(" ", strip=True)220 if card else "")221 price_label = ""222 pm = PRICE_RE.search(card_text)223 if pm:224 price_label = "À partir de " + pm.group(1).strip()225 price = parse_price(price_label)226227 # Superficie / chambres / sdb depuis la carte elle-même228 detail_bits: list[str] = []229 suite_amenities = amenities230 am_ = AREA_RE.search(card_text)231 if am_:232 rng = re.sub(r"[\s ]+", " ", am_.group(1)).strip()233 detail_bits.append(f"Superficie (pi ca) : {rng}")234 lo = re.sub(r"[\s\u00a0]", "", rng.split("-")[0])235 if lo.isdigit():236 # borne basse -> texte brut, finalize() dérive area_sqft237 suite_amenities = amenities + [f"{lo} pi ca"]238 bm = BEDS_RE.search(card_text)239 if bm:240 detail_bits.append(f"{bm.group(1)} c. à c.")241 sm = BATHS_RE.search(card_text)242 if sm:243 detail_bits.append(f"{sm.group(1)} sdb")244245 # Statut du groupe : disponible ou liste d'attente246 availability = "Disponible"247 if re.search(r"liste d.attente", card_text, re.I):248 availability = "Sur la liste d'attente"249 else:250 group = card.find_parent(class_="unit_type") if card else None251 if group:252 gm = re.search(r"(\d+)\s*logements?\s*disponibles?",253 group.get_text(" ", strip=True), re.I)254 if gm:255 availability = f"{gm.group(1)} logement(s) disponible(s)"256257 # Photos spécifiques de la suite (page détail, cache BD)258 suite_imgs: list[str] = []259 if suite_url.startswith(BASE) and self.use_detail_cache:260 key = hashlib.sha1(261 (suite_url + "|" + card_text).encode("utf-8")).hexdigest()262 payload = self.detail(263 f"{prop_slug}-{suite_href.rstrip('/').split('/')[-1]}",264 key, lambda u=suite_url: self._suite_images(u))265 suite_imgs = payload.get("images") or []266267 type_slug = (suite_href.rstrip("/").split("/")[-1]268 or re.sub(r"[^a-z0-9]+", "-",269 suite_name.lower()).strip("-"))270 details = {"contact": {"phone": phone}} if phone else {}271 listings.append(Listing(272 source=self.source_id,273 external_id=f"{prop_slug}-{type_slug}",274 url=suite_url or url,275 title=f"{name} — {suite_name}",276 address=address,277 sector=default_sector,278 city=city,279 province=province,280 unit_type=_suite_type(suite_name),281 price=price,282 price_label=price_label,283 availability=availability,284 description=" — ".join(285 x for x in [desc] + detail_bits if x)[:600],286 amenities=suite_amenities,287 details=details,288 images=(suite_imgs or images)[: self.max_images],289 ))290 except Exception:291 continue292 return listings293294 def _suite_images(self, suite_url: str) -> dict:295 """Photos propres à un type de suite (page détail de la suite)."""296 try:297 sh = self.get(suite_url).text298 except Exception:299 return {}300 imgs: list[str] = []301 ssoup = BeautifulSoup(sh, "html.parser")302 for img in ssoup.select("img[src]"):303 src = img.get("src", "")304 if (re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",305 src) and src.startswith("http")306 and src not in imgs):307 imgs.append(src)308 return {"images": imgs[: self.max_images]}309