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%
5.6 KB · 128 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/simplex.py : connecteur Simplex Immobilier (simpleximmobilier.com)5#   — agence/gestionnaire de ~600 logements à Trois-Rivières, Shawinigan et en6#   Mauricie (~25-30 annonces publiées à la fois). Site Astro dont le catalogue7#   vit dans un Supabase public : la RPC `get_public_rental_listings_page`8#   (clé publiable `sb_publishable_…` embarquée dans le bundle JS du site)9#   retourne TOUT en 1-2 requêtes JSON : titre, ville/secteur, type d'unité,10#   chambres/sdb, loyer, statut (available/coming_soon/reserved/rented),11#   date de disponibilité, description, inclusions, animaux, stationnement,12#   photos (bucket public `rental-listing-media`).13#   ⚠️ Aucune adresse civique publiée (même la RPC détail n'en expose pas) :14#   ville/secteur seulement, donc pas de géocodage fin possible.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import json19import os2021from ..schema import Listing22from .base import BaseConnector2324SB_URL = "https://tyrzkvwbiaadajwopdrj.supabase.co"25RPC_URL = f"{SB_URL}/rest/v1/rpc/get_public_rental_listings_page"26STORAGE_URL = f"{SB_URL}/storage/v1/object/public"27SITE = "https://simpleximmobilier.com"28# clé PUBLIABLE (anon) du site — surchargeable si Simplex la fait tourner29API_KEY = os.environ.get(30    "LOUKA_SIMPLEX_KEY", "sb_publishable_HT7wke-8-o_TUxMUeLjWbw_wsVK1FC4")3132PAGE = 10033_KEEP = {"available", "coming_soon"}34_STATUS_FR = {"available": "Disponible", "coming_soon": "Bientôt disponible"}35_PETS = {"oui": "oui", "non": "non"}363738class SimplexConnector(BaseConnector):39    source_id = "simplex"40    request_delay = 0.84142    def _rpc(self, offset: int) -> list[dict]:43        resp = self.post(RPC_URL, data=json.dumps({44            "p_search": None, "p_status": None, "p_location": None,45            "p_unit_type": None, "p_max_price": None, "p_pets": None,46            "p_parking": None, "p_sort": "recommended",47            "p_offset": offset, "p_limit": PAGE,48        }), headers={"apikey": API_KEY,49                     "Authorization": f"Bearer {API_KEY}",50                     "Content-Type": "application/json"})51        data = resp.json()52        return data if isinstance(data, list) else []5354    def fetch(self) -> list[Listing]:55        rows: list[dict] = []56        offset = 057        while True:58            batch = self._rpc(offset)59            rows.extend(batch)60            total = batch[0].get("total_count", 0) if batch else 061            offset += PAGE62            if not batch or offset >= total:63                break64        listings: dict[str, Listing] = {}65        for row in rows:66            if row.get("availability_status") not in _KEEP:67                continue68            lst = self._listing(row)69            if lst and lst.uid not in listings:70                listings[lst.uid] = lst71        return list(listings.values())7273    def _images(self, row: dict) -> list[str]:74        media = sorted(row.get("media") or [],75                       key=lambda m: (not m.get("isPrimary"),76                                      m.get("sortOrder") or 0))77        out = []78        for m in media:79            if m.get("bucket") and m.get("path"):80                out.append(f"{STORAGE_URL}/{m['bucket']}/{m['path']}")81        for p in [row.get("primary_image_path"),82                  *(row.get("gallery_paths") or [])]:83            if p and p.startswith("http") and p not in out:84                out.append(p)85        return out[:20]8687    def _listing(self, row: dict) -> Listing | None:88        price = row.get("rent_amount")89        status = row.get("availability_status") or ""90        avail_txt = _STATUS_FR.get(status, "")91        avail_date = (row.get("available_from") or "")[:10]92        if status == "coming_soon" and avail_date:93            avail_txt = f"{avail_txt} — à compter du {avail_date}"94        pets_raw = (row.get("pets_policy") or "").strip().lower()95        amen = [a for a in (row.get("inclusions") or []) +96                (row.get("features") or []) + (row.get("labels") or []) if a]97        details = {}98        if row.get("parking"):99            details["stationnement"] = str(row["parking"]).strip()100        if row.get("public_reference"):101            details["référence"] = row["public_reference"]102        if row.get("rent_note"):103            details["note loyer"] = row["rent_note"]104        return Listing(105            source=self.source_id,106            external_id=str(row.get("id") or row.get("slug")),107            url=f"{SITE}/logements-a-louer/{row.get('slug', '')}/",108            title=(row.get("title") or "").strip(),109            sector=(row.get("sector") or "").strip(),110            city=(row.get("city") or "").strip(),111            unit_type=(row.get("unit_type") or "").strip(),112            bedrooms=(float(row["bedrooms"])113                      if row.get("bedrooms") is not None else None),114            bathrooms=(float(row["bathrooms"])115                       if row.get("bathrooms") is not None else None),116            price=float(price) if price else None,117            price_label=(f"{price:,.0f} $/mois".replace(",", " ")118                         if price else ""),119            availability=avail_txt,120            availability_date=("now" if status == "available"121                               else (avail_date or None)),122            pets=_PETS.get(pets_raw.rstrip(" .!")),123            description=(row.get("description") or "")[:2000],124            amenities=amen[:30],125            details=details,126            images=self._images(row),127        )128