SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
11.1 KB · 260 lines python
Raw Blame History
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