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%
11.5 KB · 260 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/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# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée (zéro impact prod).50_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"5152# gestionnaires déjà couverts par un connecteur Lou-Ka dédié (gaté Ontario) —53# leurs immeubles Starlight sont sautés ici pour éviter les doublons54_SKIP_DOMAINS = ("metcap.com", "cogir.net", "sterlingkaramar.com")5556# fragment d'adresse civique dans le titre (« 1637 - 1645 Bathurst Street »,57# « Yonge St Towns: 389 Yonge Street », « St Regents Place - 2375 The58# Collegeway ») — on n'utilise le titre comme adresse QUE s'il y ressemble59_ADDR_RE = re.compile(60    r"\d[\d\s&/-]*\s+[A-Za-z'’.\- ]+\b(?:Street|St\.?|Avenue|Ave\.?|Road|"61    r"Rd\.?|Drive|Dr\.?|Boulevard|Blvd\.?|Court|Crt\.?|Crescent|Cres\.?|"62    r"Lane|Ln\.?|Way|Place|Pl\.?|Terrace|Circle|Cir\.?|Parkway|Pkwy\.?|"63    r"Gate|Square|Sq\.?|Trail|Line|Highway|Hwy\.?|Gardens|Grove|Path|"64    r"Promenade|Quay|Row|Walk|Heights|Collegeway|Queensway|Kingsway|"65    r"Esplanade|Appleby|Mall)\b[.A-Za-z'’\- ]*", re.I)6667# visuels du microsite Duda à ÉCARTER (logos gestionnaire, drapeaux du68# sélecteur de langue, pictos, bandeau-titre « just address » du gabarit)69_IMG_SKIP_RE = re.compile(70    r"logo|flag|icon|favicon|\+100px|badge|certif|just\+address|\+font\+",71    re.I)72_META_DESC_RE = re.compile(73    r'<meta\s+(?:name|property)="(?:og:)?description"\s+content="([^"]*)"',74    re.I)75_SITE_ALIAS_RE = re.compile(r"siteAlias\s*=\s*'([0-9a-f]{6,10})'")76_TEL_RE = re.compile(r'href="tel:([\d\s().+-]{7,20})"')77_DUDA_IMG_RE = re.compile(78    r"https://irp\.cdn-website\.com/[^\"'\\\s)]+\.(?:jpe?g|png|webp)", re.I)798081class StarlightConnector(BaseConnector):82    source_id = "starlight"83    request_delay = 1.0        # microsites variés : rester poli partout84    disabled = not _ONTARIO    # gate expansion Ontario (LOUKA_ONTARIO=1)85    max_properties = 450       # garde-fou (~391 propriétés ON « live »)86    max_details = 10           # vraies visites de microsites par sync (le87    #                            reste est servi par le cache BD self.detail)88    max_images = 128990    def __init__(self) -> None:91        super().__init__()92        self._detail_fetches = 09394    # -- portefeuille : index Algolia public du Property Finder -----------------95    def _properties(self) -> list[dict]:96        headers = {97            "X-Algolia-Application-Id": ALGOLIA_APP,98            "X-Algolia-API-Key": ALGOLIA_KEY,99            "Content-Type": "application/json",100        }101        hits: list[dict] = []102        page = 0103        while True:104            resp = self.post(ALGOLIA_URL, headers=headers, data=json.dumps({105                "query": "",106                "page": page,107                "hitsPerPage": 1000,108                "attributesToRetrieve": ["title", "website", "address",109                                         "status", "section"],110                "attributesToHighlight": [],111            }))112            data = resp.json()113            hits.extend(data.get("hits") or [])114            page += 1115            if page >= int(data.get("nbPages") or 1) or page > 5:116                break117        if not hits:118            raise RuntimeError("Starlight : index Algolia prod_properties "119                               "vide (clé/app modifiée ?)")120        return hits121122    def fetch(self) -> list[Listing]:123        listings: list[Listing] = []124        count = 0125        seen: set[tuple[str, str]] = set()126        for p in self._properties():127            if (p.get("status") or "") != "live":128                continue129            addr = p.get("address") or {}130            if ((addr.get("state") or "").strip().upper()) != "ON":131                continue        # portefeuille national : Ontario seulement ici132            website = (p.get("website") or "").strip()133            if not website:134                continue135            host = (urlparse(website).hostname or "").lower()136            if any(host == d or host.endswith("." + d)137                   for d in _SKIP_DOMAINS):138                continue        # gestionnaire déjà couvert (metcap/cogir)139            # l'index contient des variantes du même immeuble (« 1637 - 1645 »140            # vs « 1637-1645 Bathurst ») : dédoublonnage titre+microsite141            key = (host, re.sub(r"\s*-\s*", "-",142                                re.sub(r"\s+", " ",143                                       (p.get("title") or "").strip().lower())))144            if key in seen:145                continue146            seen.add(key)147            if count >= self.max_properties:148                break149            count += 1150            try:151                lst = self._listing(p, addr, website)152                if lst is not None:153                    listings.append(lst)154            except Exception:155                continue156        return listings157158    # -- une annonce par propriété (granularité immeuble, sans prix) ------------159    def _listing(self, p: dict, addr: dict, website: str) -> Listing | None:160        title = re.sub(r"\s+", " ", (p.get("title") or "")).strip()161        city = (addr.get("city") or "").strip()162        if not title or not city:163            return None164165        # adresse civique : champ street1 quand il existe (rare), sinon le166        # titre s'il ressemble à une adresse — sinon rien (pas d'invention)167        street = (addr.get("street1") or "").strip()168        if not street:169            m = _ADDR_RE.search(title)170            if m:171                street = m.group(0).strip()172        full_addr = ""173        if street:174            full_addr = f"{street}, {city}, ON"175            postal = (addr.get("zip") or "").strip()176            if postal:177                full_addr += f" {postal}"178179        # coordonnées GPS de l'index (présentes sur ~5 % des fiches ;180        # finalize() valide la bbox Ontario)181        try:182            lat = float(addr.get("lat")) or None183            lng = float(addr.get("lng")) or None184        except (TypeError, ValueError):185            lat = lng = None186187        # fiche microsite (description, téléphone, galerie) — via le cache BD :188        # revisitée seulement si la ligne de l'index change189        ext_id = str(p.get("objectID") or "").strip()190        feed_key = hashlib.sha1("|".join(str(x) for x in (191            title, website, city, street, addr.get("zip"), lat, lng,192        )).encode("utf-8")).hexdigest()193        d: dict = {}194        try:195            d = self.detail(ext_id, feed_key,196                            lambda: self._fetch_detail(website))197        except Exception:198            pass    # budget détail épuisé ou microsite injoignable199200        details: dict = {"property_manager_site": website}201        phone = (d.get("phone") or "").strip()202        if phone:203            details["contact"] = {"phone": phone}204205        return Listing(206            source=self.source_id,207            external_id=ext_id,208            url=website,209            title=title,210            address=full_addr,211            city=city,212            province="ON",213            description=d.get("description") or "",214            images=list(d.get("images") or [])[: self.max_images],215            details=details,216            lat=lat,217            lng=lng,218        )219220    # -- microsite : meta description + téléphone + galerie (HTML serveur) ------221    def _fetch_detail(self, url: str) -> dict:222        """Extrait les champs sûrs du HTML serveur du microsite Duda.223224        Les prix/plans d'étage sont volontairement ignorés : ils sont chargés225        côté client par un GraphQL PropertyVista inaccessible (voir en-tête).226        """227        if self._detail_fetches >= self.max_details:228            raise RuntimeError("budget de microsites atteint")   # non mis en cache229        self._detail_fetches += 1230        out: dict = {"description": "", "phone": "", "images": []}231        try:232            html = self.get(url).text233        except Exception:234            return out          # microsite mort/parqué : cache vide, on n'y235        #                         retournera que si la ligne de l'index change236237        m = _META_DESC_RE.search(html)238        if m:239            import html as _h240            out["description"] = _h.unescape(m.group(1)).strip()[:900]241242        m = _TEL_RE.search(html)243        if m:244            out["phone"] = re.sub(r"\s+", " ", m.group(1)).strip()245246        # galerie : uniquement les visuels du site lui-même (chemin contenant247        # l'alias Duda du microsite), hors logos/drapeaux/pictos248        alias_m = _SITE_ALIAS_RE.search(html)249        alias = alias_m.group(1) if alias_m else None250        images: list[str] = []251        for src in _DUDA_IMG_RE.findall(html):252            if alias and f"/{alias}/" not in src:253                continue        # visuel partagé du gabarit (logo gestionnaire…)254            if _IMG_SKIP_RE.search(src):255                continue256            if src not in images:257                images.append(src)258        out["images"] = images[: self.max_images]259        return out260