SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
11.4 KB · 259 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/starlight.py : connecteur Starlight Investments (starlightinvest.com)5#   Plus grand propriétaire résidentiel privé du Canada (~60 000 unités). Le6#   site corpo (Craft CMS) n'a pas de page /properties : le portefeuille est7#   exposé par le « Property Finder » de /real-estate/canadian-residential,8#   alimenté par un index Algolia PUBLIC (appId PX9UWFRLZ3, clé search-only9#   embarquée dans la page, index `prod_properties`, ~549 immeubles dont ~39110#   « live » en Ontario). Chaque enregistrement donne : nom d'immeuble (le plus11#   souvent l'adresse civique), ville, province et l'URL d'un MICROSITE par12#   immeuble. Aucun prix/plan d'étage dans l'index.13#   Les microsites (317 domaines ON distincts) partagent majoritairement un14#   gabarit Duda + PropertyVista, MAIS les plans d'étage y sont chargés par un15#   GraphQL (DudaWebsiteService_getFloorplanList) dont l'endpoint vit dans une16#   collection Duda `vista_api_url` inaccessible hors runtime (400/500 même en17#   navigateur headless — vérifié via Scrapfly js_scenario) : pas de pattern18#   prix exploitable. On émet donc UNE annonce PAR PROPRIÉTÉ (granularité19#   immeuble, sans prix — jamais de valeur inventée) et on enrichit chaque20#   fiche avec le HTML serveur du microsite (meta description, téléphone,21#   galerie irp.cdn-website.com filtrée par l'alias du site) via le cache BD22#   self.detail() — budget max_details par sync, le reste vient du cache.23#   Les immeubles dont le « microsite » est en réalité une page metcap.com ou24#   cogir.net sont SAUTÉS : les connecteurs dédiés `metcap` et `cogir` (tous25#   deux gatés Ontario) couvrent déjà ces gestionnaires — zéro doublon.26#   Idem sterlingkaramar.com (65 propriétés) : couvert par rsgw_sterlingkaramar.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import hashlib31import json32import os33import re34from urllib.parse import urlparse3536from ..schema import Listing37from .base import BaseConnector3839# index Algolia public du Property Finder (clé search-only embarquée dans40# https://www.starlightinvest.com/real-estate/canadian-residential)41ALGOLIA_APP = "PX9UWFRLZ3"42ALGOLIA_KEY = "5d9417503fde20d753b7a25a301f8991"43ALGOLIA_URL = (f"https://{ALGOLIA_APP.lower()}-dsn.algolia.net"44               "/1/indexes/prod_properties/query")45FINDER_PAGE = ("https://www.starlightinvest.com/real-estate/"46               "canadian-residential#property-finder")4748# Gate expansion Ontario : le connecteur reste hors registre tant que la49_ONTARIO = True  # Rent-Ka: always on (ROC scope)5051# gestionnaires déjà couverts par un connecteur Rent-Ka dédié (gaté Ontario) —52# leurs immeubles Starlight sont sautés ici pour éviter les doublons53_SKIP_DOMAINS = ("metcap.com", "cogir.net", "sterlingkaramar.com")5455# fragment d'adresse civique dans le titre (« 1637 - 1645 Bathurst Street »,56# « Yonge St Towns: 389 Yonge Street », « St Regents Place - 2375 The57# Collegeway ») — on n'utilise le titre comme adresse QUE s'il y ressemble58_ADDR_RE = re.compile(59    r"\d[\d\s&/-]*\s+[A-Za-z'’.\- ]+\b(?:Street|St\.?|Avenue|Ave\.?|Road|"60    r"Rd\.?|Drive|Dr\.?|Boulevard|Blvd\.?|Court|Crt\.?|Crescent|Cres\.?|"61    r"Lane|Ln\.?|Way|Place|Pl\.?|Terrace|Circle|Cir\.?|Parkway|Pkwy\.?|"62    r"Gate|Square|Sq\.?|Trail|Line|Highway|Hwy\.?|Gardens|Grove|Path|"63    r"Promenade|Quay|Row|Walk|Heights|Collegeway|Queensway|Kingsway|"64    r"Esplanade|Appleby|Mall)\b[.A-Za-z'’\- ]*", re.I)6566# visuels du microsite Duda à ÉCARTER (logos gestionnaire, drapeaux du67# sélecteur de langue, pictos, bandeau-titre « just address » du gabarit)68_IMG_SKIP_RE = re.compile(69    r"logo|flag|icon|favicon|\+100px|badge|certif|just\+address|\+font\+",70    re.I)71_META_DESC_RE = re.compile(72    r'<meta\s+(?:name|property)="(?:og:)?description"\s+content="([^"]*)"',73    re.I)74_SITE_ALIAS_RE = re.compile(r"siteAlias\s*=\s*'([0-9a-f]{6,10})'")75_TEL_RE = re.compile(r'href="tel:([\d\s().+-]{7,20})"')76_DUDA_IMG_RE = re.compile(77    r"https://irp\.cdn-website\.com/[^\"'\\\s)]+\.(?:jpe?g|png|webp)", re.I)787980class StarlightConnector(BaseConnector):81    source_id = "starlight"82    request_delay = 1.0        # microsites variés : rester poli partout83    disabled = False84    max_properties = 450       # garde-fou (~391 propriétés ON « live »)85    max_details = 10           # vraies visites de microsites par sync (le86    #                            reste est servi par le cache BD self.detail)87    max_images = 128889    def __init__(self) -> None:90        super().__init__()91        self._detail_fetches = 09293    # -- portefeuille : index Algolia public du Property Finder -----------------94    def _properties(self) -> list[dict]:95        headers = {96            "X-Algolia-Application-Id": ALGOLIA_APP,97            "X-Algolia-API-Key": ALGOLIA_KEY,98            "Content-Type": "application/json",99        }100        hits: list[dict] = []101        page = 0102        while True:103            resp = self.post(ALGOLIA_URL, headers=headers, data=json.dumps({104                "query": "",105                "page": page,106                "hitsPerPage": 1000,107                "attributesToRetrieve": ["title", "website", "address",108                                         "status", "section"],109                "attributesToHighlight": [],110            }))111            data = resp.json()112            hits.extend(data.get("hits") or [])113            page += 1114            if page >= int(data.get("nbPages") or 1) or page > 5:115                break116        if not hits:117            raise RuntimeError("Starlight : index Algolia prod_properties "118                               "vide (clé/app modifiée ?)")119        return hits120121    def fetch(self) -> list[Listing]:122        listings: list[Listing] = []123        count = 0124        seen: set[tuple[str, str]] = set()125        for p in self._properties():126            if (p.get("status") or "") != "live":127                continue128            addr = p.get("address") or {}129            if ((addr.get("state") or "").strip().upper()) != "ON":130                continue        # portefeuille national : Ontario seulement ici131            website = (p.get("website") or "").strip()132            if not website:133                continue134            host = (urlparse(website).hostname or "").lower()135            if any(host == d or host.endswith("." + d)136                   for d in _SKIP_DOMAINS):137                continue        # gestionnaire déjà couvert (metcap/cogir)138            # l'index contient des variantes du même immeuble (« 1637 - 1645 »139            # vs « 1637-1645 Bathurst ») : dédoublonnage titre+microsite140            key = (host, re.sub(r"\s*-\s*", "-",141                                re.sub(r"\s+", " ",142                                       (p.get("title") or "").strip().lower())))143            if key in seen:144                continue145            seen.add(key)146            if count >= self.max_properties:147                break148            count += 1149            try:150                lst = self._listing(p, addr, website)151                if lst is not None:152                    listings.append(lst)153            except Exception:154                continue155        return listings156157    # -- une annonce par propriété (granularité immeuble, sans prix) ------------158    def _listing(self, p: dict, addr: dict, website: str) -> Listing | None:159        title = re.sub(r"\s+", " ", (p.get("title") or "")).strip()160        city = (addr.get("city") or "").strip()161        if not title or not city:162            return None163164        # adresse civique : champ street1 quand il existe (rare), sinon le165        # titre s'il ressemble à une adresse — sinon rien (pas d'invention)166        street = (addr.get("street1") or "").strip()167        if not street:168            m = _ADDR_RE.search(title)169            if m:170                street = m.group(0).strip()171        full_addr = ""172        if street:173            full_addr = f"{street}, {city}, ON"174            postal = (addr.get("zip") or "").strip()175            if postal:176                full_addr += f" {postal}"177178        # coordonnées GPS de l'index (présentes sur ~5 % des fiches ;179        # finalize() valide la bbox Ontario)180        try:181            lat = float(addr.get("lat")) or None182            lng = float(addr.get("lng")) or None183        except (TypeError, ValueError):184            lat = lng = None185186        # fiche microsite (description, téléphone, galerie) — via le cache BD :187        # revisitée seulement si la ligne de l'index change188        ext_id = str(p.get("objectID") or "").strip()189        feed_key = hashlib.sha1("|".join(str(x) for x in (190            title, website, city, street, addr.get("zip"), lat, lng,191        )).encode("utf-8")).hexdigest()192        d: dict = {}193        try:194            d = self.detail(ext_id, feed_key,195                            lambda: self._fetch_detail(website))196        except Exception:197            pass    # budget détail épuisé ou microsite injoignable198199        details: dict = {"property_manager_site": website}200        phone = (d.get("phone") or "").strip()201        if phone:202            details["contact"] = {"phone": phone}203204        return Listing(205            source=self.source_id,206            external_id=ext_id,207            url=website,208            title=title,209            address=full_addr,210            city=city,211            province="ON",212            description=d.get("description") or "",213            images=list(d.get("images") or [])[: self.max_images],214            details=details,215            lat=lat,216            lng=lng,217        )218219    # -- microsite : meta description + téléphone + galerie (HTML serveur) ------220    def _fetch_detail(self, url: str) -> dict:221        """Extrait les champs sûrs du HTML serveur du microsite Duda.222223        Les prix/plans d'étage sont volontairement ignorés : ils sont chargés224        côté client par un GraphQL PropertyVista inaccessible (voir en-tête).225        """226        if self._detail_fetches >= self.max_details:227            raise RuntimeError("budget de microsites atteint")   # non mis en cache228        self._detail_fetches += 1229        out: dict = {"description": "", "phone": "", "images": []}230        try:231            html = self.get(url).text232        except Exception:233            return out          # microsite mort/parqué : cache vide, on n'y234        #                         retournera que si la ligne de l'index change235236        m = _META_DESC_RE.search(html)237        if m:238            import html as _h239            out["description"] = _h.unescape(m.group(1)).strip()[:900]240241        m = _TEL_RE.search(html)242        if m:243            out["phone"] = re.sub(r"\s+", " ", m.group(1)).strip()244245        # galerie : uniquement les visuels du site lui-même (chemin contenant246        # l'alias Duda du microsite), hors logos/drapeaux/pictos247        alias_m = _SITE_ALIAS_RE.search(html)248        alias = alias_m.group(1) if alias_m else None249        images: list[str] = []250        for src in _DUDA_IMG_RE.findall(html):251            if alias and f"/{alias}/" not in src:252                continue        # visuel partagé du gabarit (logo gestionnaire…)253            if _IMG_SKIP_RE.search(src):254                continue255            if src not in images:256                images.append(src)257        out["images"] = images[: self.max_images]258        return out259