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%
15.4 KB · 366 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/riocan.py : connecteur RioCan Living (riocanliving.com)5#   Bras résidentiel du REIT RioCan — tours locatives neuves au-dessus de ses6#   centres commerciaux (eCentral/ePlace et Pivot à Toronto, Rhythm à Ottawa,7#   Bridge à Leaside…). Le site corporatif est un WordPress SANS anti-bot :8#   CPT `property` exposé par l'API REST (`/wp-json/wp/v2/property?per_page=9#   100`) — on garde les billets EN (« /property/ » dans le lien, les billets10#   FR doublonnent sous « /fr/propriete/ »), de type « rental » et localisés11#   en Ontario (property-location-toronto-gta / -ottawa ; fourth-street-lofts12#   est à CALGARY -> exclu). La fiche riocanliving fournit adresse civique,13#   description, visuels app/uploads et le CTA « Website » vers le MICROSITE14#   de la tour — chaque tour a le sien, d'architecture différente :15#     - eCentral : blob JS `floorPlanData` (RentCafe embarqué : nom,16#       BedroomNum, BathNum, Size, MinimumRent, FloorplanId, avaliableDate)17#     - Pivot (livingatpivot.com, Cloudflare 403 -> la chaîne résiliente de18#       self.get escalade d'elle-même vers Scrapfly ASP) : cartes RentCafe19#       `.fp-container` (« N Bed / N Bath / N Sq. Ft. », « Starting at $X »,20#       « Available from JJ/MM/AAAA »)21#     - Bridge : cartes par CATÉGORIE (« Studio … Starting from $2,095* ») —22#       une annonce par catégorie de chambres, prix plancher affiché23#     - Rhythm : aucun prix publié -> repli « une annonce par propriété »24#   Fiche riocanliving + découverte du microsite via le cache BD25#   self.detail() (clé = date `modified` du flux WP) ; la page « suites » du26#   microsite est relue EN DIRECT à chaque synchronisation (donnée vivante).27#   Une annonce par plan d'étage TARIFÉ ; sinon par catégorie tarifée ; sinon28#   repli par propriété SANS prix (rien d'inventé).29#30#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import os35import re36from urllib.parse import urljoin3738from bs4 import BeautifulSoup3940from ..schema import Listing, normalize_unit_type, parse_price41from .base import BaseConnector4243BASE = "https://riocanliving.com"44FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100"4546# Gate expansion Ontario : le connecteur reste hors registre tant que la47_ONTARIO = True  # Rent-Ka: always on (ROC scope)4849# localisations ontariennes du CPT (class_list property-location-<slug>)50_ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"}5152# adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 »)53_ADDR_RE = re.compile(54    r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*"55    r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")56# blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON —57# floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …};58_FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};")59# cartes par catégorie (Bridge) : « Studio Starting from $2,095* » —60# la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux)61_CAT_RE = re.compile(62    r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?"63    r"Starting\s+from\s+\$\s*([\d,]+)", re.I)64_BED_RE = re.compile(r"(\d+)\s*Bed", re.I)65_BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I)66_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)67# lien « suites/plans » du microsite (découverte sur la page d'accueil)68_SUITES_HREF_RE = re.compile(69    r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I)707172class RioCanConnector(BaseConnector):73    source_id = "riocan"74    request_delay = 1.2       # microsites variés, dont un Cloudflare (Pivot)75    disabled = False76    max_properties = 10       # garde-fou (4 tours ON aujourd'hui)77    max_images = 157879    def fetch(self) -> list[Listing]:80        props = self.get(FEED_URL,81                         headers={"Accept": "application/json"}).json()8283        listings: list[Listing] = []84        count = 085        for p in props:86            try:87                link = p.get("link") or ""88                # billets EN seulement (les FR doublonnent /fr/propriete/)89                if "/property/" not in link:90                    continue91                cls = set(p.get("class_list") or [])92                if "property-type-rental" not in cls:93                    continue94                # Ontario seulement (fourth-street-lofts = Calgary -> exclu)95                city = ""96                for slug, label in _ON_LOCATIONS.items():97                    if f"property-location-{slug}" in cls:98                        city = label99                        break100                if not city:101                    continue102                if count >= self.max_properties:103                    break104                count += 1105                listings.extend(self._property_listings(p, city))106            except Exception:107                continue108        return listings109110    # -- annonces d'une tour (une par plan/catégorie tarifé, repli) -------------111    def _property_listings(self, p: dict, city: str) -> list[Listing]:112        pid = str(p.get("id"))113        link = p.get("link") or ""114        title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",115                              "html.parser").get_text(" ", strip=True)116117        # fiche riocanliving + découverte du microsite via le cache BD :118        # revisitée seulement quand le billet WordPress est modifié119        feed_key = str(p.get("modified") or p.get("modified_gmt") or "")120        d = self.detail(pid, feed_key, lambda: self._fetch_detail(link))121122        url = d.get("microsite") or link123        common = dict(124            source=self.source_id, url=url,125            address=d.get("address") or "", city=city, province="ON",126            description=d.get("description") or "",127            images=(d.get("images") or [])[: self.max_images],128        )129130        # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante)131        page = ""132        suites_url = d.get("suites_url") or ""133        if suites_url:134            try:135                page = self.get(suites_url).text136            except Exception:137                page = ""138139        out = self._parse_floorplandata(page, pid, title, common)140        if not out:141            out = self._parse_fp_containers(page, pid, title, common)142        if not out:143            out = self._parse_categories(page, pid, title, common)144        if out:145            return out146147        # repli : une annonce par tour — aucun prix inventé (Rhythm…)148        return [Listing(149            external_id=pid,150            title=title,151            unit_type="",152            **common,153        )]154155    # -- parseur 1 : blob JS floorPlanData (eCentral) ----------------------------156    @staticmethod157    def _js_field(body: str, *keys: str) -> str:158        """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`)."""159        for k in keys:160            m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body)161            if m:162                return m.group(1).replace("\\'", "'").strip()163        return ""164165    def _parse_floorplandata(self, page: str, pid: str, title: str,166                             common: dict) -> list[Listing]:167        out: list[Listing] = []168        seen: set[str] = set()169        for m in _FPDATA_RE.finditer(page or ""):170            body = m.group(1)171            name = self._js_field(body, "name", "Name")172            fpid = self._js_field(body, "FloorplanId", "floorplanId", "id")173            key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")174            if not key or key in seen:175                continue176            seen.add(key)177            # loyer plancher affiché seulement (0/absent = pas de prix)178            price = None179            try:180                v = float(self._js_field(body, "MinimumRent")181                          .replace(",", "") or 0)182                if v > 0:183                    price = v184            except ValueError:185                pass186            if price is None:187                continue188            beds = baths = None189            try:190                beds = float(self._js_field(body, "BedroomNum"))191            except ValueError:192                pass193            try:194                baths = float(self._js_field(body, "BathNum", "Bath"))195            except ValueError:196                pass197            sqft = None198            msq = re.search(r"[\d,]+", self._js_field(body, "Size",199                                                      "DisplaySize"))200            if msq:201                try:202                    v = float(msq.group(0).replace(",", ""))203                    if 80 <= v <= 20000:204                        sqft = v205                except ValueError:206                    pass207            avail = self._js_field(body, "avaliableDate", "AvailableDate")208            out.append(Listing(209                external_id=f"{pid}-{key}",210                title=f"{title} — {name}" if name else title,211                unit_type=("Studio" if beds == 0 else normalize_unit_type(212                    f"{int(beds)} chambres") if beds is not None else ""),213                bedrooms=beds,214                bathrooms=baths,215                price=price,216                price_label=f"À partir de {price:.0f} $ /mois",217                availability=avail,218                area_sqft=sqft,219                **common,220            ))221        return out222223    # -- parseur 2 : cartes RentCafe .fp-container (Pivot) -----------------------224    def _parse_fp_containers(self, page: str, pid: str, title: str,225                             common: dict) -> list[Listing]:226        if not page or "fp-container" not in page:227            return []228        soup = BeautifulSoup(page, "html.parser")229        out: list[Listing] = []230        seen: set[str] = set()231        for card in soup.select("[id^=fp-container-]"):232            key = (card.get("id") or "").replace("fp-container-", "").strip()233            h = card.select_one(".card-title") or card.find(["h2", "h3"])234            name = h.get_text(" ", strip=True) if h else ""235            if not key:236                key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")237            if not key or key in seen:238                continue239            seen.add(key)240            txt = card.get_text(" ", strip=True)241            # prix plancher affiché seulement (« Starting at $3,204.00 »)242            mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I)243            price = parse_price(mprice.group(1)) if mprice else None244            if price is None:245                continue246            beds = None247            if re.search(r"\bStudio|\bBachelor", txt, re.I):248                beds = 0.0249            else:250                mb = _BED_RE.search(txt)251                if mb:252                    beds = float(mb.group(1))253            mba = _BATH_RE.search(txt)254            msq = _SQFT_RE.search(txt)255            sqft = float(msq.group(1).replace(",", "")) if msq else None256            mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I)257            out.append(Listing(258                external_id=f"{pid}-{key}",259                title=f"{title} — {name}" if name else title,260                unit_type=("Studio" if beds == 0 else normalize_unit_type(261                    f"{int(beds)} chambres") if beds is not None else ""),262                bedrooms=beds,263                bathrooms=float(mba.group(1)) if mba else None,264                price=price,265                price_label=f"À partir de {price:.0f} $ /mois",266                availability=f"Disponible le {mav.group(1)}" if mav else "",267                area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None,268                **common,269            ))270        return out271272    # -- parseur 3 : cartes par catégorie (Bridge) --------------------------------273    def _parse_categories(self, page: str, pid: str, title: str,274                          common: dict) -> list[Listing]:275        if not page:276            return []277        txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True)278        out: list[Listing] = []279        seen: set[str] = set()280        for m in _CAT_RE.finditer(txt):281            label = m.group(1).strip()282            beds = 0.0 if m.group(2) is None else float(m.group(2))283            key = "studio" if beds == 0 else f"{int(beds)}-bed"284            if key in seen:285                continue286            seen.add(key)287            try:288                price = float(m.group(3).replace(",", ""))289            except ValueError:290                continue291            if price <= 0:292                continue293            out.append(Listing(294                external_id=f"{pid}-cat-{key}",295                title=f"{title} — {label}",296                unit_type=("Studio" if beds == 0297                           else normalize_unit_type(f"{int(beds)} chambres")),298                bedrooms=beds,299                price=price,300                price_label=f"À partir de {price:.0f} $ /mois",301                **common,302            ))303        return out304305    # -- fiche riocanliving : adresse, description, visuels, microsite -----------306    def _fetch_detail(self, link: str) -> dict:307        out: dict = {"address": "", "description": "", "images": [],308                     "microsite": "", "suites_url": ""}309        if not link:310            return out311        try:312            page = self.get(link).text313        except Exception:314            return out315        soup = BeautifulSoup(page, "html.parser")316317        m = _ADDR_RE.search(soup.get_text(" ", strip=True))318        if m:319            out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()320321        # description : premiers paragraphes substantiels de la fiche322        paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")]323        out["description"] = " ".join(324            t for t in paras if len(t) > 60)[:600]325326        # visuels du site corporatif (app/uploads) — hors logos/icônes327        images: list[str] = []328        for img in soup.find_all("img"):329            src = (img.get("src") or "").strip()330            if "/app/uploads/" not in src or src.lower().endswith(".svg"):331                continue332            if re.search(r"logo|icon", src, re.I):333                continue334            if src not in images:335                images.append(src)336        out["images"] = images[: self.max_images]337338        # CTA « Website » -> microsite de la tour339        micro = ""340        for a in soup.find_all("a", href=True):341            if a.get_text(" ", strip=True).lower() in (342                    "website", "visit website"):343                micro = a["href"].strip()344                break345        out["microsite"] = micro346        if not micro:347            return out348349        # découverte de la page « suites/plans » sur l'accueil du microsite350        # (la chaîne résiliente escalade seule le Cloudflare de Pivot)351        try:352            home = self.get(micro).text353        except Exception:354            return out355        hsoup = BeautifulSoup(home, "html.parser")356        for a in hsoup.find_all("a", href=True):357            href = a["href"].strip()358            if _SUITES_HREF_RE.search(href.split("?")[0]):359                out["suites_url"] = urljoin(micro, href)360                break361        # certains microsites affichent les plans sur l'accueil même362        if not out["suites_url"] and (363                "floorPlanData" in home or "fp-container" in home):364            out["suites_url"] = micro365        return out366