Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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