# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/sinistar.py : Sinistar (sinistar.ca) # # Plateforme québécoise de relogement temporaire pour sinistrés (assurance # habitation) : ~5 600 logements meublés au Québec, partout en province. # SPA Next.js, mais la recherche passe par un index Algolia public : # 1. LISTE : POST https://RKSJN2W5I1-dsn.algolia.net/1/indexes/prod_housings/ # query (clé search-only embarquée dans les bundles du site) avec # facetFilters state:QC. Chaque hit : reference, ville, type, chambres, # lits, sdb, photos, _geoloc. L'index plafonne à 1 000 enregistrements # par requête (paginationLimitedTo) → couverture par quadrillage # insideBoundingBox : une cellule qui dépasse 1 000 hits est subdivisée # en 4 (quadtree, même esprit que le connecteur Airbnb). # 2. DÉTAIL (cache self.detail) : GET https://sinistar.ca/fr/housing/ # — la page embarque le JSON du logement dans le flux React Flight # (self.__next_f.push) : description, commodités, capacité. # 3. AUCUN PRIX PUBLIC : le tarif est négocié entre l'hôte et l'assureur # (« couvert par l'assurance ») → price_night=None, prix absent assumé. # Vérifié 2026-08-25 : ni l'index Algolia, ni le flux React Flight, ni # le document Firestore public (projects/sinistar-13fdf …/housings/, # lisible sans auth) ne contiennent de champ prix ou note — les montants # circulent uniquement dans les soumissions hôte↔assureur (auth requise). # La région touristique est déduite des coordonnées (centroïdes Airbnb). # # Réglage env : LOUKA_SINISTAR_LIMIT (nb max d'annonces, 0 = tout ; utile # pour tester petit sans payer les ~5 600 pages détail du premier run). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import sys from ..schema import StListing from .airbnb import _region_from_latlng from .base import StConnector SITE = "https://sinistar.ca" ALGOLIA_URL = ("https://RKSJN2W5I1-dsn.algolia.net/1/indexes/" "prod_housings/query") ALGOLIA_HEADERS = { "x-algolia-application-id": "RKSJN2W5I1", # clé search-only publique (extraite des bundles JS de sinistar.ca) "x-algolia-api-key": "4dbe623a9ffc1181003e9d98cad4c05b", "Content-Type": "application/json", } # Zone habitée du Québec (sud, ouest, nord, est) pour le quadrillage géo. QC_BBOX = (44.95, -79.80, 62.00, -56.90) # type Sinistar → (type canonique Lou-Ka, libellé français pour le titre) _TYPES = { "house": ("Maison", "Maison meublée"), "cityhouse": ("Maison", "Maison de ville meublée"), "semidetached": ("Maison", "Maison jumelée meublée"), "appartment": ("Appartement", "Appartement meublé"), "apartment": ("Appartement", "Appartement meublé"), "condo": ("Condo", "Condo meublé"), "cottage": ("Chalet", "Chalet meublé"), "loft": ("Loft", "Loft meublé"), "hotel": ("Auberge", "Hébergement hôtelier"), } _FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.S) def _num(v) -> float | None: try: return float(v) if v not in (None, "") else None except (TypeError, ValueError): return None def _decode_flight(html: str) -> str: """Concatène les segments React Flight (chaînes JS échappées → UTF-8).""" out = [] for chunk in _FLIGHT_RE.findall(html): try: s = chunk.encode("utf-8").decode("unicode_escape") out.append(s.encode("latin-1", "replace").decode("utf-8", "replace")) except (UnicodeDecodeError, UnicodeEncodeError): continue return "".join(out) class Sinistar(StConnector): source_id = "sinistar" request_delay = 0.4 # -- liste (Algolia, quadtree géo) ---------------------------------------- def _query(self, params: str) -> dict: resp = self.post(ALGOLIA_URL, headers=ALGOLIA_HEADERS, json={"params": params}) return resp.json() def _all_hits(self) -> list[dict]: hits: list[dict] = [] seen: set[str] = set() stack = [QC_BBOX] while stack: s, w, n, e = stack.pop() box = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}" data = self._query( "hitsPerPage=1000" "&facetFilters=%5B%5B%22state%3AQC%22%5D%5D" f"&insideBoundingBox={box}") nb = data.get("nbHits") or 0 if nb > 1000 and (n - s) > 0.002: # cellule saturée (limite Algolia) → subdivision en 4 mlat, mlng = (s + n) / 2, (w + e) / 2 stack.extend([(s, w, mlat, mlng), (s, mlng, mlat, e), (mlat, w, n, mlng), (mlat, mlng, n, e)]) continue for h in data.get("hits") or []: oid = h.get("objectID") or "" if oid and oid not in seen: seen.add(oid) hits.append(h) return hits # -- détail (page housing, cache BD) --------------------------------------- def _fetch_detail(self, reference: str) -> dict: resp = self.get(f"{SITE}/fr/housing/{reference}") blob = _decode_flight(resp.text) i = blob.find(f'"reference":{reference}') if i < 0: return {} line = blob[blob.rfind("\n", 0, i) + 1:] line = line[:line.find("\n")] if "\n" in line else line try: payload = json.loads(line.split(":", 1)[1]) except (ValueError, IndexError): return {} housing = {} def walk(o): nonlocal housing if housing: return if isinstance(o, dict): if str(o.get("reference")) == reference and "amenities" in o: housing = o return for v in o.values(): walk(v) elif isinstance(o, list): for v in o: walk(v) walk(payload) if not housing: return {} amenities = [] for cat in (housing.get("amenities") or {}).values(): for a in (cat if isinstance(cat, list) else []): if isinstance(a, str): amenities.append(a.replace("_", " ").strip()) return { "description": (housing.get("description") or "").strip(), "amenities": amenities, "capacity": _num(housing.get("capacity")), } # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_SINISTAR_LIMIT", "0") or 0) listings: list[StListing] = [] seen: set[str] = set() for h in self._all_hits(): ref = str(h.get("reference") or "").strip() city = (h.get("city") or "").strip() if not ref or ref in seen or not city: continue seen.add(ref) geo = h.get("_geoloc") or {} lat, lng = _num(geo.get("lat")), _num(geo.get("lng")) ptype, label = _TYPES.get((h.get("type") or "").strip().lower(), ("Autre", "Logement meublé")) bedrooms = _num(h.get("bedrooms")) title = label if bedrooms: title += f" {int(bedrooms)} chambre{'s' if bedrooms > 1 else ''}" title += f" à {city}" images = [p.get("url") for p in (h.get("pictures") or [])[:15] if isinstance(p.get("url"), str) and p["url"].startswith("https://")] if not images and isinstance( (h.get("coverPicture") or {}).get("url"), str): images = [h["coverPicture"]["url"]] # clé de cache détail : sous-ensemble stable du hit liste key = json.dumps([ref, h.get("type"), h.get("bedrooms"), h.get("beds"), h.get("bathrooms"), len(images)], ensure_ascii=False) try: det = self.detail(ref, key, lambda r=ref: self._fetch_detail(r)) except Exception as exc: # une fiche cassée ≠ annonce perdue print(f"[sinistar] détail {ref} en échec : {exc}", file=sys.stderr) det = {} listings.append(StListing( source=self.source_id, external_id=ref, url=f"{SITE}/fr/housing/{ref}", title=title, property_type=ptype, city=city, region=_region_from_latlng(lat, lng), price_night=None, # tarif négocié avec l'assureur price_label="", capacity=det.get("capacity"), bedrooms=bedrooms, beds=_num(h.get("beds")), bathrooms=_num(h.get("bathrooms")), description=det.get("description") or "", amenities=det.get("amenities") or [], details={"relocation": True, "sinistar_type": h.get("type") or ""}, images=images, lat=lat, lng=lng, ).finalize()) if limit and len(listings) >= limit: break return listings