SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.9 KB · 259 lines python
Raw Blame History
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