Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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