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/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# -----------------------------------------------------------------------------29from __future__ import annotations3031import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type37from .base import BaseConnector3839BASE = "https://sifton.com"40PLACES_API = f"{BASE}/api/collections/places/entries"41PRODUCTS_API = f"{BASE}/api/collections/products/entries"4243# Rent-Ka: connector always active.44_ONTARIO = True # Rent-Ka: always on (ROC scope)4546# taxonomie beds de Statamic : « studio », « 1-bed », « 2_5-beds » (2 cc + den)47_BEDS_SLUG_RE = re.compile(r"^(\d+)(_5)?-beds?$")4849# visuels de style de vie génériques réutilisés sur plusieurs fiches — écartés50_STOCK_IMG_RE = re.compile(51 r"people-walking|balcony-lady|logo|favicon|icon", re.I)525354def _strip_html(fragment: str) -> str:55 return BeautifulSoup(fragment or "", "html.parser").get_text(56 " ", strip=True)575859class SiftonConnector(BaseConnector):60 source_id = "sifton"61 request_delay = 1.062 disabled = False63 max_properties = 40 # garde-fou places locatives (15 au 2026-08)64 max_listings = 200 # garde-fou produits (43 au 2026-08)65 max_images = 156667 # -- API Statamic ------------------------------------------------------------68 def _entries(self, url: str, limit: int) -> list[dict]:69 rows: list[dict] = []70 page = 171 while page <= 10:72 data = self.get(url, params={"limit": limit, "page": page},73 headers={"Accept": "application/json"}).json()74 rows.extend(data.get("data") or [])75 meta = data.get("meta") or {}76 if page >= int(meta.get("last_page") or 1):77 break78 page += 179 return rows8081 # -- places locatives : contexte partagé par leurs produits -------------------82 def _rental_places(self) -> dict[str, dict]:83 out: dict[str, dict] = {}84 for p in self._entries(PLACES_API, 100):85 ptype = (p.get("place_type") or {})86 if not isinstance(ptype, dict) or ptype.get("slug") != "rentals":87 continue88 if p.get("status") not in (None, "published"):89 continue90 if len(out) >= self.max_properties:91 break92 try:93 out[p["slug"]] = self._place_ctx(p)94 except Exception:95 continue96 return out9798 def _place_ctx(self, p: dict) -> dict:99 gm = p.get("address_google_maps") or {}100 city = (gm.get("locality") or "").strip()101 street = " ".join(x for x in (102 (gm.get("streetNumber") or "").strip(),103 (gm.get("streetName") or "").strip()) if x)104 # address_street du CMS (« 550 Berkshire Drive, London, ON ») est déjà105 # complète et plus fiable que le géocodage pour les adresses multiples106 # (« 577/587/597/607 Cranbrook Road ») ; on la suffixe du code postal107 address = (p.get("address_street") or "").strip().rstrip(",")108 if not address:109 address = ", ".join(x for x in (street, city) if x) + ", ON"110 postal = (gm.get("postalCode") or "").strip()111 if postal and postal not in address:112 address += f" {postal}"113114 try:115 lat = float(gm.get("lat")) or None116 lng = float(gm.get("lon")) or None117 except (TypeError, ValueError):118 lat = lng = None119120 # description + accordéons de commodités + galerie : modules de la121 # landing page (structure Statamic, servie directement par l'API)122 desc_parts: list[str] = []123 amenities: list[str] = []124 images: list[str] = []125 card = (p.get("card_image") or {}).get("permalink")126 if card:127 images.append(card)128 modules = ((p.get("landing_page") or {}).get("page_modules")) or []129 for m in modules:130 mtype = m.get("type")131 if mtype == "two_col_text_acc":132 t = _strip_html(m.get("text_acc_content_text") or "")133 if t:134 desc_parts.append(t)135 acc = (m.get("text_acc_content_acc") or {})136 for item in acc.get("accordion_repeater") or []:137 for blk in item.get("acc_dropdown_content") or []:138 soup = BeautifulSoup(blk.get("text") or "",139 "html.parser")140 for li in soup.find_all("li"):141 t = li.get_text(" ", strip=True)142 if t and len(t) < 90 and t not in amenities:143 amenities.append(t)144 elif mtype == "image_gallery":145 for img in m.get("images") or []:146 u = (img.get("permalink") or "").strip()147 if u and not _STOCK_IMG_RE.search(u) and u not in images:148 images.append(u)149150 # URL publique : microsite externe (Candela, Spektra) sinon la fiche151 url = (p.get("external_url") or "").strip() or \152 (p.get("permalink") or "").strip() or BASE153154 return {155 "title": (p.get("title") or "").strip(),156 "address": address, "city": city, "lat": lat, "lng": lng,157 "url": url, "description": " ".join(desc_parts)[:900],158 "amenities": amenities[:25],159 "images": images[: self.max_images],160 }161162 # -- collecte : une annonce par produit locatif -------------------------------163 def fetch(self) -> list[Listing]:164 places = self._rental_places()165 listings: list[Listing] = []166 for pr in self._entries(PRODUCTS_API, 300):167 if len(listings) >= self.max_listings:168 break169 try:170 if pr.get("_type_taxonomy") != "rental_types":171 continue172 if pr.get("status") not in (None, "published"):173 continue174 b = places.get(pr.get("place_slug") or "")175 if b is None:176 continue # place non locative ou hors garde-fou177 listings.append(self._listing(pr, b))178 except Exception:179 continue180 return listings181182 def _listing(self, pr: dict, b: dict) -> Listing:183 title_raw = (pr.get("title") or "").strip() # « 2 Bedroom + Den »184185 # chambres fermées : taxonomie beds (studio / n-bed / n_5-beds, où186 # « _5 » = den, pas une chambre) — jamais inventé187 beds = None188 slug_beds = ((pr.get("beds") or {}).get("slug") or "").strip()189 if slug_beds == "studio":190 beds = 0.0191 else:192 m = _BEDS_SLUG_RE.match(slug_beds)193 if m:194 beds = float(m.group(1))195196 # type d'unité : maison de ville -> « Maison », sinon dérivé des cc197 subtypes = pr.get("_subtypes") or []198 if "townhomes" in subtypes:199 unit_type = normalize_unit_type("townhouse")200 elif beds == 0:201 unit_type = "Studio"202 elif beds is not None:203 unit_type = normalize_unit_type(f"{int(beds)} chambres")204 else:205 unit_type = normalize_unit_type(title_raw)206207 # prix : toujours « from » chez Sifton (_price_type = from)208 price = None209 try:210 price = float(pr.get("_price") or 0) or None211 except (TypeError, ValueError):212 pass213 price_label = f"À partir de {price:.0f} $ /mois" if price else ""214215 # superficie plancher plausible (sq_ft_end = haut de fourchette)216 area = None217 try:218 v = float(pr.get("_sqft") or 0)219 if 80 <= v <= 20000:220 area = v221 except (TypeError, ValueError):222 pass223224 # baths : taxonomie « none » sur tout le catalogue -> inconnu (None)225 baths = None226 slug_baths = ((pr.get("baths") or {}).get("slug") or "").strip()227 m = re.match(r"^(\d+)(_5)?-baths?$", slug_baths)228 if m:229 baths = float(m.group(1)) + (0.5 if m.group(2) else 0.0)230231 available = pr.get("available") is True232 details: dict = {}233 if "townhomes" in subtypes:234 details["Type de bâtiment"] = "Maison de ville"235236 return Listing(237 source=self.source_id,238 external_id=(pr.get("slug") or pr.get("id") or "").strip(),239 url=b["url"],240 title=f"{b['title']} — {title_raw}" if title_raw else b["title"],241 address=b["address"],242 city=b["city"],243 province="ON",244 unit_type=unit_type,245 bedrooms=beds,246 bathrooms=baths,247 price=price,248 price_label=price_label,249 availability=("Unités disponibles" if available250 else "Aucune unité disponible actuellement"),251 area_sqft=area,252 description=b["description"],253 amenities=b["amenities"],254 details=details,255 images=b["images"],256 lat=b["lat"],257 lng=b["lng"],258 )259