Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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