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/sifton.py : connecteur Sifton Properties (sifton.com)5# Gros gestionnaire/promoteur de London ON (aussi Guelph, Brantford) :6# appartements ET maisons de ville en location longue durée (~15 communautés7# locatives, 43 « produits » = types d'unités au moment de l'écriture).8# Le site est un Statamic (Laravel) dont l'API de contenu REST est PUBLIQUE9# et ouverte — aucun rendu JS ni anti-bot nécessaire (le front l'utilise10# aussi via /api/custom/search, plus lourd car il renvoie des cartes HTML).11# Deux requêtes suffisent :12# - /api/collections/places/entries?limit=100 — les « places » (41, dont13# ~15 place_type=rentals) : adresse civique complète (address_street),14# géocodage Google embarqué (lat/lon, ville, code postal), description15# et accordéons de commodités (landing_page.page_modules), galerie16# photo, microsite externe éventuel (Candela, Spektra) ;17# - /api/collections/products/entries?limit=300 — les « produits »18# (_type_taxonomy=rental_types) : type d'unité (« 1 Bedroom »,19# « 2 Bedroom + Den »…), sous-type apartments/townhomes, prix plancher20# (_price, toujours « from »), pi², taxonomie beds (studio, 1-bed,21# 2_5-beds = 2 cc + den), drapeau `available`, place_slug de rattachement.22# Une annonce PAR PRODUIT (type d'unité) rattachée à sa place — granularité23# identique au site. Le drapeau `available` du produit pilote le texte de24# disponibilité (les produits non disponibles restent affichés par Sifton25# avec leur prix « Get In Touch » : on les garde, comme les plans Killam).26# Baths : la taxonomie est « none » partout — jamais inventé (None).27# Tout vient de l'API en 2 requêtes : pas besoin du cache BD self.detail().28# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.29# -----------------------------------------------------------------------------30from __future__ import annotations3132import os33import re3435from bs4 import BeautifulSoup3637from ..schema import Listing, normalize_unit_type38from .base import BaseConnector3940BASE = "https://sifton.com"41PLACES_API = f"{BASE}/api/collections/places/entries"42PRODUCTS_API = f"{BASE}/api/collections/products/entries"4344# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent45_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4647# taxonomie beds de Statamic : « studio », « 1-bed », « 2_5-beds » (2 cc + den)48_BEDS_SLUG_RE = re.compile(r"^(\d+)(_5)?-beds?$")4950# visuels de style de vie génériques réutilisés sur plusieurs fiches — écartés51_STOCK_IMG_RE = re.compile(52 r"people-walking|balcony-lady|logo|favicon|icon", re.I)535455def _strip_html(fragment: str) -> str:56 return BeautifulSoup(fragment or "", "html.parser").get_text(57 " ", strip=True)585960class SiftonConnector(BaseConnector):61 source_id = "sifton"62 request_delay = 1.063 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)64 max_properties = 40 # garde-fou places locatives (15 au 2026-08)65 max_listings = 200 # garde-fou produits (43 au 2026-08)66 max_images = 156768 # -- API Statamic ------------------------------------------------------------69 def _entries(self, url: str, limit: int) -> list[dict]:70 rows: list[dict] = []71 page = 172 while page <= 10:73 data = self.get(url, params={"limit": limit, "page": page},74 headers={"Accept": "application/json"}).json()75 rows.extend(data.get("data") or [])76 meta = data.get("meta") or {}77 if page >= int(meta.get("last_page") or 1):78 break79 page += 180 return rows8182 # -- places locatives : contexte partagé par leurs produits -------------------83 def _rental_places(self) -> dict[str, dict]:84 out: dict[str, dict] = {}85 for p in self._entries(PLACES_API, 100):86 ptype = (p.get("place_type") or {})87 if not isinstance(ptype, dict) or ptype.get("slug") != "rentals":88 continue89 if p.get("status") not in (None, "published"):90 continue91 if len(out) >= self.max_properties:92 break93 try:94 out[p["slug"]] = self._place_ctx(p)95 except Exception:96 continue97 return out9899 def _place_ctx(self, p: dict) -> dict:100 gm = p.get("address_google_maps") or {}101 city = (gm.get("locality") or "").strip()102 street = " ".join(x for x in (103 (gm.get("streetNumber") or "").strip(),104 (gm.get("streetName") or "").strip()) if x)105 # address_street du CMS (« 550 Berkshire Drive, London, ON ») est déjà106 # complète et plus fiable que le géocodage pour les adresses multiples107 # (« 577/587/597/607 Cranbrook Road ») ; on la suffixe du code postal108 address = (p.get("address_street") or "").strip().rstrip(",")109 if not address:110 address = ", ".join(x for x in (street, city) if x) + ", ON"111 postal = (gm.get("postalCode") or "").strip()112 if postal and postal not in address:113 address += f" {postal}"114115 try:116 lat = float(gm.get("lat")) or None117 lng = float(gm.get("lon")) or None118 except (TypeError, ValueError):119 lat = lng = None120121 # description + accordéons de commodités + galerie : modules de la122 # landing page (structure Statamic, servie directement par l'API)123 desc_parts: list[str] = []124 amenities: list[str] = []125 images: list[str] = []126 card = (p.get("card_image") or {}).get("permalink")127 if card:128 images.append(card)129 modules = ((p.get("landing_page") or {}).get("page_modules")) or []130 for m in modules:131 mtype = m.get("type")132 if mtype == "two_col_text_acc":133 t = _strip_html(m.get("text_acc_content_text") or "")134 if t:135 desc_parts.append(t)136 acc = (m.get("text_acc_content_acc") or {})137 for item in acc.get("accordion_repeater") or []:138 for blk in item.get("acc_dropdown_content") or []:139 soup = BeautifulSoup(blk.get("text") or "",140 "html.parser")141 for li in soup.find_all("li"):142 t = li.get_text(" ", strip=True)143 if t and len(t) < 90 and t not in amenities:144 amenities.append(t)145 elif mtype == "image_gallery":146 for img in m.get("images") or []:147 u = (img.get("permalink") or "").strip()148 if u and not _STOCK_IMG_RE.search(u) and u not in images:149 images.append(u)150151 # URL publique : microsite externe (Candela, Spektra) sinon la fiche152 url = (p.get("external_url") or "").strip() or \153 (p.get("permalink") or "").strip() or BASE154155 return {156 "title": (p.get("title") or "").strip(),157 "address": address, "city": city, "lat": lat, "lng": lng,158 "url": url, "description": " ".join(desc_parts)[:900],159 "amenities": amenities[:25],160 "images": images[: self.max_images],161 }162163 # -- collecte : une annonce par produit locatif -------------------------------164 def fetch(self) -> list[Listing]:165 places = self._rental_places()166 listings: list[Listing] = []167 for pr in self._entries(PRODUCTS_API, 300):168 if len(listings) >= self.max_listings:169 break170 try:171 if pr.get("_type_taxonomy") != "rental_types":172 continue173 if pr.get("status") not in (None, "published"):174 continue175 b = places.get(pr.get("place_slug") or "")176 if b is None:177 continue # place non locative ou hors garde-fou178 listings.append(self._listing(pr, b))179 except Exception:180 continue181 return listings182183 def _listing(self, pr: dict, b: dict) -> Listing:184 title_raw = (pr.get("title") or "").strip() # « 2 Bedroom + Den »185186 # chambres fermées : taxonomie beds (studio / n-bed / n_5-beds, où187 # « _5 » = den, pas une chambre) — jamais inventé188 beds = None189 slug_beds = ((pr.get("beds") or {}).get("slug") or "").strip()190 if slug_beds == "studio":191 beds = 0.0192 else:193 m = _BEDS_SLUG_RE.match(slug_beds)194 if m:195 beds = float(m.group(1))196197 # type d'unité : maison de ville -> « Maison », sinon dérivé des cc198 subtypes = pr.get("_subtypes") or []199 if "townhomes" in subtypes:200 unit_type = normalize_unit_type("townhouse")201 elif beds == 0:202 unit_type = "Studio"203 elif beds is not None:204 unit_type = normalize_unit_type(f"{int(beds)} chambres")205 else:206 unit_type = normalize_unit_type(title_raw)207208 # prix : toujours « from » chez Sifton (_price_type = from)209 price = None210 try:211 price = float(pr.get("_price") or 0) or None212 except (TypeError, ValueError):213 pass214 price_label = f"À partir de {price:.0f} $ /mois" if price else ""215216 # superficie plancher plausible (sq_ft_end = haut de fourchette)217 area = None218 try:219 v = float(pr.get("_sqft") or 0)220 if 80 <= v <= 20000:221 area = v222 except (TypeError, ValueError):223 pass224225 # baths : taxonomie « none » sur tout le catalogue -> inconnu (None)226 baths = None227 slug_baths = ((pr.get("baths") or {}).get("slug") or "").strip()228 m = re.match(r"^(\d+)(_5)?-baths?$", slug_baths)229 if m:230 baths = float(m.group(1)) + (0.5 if m.group(2) else 0.0)231232 available = pr.get("available") is True233 details: dict = {}234 if "townhomes" in subtypes:235 details["Type de bâtiment"] = "Maison de ville"236237 return Listing(238 source=self.source_id,239 external_id=(pr.get("slug") or pr.get("id") or "").strip(),240 url=b["url"],241 title=f"{b['title']} — {title_raw}" if title_raw else b["title"],242 address=b["address"],243 city=b["city"],244 province="ON",245 unit_type=unit_type,246 bedrooms=beds,247 bathrooms=baths,248 price=price,249 price_label=price_label,250 availability=("Unités disponibles" if available251 else "Aucune unité disponible actuellement"),252 area_sqft=area,253 description=b["description"],254 amenities=b["amenities"],255 details=details,256 images=b["images"],257 lat=b["lat"],258 lng=b["lng"],259 )260