# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sifton.py : connecteur Sifton Properties (sifton.com) # Gros gestionnaire/promoteur de London ON (aussi Guelph, Brantford) : # appartements ET maisons de ville en location longue durée (~15 communautés # locatives, 43 « produits » = types d'unités au moment de l'écriture). # Le site est un Statamic (Laravel) dont l'API de contenu REST est PUBLIQUE # et ouverte — aucun rendu JS ni anti-bot nécessaire (le front l'utilise # aussi via /api/custom/search, plus lourd car il renvoie des cartes HTML). # Deux requêtes suffisent : # - /api/collections/places/entries?limit=100 — les « places » (41, dont # ~15 place_type=rentals) : adresse civique complète (address_street), # géocodage Google embarqué (lat/lon, ville, code postal), description # et accordéons de commodités (landing_page.page_modules), galerie # photo, microsite externe éventuel (Candela, Spektra) ; # - /api/collections/products/entries?limit=300 — les « produits » # (_type_taxonomy=rental_types) : type d'unité (« 1 Bedroom », # « 2 Bedroom + Den »…), sous-type apartments/townhomes, prix plancher # (_price, toujours « from »), pi², taxonomie beds (studio, 1-bed, # 2_5-beds = 2 cc + den), drapeau `available`, place_slug de rattachement. # Une annonce PAR PRODUIT (type d'unité) rattachée à sa place — granularité # identique au site. Le drapeau `available` du produit pilote le texte de # disponibilité (les produits non disponibles restent affichés par Sifton # avec leur prix « Get In Touch » : on les garde, comme les plans Killam). # Baths : la taxonomie est « none » partout — jamais inventé (None). # Tout vient de l'API en 2 requêtes : pas besoin du cache BD self.detail(). # Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://sifton.com" PLACES_API = f"{BASE}/api/collections/places/entries" PRODUCTS_API = f"{BASE}/api/collections/products/entries" # Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" # taxonomie beds de Statamic : « studio », « 1-bed », « 2_5-beds » (2 cc + den) _BEDS_SLUG_RE = re.compile(r"^(\d+)(_5)?-beds?$") # visuels de style de vie génériques réutilisés sur plusieurs fiches — écartés _STOCK_IMG_RE = re.compile( r"people-walking|balcony-lady|logo|favicon|icon", re.I) def _strip_html(fragment: str) -> str: return BeautifulSoup(fragment or "", "html.parser").get_text( " ", strip=True) class SiftonConnector(BaseConnector): source_id = "sifton" request_delay = 1.0 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) max_properties = 40 # garde-fou places locatives (15 au 2026-08) max_listings = 200 # garde-fou produits (43 au 2026-08) max_images = 15 # -- API Statamic ------------------------------------------------------------ def _entries(self, url: str, limit: int) -> list[dict]: rows: list[dict] = [] page = 1 while page <= 10: data = self.get(url, params={"limit": limit, "page": page}, headers={"Accept": "application/json"}).json() rows.extend(data.get("data") or []) meta = data.get("meta") or {} if page >= int(meta.get("last_page") or 1): break page += 1 return rows # -- places locatives : contexte partagé par leurs produits ------------------- def _rental_places(self) -> dict[str, dict]: out: dict[str, dict] = {} for p in self._entries(PLACES_API, 100): ptype = (p.get("place_type") or {}) if not isinstance(ptype, dict) or ptype.get("slug") != "rentals": continue if p.get("status") not in (None, "published"): continue if len(out) >= self.max_properties: break try: out[p["slug"]] = self._place_ctx(p) except Exception: continue return out def _place_ctx(self, p: dict) -> dict: gm = p.get("address_google_maps") or {} city = (gm.get("locality") or "").strip() street = " ".join(x for x in ( (gm.get("streetNumber") or "").strip(), (gm.get("streetName") or "").strip()) if x) # address_street du CMS (« 550 Berkshire Drive, London, ON ») est déjà # complète et plus fiable que le géocodage pour les adresses multiples # (« 577/587/597/607 Cranbrook Road ») ; on la suffixe du code postal address = (p.get("address_street") or "").strip().rstrip(",") if not address: address = ", ".join(x for x in (street, city) if x) + ", ON" postal = (gm.get("postalCode") or "").strip() if postal and postal not in address: address += f" {postal}" try: lat = float(gm.get("lat")) or None lng = float(gm.get("lon")) or None except (TypeError, ValueError): lat = lng = None # description + accordéons de commodités + galerie : modules de la # landing page (structure Statamic, servie directement par l'API) desc_parts: list[str] = [] amenities: list[str] = [] images: list[str] = [] card = (p.get("card_image") or {}).get("permalink") if card: images.append(card) modules = ((p.get("landing_page") or {}).get("page_modules")) or [] for m in modules: mtype = m.get("type") if mtype == "two_col_text_acc": t = _strip_html(m.get("text_acc_content_text") or "") if t: desc_parts.append(t) acc = (m.get("text_acc_content_acc") or {}) for item in acc.get("accordion_repeater") or []: for blk in item.get("acc_dropdown_content") or []: soup = BeautifulSoup(blk.get("text") or "", "html.parser") for li in soup.find_all("li"): t = li.get_text(" ", strip=True) if t and len(t) < 90 and t not in amenities: amenities.append(t) elif mtype == "image_gallery": for img in m.get("images") or []: u = (img.get("permalink") or "").strip() if u and not _STOCK_IMG_RE.search(u) and u not in images: images.append(u) # URL publique : microsite externe (Candela, Spektra) sinon la fiche url = (p.get("external_url") or "").strip() or \ (p.get("permalink") or "").strip() or BASE return { "title": (p.get("title") or "").strip(), "address": address, "city": city, "lat": lat, "lng": lng, "url": url, "description": " ".join(desc_parts)[:900], "amenities": amenities[:25], "images": images[: self.max_images], } # -- collecte : une annonce par produit locatif ------------------------------- def fetch(self) -> list[Listing]: places = self._rental_places() listings: list[Listing] = [] for pr in self._entries(PRODUCTS_API, 300): if len(listings) >= self.max_listings: break try: if pr.get("_type_taxonomy") != "rental_types": continue if pr.get("status") not in (None, "published"): continue b = places.get(pr.get("place_slug") or "") if b is None: continue # place non locative ou hors garde-fou listings.append(self._listing(pr, b)) except Exception: continue return listings def _listing(self, pr: dict, b: dict) -> Listing: title_raw = (pr.get("title") or "").strip() # « 2 Bedroom + Den » # chambres fermées : taxonomie beds (studio / n-bed / n_5-beds, où # « _5 » = den, pas une chambre) — jamais inventé beds = None slug_beds = ((pr.get("beds") or {}).get("slug") or "").strip() if slug_beds == "studio": beds = 0.0 else: m = _BEDS_SLUG_RE.match(slug_beds) if m: beds = float(m.group(1)) # type d'unité : maison de ville -> « Maison », sinon dérivé des cc subtypes = pr.get("_subtypes") or [] if "townhomes" in subtypes: unit_type = normalize_unit_type("townhouse") elif beds == 0: unit_type = "Studio" elif beds is not None: unit_type = normalize_unit_type(f"{int(beds)} chambres") else: unit_type = normalize_unit_type(title_raw) # prix : toujours « from » chez Sifton (_price_type = from) price = None try: price = float(pr.get("_price") or 0) or None except (TypeError, ValueError): pass price_label = f"À partir de {price:.0f} $ /mois" if price else "" # superficie plancher plausible (sq_ft_end = haut de fourchette) area = None try: v = float(pr.get("_sqft") or 0) if 80 <= v <= 20000: area = v except (TypeError, ValueError): pass # baths : taxonomie « none » sur tout le catalogue -> inconnu (None) baths = None slug_baths = ((pr.get("baths") or {}).get("slug") or "").strip() m = re.match(r"^(\d+)(_5)?-baths?$", slug_baths) if m: baths = float(m.group(1)) + (0.5 if m.group(2) else 0.0) available = pr.get("available") is True details: dict = {} if "townhomes" in subtypes: details["Type de bâtiment"] = "Maison de ville" return Listing( source=self.source_id, external_id=(pr.get("slug") or pr.get("id") or "").strip(), url=b["url"], title=f"{b['title']} — {title_raw}" if title_raw else b["title"], address=b["address"], city=b["city"], province="ON", unit_type=unit_type, bedrooms=beds, bathrooms=baths, price=price, price_label=price_label, availability=("Unités disponibles" if available else "Aucune unité disponible actuellement"), area_sqft=area, description=b["description"], amenities=b["amenities"], details=details, images=b["images"], lat=b["lat"], lng=b["lng"], )