SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
15.6 KB · 368 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : 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#   Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le31#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).32# -----------------------------------------------------------------------------33from __future__ import annotations3435import os36import re37from urllib.parse import urljoin3839from bs4 import BeautifulSoup4041from ..schema import Listing, normalize_unit_type, parse_price42from .base import BaseConnector4344BASE = "https://riocanliving.com"45FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100"4647# Gate expansion Ontario : le connecteur reste hors registre tant que la48# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.49_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"5051# localisations ontariennes du CPT (class_list property-location-<slug>)52_ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"}5354# adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 »)55_ADDR_RE = re.compile(56    r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*"57    r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")58# blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON —59# floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …};60_FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};")61# cartes par catégorie (Bridge) : « Studio Starting from $2,095* » —62# la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux)63_CAT_RE = re.compile(64    r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?"65    r"Starting\s+from\s+\$\s*([\d,]+)", re.I)66_BED_RE = re.compile(r"(\d+)\s*Bed", re.I)67_BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I)68_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)69# lien « suites/plans » du microsite (découverte sur la page d'accueil)70_SUITES_HREF_RE = re.compile(71    r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I)727374class RioCanConnector(BaseConnector):75    source_id = "riocan"76    request_delay = 1.2       # microsites variés, dont un Cloudflare (Pivot)77    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)78    max_properties = 10       # garde-fou (4 tours ON aujourd'hui)79    max_images = 158081    def fetch(self) -> list[Listing]:82        props = self.get(FEED_URL,83                         headers={"Accept": "application/json"}).json()8485        listings: list[Listing] = []86        count = 087        for p in props:88            try:89                link = p.get("link") or ""90                # billets EN seulement (les FR doublonnent /fr/propriete/)91                if "/property/" not in link:92                    continue93                cls = set(p.get("class_list") or [])94                if "property-type-rental" not in cls:95                    continue96                # Ontario seulement (fourth-street-lofts = Calgary -> exclu)97                city = ""98                for slug, label in _ON_LOCATIONS.items():99                    if f"property-location-{slug}" in cls:100                        city = label101                        break102                if not city:103                    continue104                if count >= self.max_properties:105                    break106                count += 1107                listings.extend(self._property_listings(p, city))108            except Exception:109                continue110        return listings111112    # -- annonces d'une tour (une par plan/catégorie tarifé, repli) -------------113    def _property_listings(self, p: dict, city: str) -> list[Listing]:114        pid = str(p.get("id"))115        link = p.get("link") or ""116        title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",117                              "html.parser").get_text(" ", strip=True)118119        # fiche riocanliving + découverte du microsite via le cache BD :120        # revisitée seulement quand le billet WordPress est modifié121        feed_key = str(p.get("modified") or p.get("modified_gmt") or "")122        d = self.detail(pid, feed_key, lambda: self._fetch_detail(link))123124        url = d.get("microsite") or link125        common = dict(126            source=self.source_id, url=url,127            address=d.get("address") or "", city=city, province="ON",128            description=d.get("description") or "",129            images=(d.get("images") or [])[: self.max_images],130        )131132        # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante)133        page = ""134        suites_url = d.get("suites_url") or ""135        if suites_url:136            try:137                page = self.get(suites_url).text138            except Exception:139                page = ""140141        out = self._parse_floorplandata(page, pid, title, common)142        if not out:143            out = self._parse_fp_containers(page, pid, title, common)144        if not out:145            out = self._parse_categories(page, pid, title, common)146        if out:147            return out148149        # repli : une annonce par tour — aucun prix inventé (Rhythm…)150        return [Listing(151            external_id=pid,152            title=title,153            unit_type="",154            **common,155        )]156157    # -- parseur 1 : blob JS floorPlanData (eCentral) ----------------------------158    @staticmethod159    def _js_field(body: str, *keys: str) -> str:160        """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`)."""161        for k in keys:162            m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body)163            if m:164                return m.group(1).replace("\\'", "'").strip()165        return ""166167    def _parse_floorplandata(self, page: str, pid: str, title: str,168                             common: dict) -> list[Listing]:169        out: list[Listing] = []170        seen: set[str] = set()171        for m in _FPDATA_RE.finditer(page or ""):172            body = m.group(1)173            name = self._js_field(body, "name", "Name")174            fpid = self._js_field(body, "FloorplanId", "floorplanId", "id")175            key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")176            if not key or key in seen:177                continue178            seen.add(key)179            # loyer plancher affiché seulement (0/absent = pas de prix)180            price = None181            try:182                v = float(self._js_field(body, "MinimumRent")183                          .replace(",", "") or 0)184                if v > 0:185                    price = v186            except ValueError:187                pass188            if price is None:189                continue190            beds = baths = None191            try:192                beds = float(self._js_field(body, "BedroomNum"))193            except ValueError:194                pass195            try:196                baths = float(self._js_field(body, "BathNum", "Bath"))197            except ValueError:198                pass199            sqft = None200            msq = re.search(r"[\d,]+", self._js_field(body, "Size",201                                                      "DisplaySize"))202            if msq:203                try:204                    v = float(msq.group(0).replace(",", ""))205                    if 80 <= v <= 20000:206                        sqft = v207                except ValueError:208                    pass209            avail = self._js_field(body, "avaliableDate", "AvailableDate")210            out.append(Listing(211                external_id=f"{pid}-{key}",212                title=f"{title} — {name}" if name else title,213                unit_type=("Studio" if beds == 0 else normalize_unit_type(214                    f"{int(beds)} chambres") if beds is not None else ""),215                bedrooms=beds,216                bathrooms=baths,217                price=price,218                price_label=f"À partir de {price:.0f} $ /mois",219                availability=avail,220                area_sqft=sqft,221                **common,222            ))223        return out224225    # -- parseur 2 : cartes RentCafe .fp-container (Pivot) -----------------------226    def _parse_fp_containers(self, page: str, pid: str, title: str,227                             common: dict) -> list[Listing]:228        if not page or "fp-container" not in page:229            return []230        soup = BeautifulSoup(page, "html.parser")231        out: list[Listing] = []232        seen: set[str] = set()233        for card in soup.select("[id^=fp-container-]"):234            key = (card.get("id") or "").replace("fp-container-", "").strip()235            h = card.select_one(".card-title") or card.find(["h2", "h3"])236            name = h.get_text(" ", strip=True) if h else ""237            if not key:238                key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")239            if not key or key in seen:240                continue241            seen.add(key)242            txt = card.get_text(" ", strip=True)243            # prix plancher affiché seulement (« Starting at $3,204.00 »)244            mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I)245            price = parse_price(mprice.group(1)) if mprice else None246            if price is None:247                continue248            beds = None249            if re.search(r"\bStudio|\bBachelor", txt, re.I):250                beds = 0.0251            else:252                mb = _BED_RE.search(txt)253                if mb:254                    beds = float(mb.group(1))255            mba = _BATH_RE.search(txt)256            msq = _SQFT_RE.search(txt)257            sqft = float(msq.group(1).replace(",", "")) if msq else None258            mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I)259            out.append(Listing(260                external_id=f"{pid}-{key}",261                title=f"{title} — {name}" if name else title,262                unit_type=("Studio" if beds == 0 else normalize_unit_type(263                    f"{int(beds)} chambres") if beds is not None else ""),264                bedrooms=beds,265                bathrooms=float(mba.group(1)) if mba else None,266                price=price,267                price_label=f"À partir de {price:.0f} $ /mois",268                availability=f"Disponible le {mav.group(1)}" if mav else "",269                area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None,270                **common,271            ))272        return out273274    # -- parseur 3 : cartes par catégorie (Bridge) --------------------------------275    def _parse_categories(self, page: str, pid: str, title: str,276                          common: dict) -> list[Listing]:277        if not page:278            return []279        txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True)280        out: list[Listing] = []281        seen: set[str] = set()282        for m in _CAT_RE.finditer(txt):283            label = m.group(1).strip()284            beds = 0.0 if m.group(2) is None else float(m.group(2))285            key = "studio" if beds == 0 else f"{int(beds)}-bed"286            if key in seen:287                continue288            seen.add(key)289            try:290                price = float(m.group(3).replace(",", ""))291            except ValueError:292                continue293            if price <= 0:294                continue295            out.append(Listing(296                external_id=f"{pid}-cat-{key}",297                title=f"{title} — {label}",298                unit_type=("Studio" if beds == 0299                           else normalize_unit_type(f"{int(beds)} chambres")),300                bedrooms=beds,301                price=price,302                price_label=f"À partir de {price:.0f} $ /mois",303                **common,304            ))305        return out306307    # -- fiche riocanliving : adresse, description, visuels, microsite -----------308    def _fetch_detail(self, link: str) -> dict:309        out: dict = {"address": "", "description": "", "images": [],310                     "microsite": "", "suites_url": ""}311        if not link:312            return out313        try:314            page = self.get(link).text315        except Exception:316            return out317        soup = BeautifulSoup(page, "html.parser")318319        m = _ADDR_RE.search(soup.get_text(" ", strip=True))320        if m:321            out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()322323        # description : premiers paragraphes substantiels de la fiche324        paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")]325        out["description"] = " ".join(326            t for t in paras if len(t) > 60)[:600]327328        # visuels du site corporatif (app/uploads) — hors logos/icônes329        images: list[str] = []330        for img in soup.find_all("img"):331            src = (img.get("src") or "").strip()332            if "/app/uploads/" not in src or src.lower().endswith(".svg"):333                continue334            if re.search(r"logo|icon", src, re.I):335                continue336            if src not in images:337                images.append(src)338        out["images"] = images[: self.max_images]339340        # CTA « Website » -> microsite de la tour341        micro = ""342        for a in soup.find_all("a", href=True):343            if a.get_text(" ", strip=True).lower() in (344                    "website", "visit website"):345                micro = a["href"].strip()346                break347        out["microsite"] = micro348        if not micro:349            return out350351        # découverte de la page « suites/plans » sur l'accueil du microsite352        # (la chaîne résiliente escalade seule le Cloudflare de Pivot)353        try:354            home = self.get(micro).text355        except Exception:356            return out357        hsoup = BeautifulSoup(home, "html.parser")358        for a in hsoup.find_all("a", href=True):359            href = a["href"].strip()360            if _SUITES_HREF_RE.search(href.split("?")[0]):361                out["suites_url"] = urljoin(micro, href)362                break363        # certains microsites affichent les plans sur l'accueil même364        if not out["suites_url"] and (365                "floorPlanData" in home or "fp-container" in home):366            out["suites_url"] = micro367        return out368