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%
13.5 KB · 309 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/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