Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/sinistar.py : Sinistar (sinistar.ca)4#5# Plateforme québécoise de relogement temporaire pour sinistrés (assurance6# habitation) : ~5 600 logements meublés au Québec, partout en province.7# SPA Next.js, mais la recherche passe par un index Algolia public :8# 1. LISTE : POST https://RKSJN2W5I1-dsn.algolia.net/1/indexes/prod_housings/9# query (clé search-only embarquée dans les bundles du site) avec10# facetFilters state:QC. Chaque hit : reference, ville, type, chambres,11# lits, sdb, photos, _geoloc. L'index plafonne à 1 000 enregistrements12# par requête (paginationLimitedTo) → couverture par quadrillage13# insideBoundingBox : une cellule qui dépasse 1 000 hits est subdivisée14# en 4 (quadtree, même esprit que le connecteur Airbnb).15# 2. DÉTAIL (cache self.detail) : GET https://sinistar.ca/fr/housing/<ref>16# — la page embarque le JSON du logement dans le flux React Flight17# (self.__next_f.push) : description, commodités, capacité.18# 3. AUCUN PRIX PUBLIC : le tarif est négocié entre l'hôte et l'assureur19# (« couvert par l'assurance ») → price_night=None, prix absent assumé.20# Vérifié 2026-08-25 : ni l'index Algolia, ni le flux React Flight, ni21# le document Firestore public (projects/sinistar-13fdf …/housings/<id>,22# lisible sans auth) ne contiennent de champ prix ou note — les montants23# circulent uniquement dans les soumissions hôte↔assureur (auth requise).24# La région touristique est déduite des coordonnées (centroïdes Airbnb).25#26# Réglage env : LOUKA_SINISTAR_LIMIT (nb max d'annonces, 0 = tout ; utile27# pour tester petit sans payer les ~5 600 pages détail du premier run).28# -----------------------------------------------------------------------------29from __future__ import annotations3031import json32import os33import re34import sys3536from ..schema import StListing37from .airbnb import _region_from_latlng38from .base import StConnector3940SITE = "https://sinistar.ca"41ALGOLIA_URL = ("https://RKSJN2W5I1-dsn.algolia.net/1/indexes/"42 "prod_housings/query")43ALGOLIA_HEADERS = {44 "x-algolia-application-id": "RKSJN2W5I1",45 # clé search-only publique (extraite des bundles JS de sinistar.ca)46 "x-algolia-api-key": "4dbe623a9ffc1181003e9d98cad4c05b",47 "Content-Type": "application/json",48}4950# Zone habitée du Québec (sud, ouest, nord, est) pour le quadrillage géo.51QC_BBOX = (44.95, -79.80, 62.00, -56.90)5253# type Sinistar → (type canonique Lou-Ka, libellé français pour le titre)54_TYPES = {55 "house": ("Maison", "Maison meublée"),56 "cityhouse": ("Maison", "Maison de ville meublée"),57 "semidetached": ("Maison", "Maison jumelée meublée"),58 "appartment": ("Appartement", "Appartement meublé"),59 "apartment": ("Appartement", "Appartement meublé"),60 "condo": ("Condo", "Condo meublé"),61 "cottage": ("Chalet", "Chalet meublé"),62 "loft": ("Loft", "Loft meublé"),63 "hotel": ("Auberge", "Hébergement hôtelier"),64}6566_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.S)676869def _num(v) -> float | None:70 try:71 return float(v) if v not in (None, "") else None72 except (TypeError, ValueError):73 return None747576def _decode_flight(html: str) -> str:77 """Concatène les segments React Flight (chaînes JS échappées → UTF-8)."""78 out = []79 for chunk in _FLIGHT_RE.findall(html):80 try:81 s = chunk.encode("utf-8").decode("unicode_escape")82 out.append(s.encode("latin-1", "replace").decode("utf-8", "replace"))83 except (UnicodeDecodeError, UnicodeEncodeError):84 continue85 return "".join(out)868788class Sinistar(StConnector):89 source_id = "sinistar"90 request_delay = 0.49192 # -- liste (Algolia, quadtree géo) ----------------------------------------93 def _query(self, params: str) -> dict:94 resp = self.post(ALGOLIA_URL, headers=ALGOLIA_HEADERS,95 json={"params": params})96 return resp.json()9798 def _all_hits(self) -> list[dict]:99 hits: list[dict] = []100 seen: set[str] = set()101 stack = [QC_BBOX]102 while stack:103 s, w, n, e = stack.pop()104 box = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"105 data = self._query(106 "hitsPerPage=1000"107 "&facetFilters=%5B%5B%22state%3AQC%22%5D%5D"108 f"&insideBoundingBox={box}")109 nb = data.get("nbHits") or 0110 if nb > 1000 and (n - s) > 0.002:111 # cellule saturée (limite Algolia) → subdivision en 4112 mlat, mlng = (s + n) / 2, (w + e) / 2113 stack.extend([(s, w, mlat, mlng), (s, mlng, mlat, e),114 (mlat, w, n, mlng), (mlat, mlng, n, e)])115 continue116 for h in data.get("hits") or []:117 oid = h.get("objectID") or ""118 if oid and oid not in seen:119 seen.add(oid)120 hits.append(h)121 return hits122123 # -- détail (page housing, cache BD) ---------------------------------------124 def _fetch_detail(self, reference: str) -> dict:125 resp = self.get(f"{SITE}/fr/housing/{reference}")126 blob = _decode_flight(resp.text)127 i = blob.find(f'"reference":{reference}')128 if i < 0:129 return {}130 line = blob[blob.rfind("\n", 0, i) + 1:]131 line = line[:line.find("\n")] if "\n" in line else line132 try:133 payload = json.loads(line.split(":", 1)[1])134 except (ValueError, IndexError):135 return {}136 housing = {}137138 def walk(o):139 nonlocal housing140 if housing:141 return142 if isinstance(o, dict):143 if str(o.get("reference")) == reference and "amenities" in o:144 housing = o145 return146 for v in o.values():147 walk(v)148 elif isinstance(o, list):149 for v in o:150 walk(v)151152 walk(payload)153 if not housing:154 return {}155 amenities = []156 for cat in (housing.get("amenities") or {}).values():157 for a in (cat if isinstance(cat, list) else []):158 if isinstance(a, str):159 amenities.append(a.replace("_", " ").strip())160 return {161 "description": (housing.get("description") or "").strip(),162 "amenities": amenities,163 "capacity": _num(housing.get("capacity")),164 }165166 # -- contrat --------------------------------------------------------------167 def fetch(self) -> list[StListing]:168 limit = int(os.environ.get("LOUKA_SINISTAR_LIMIT", "0") or 0)169 listings: list[StListing] = []170 seen: set[str] = set()171 for h in self._all_hits():172 ref = str(h.get("reference") or "").strip()173 city = (h.get("city") or "").strip()174 if not ref or ref in seen or not city:175 continue176 seen.add(ref)177178 geo = h.get("_geoloc") or {}179 lat, lng = _num(geo.get("lat")), _num(geo.get("lng"))180 ptype, label = _TYPES.get((h.get("type") or "").strip().lower(),181 ("Autre", "Logement meublé"))182 bedrooms = _num(h.get("bedrooms"))183 title = label184 if bedrooms:185 title += f" {int(bedrooms)} chambre{'s' if bedrooms > 1 else ''}"186 title += f" à {city}"187188 images = [p.get("url") for p in (h.get("pictures") or [])[:15]189 if isinstance(p.get("url"), str)190 and p["url"].startswith("https://")]191 if not images and isinstance(192 (h.get("coverPicture") or {}).get("url"), str):193 images = [h["coverPicture"]["url"]]194195 # clé de cache détail : sous-ensemble stable du hit liste196 key = json.dumps([ref, h.get("type"), h.get("bedrooms"),197 h.get("beds"), h.get("bathrooms"), len(images)],198 ensure_ascii=False)199 try:200 det = self.detail(ref, key, lambda r=ref: self._fetch_detail(r))201 except Exception as exc: # une fiche cassée ≠ annonce perdue202 print(f"[sinistar] détail {ref} en échec : {exc}",203 file=sys.stderr)204 det = {}205206 listings.append(StListing(207 source=self.source_id,208 external_id=ref,209 url=f"{SITE}/fr/housing/{ref}",210 title=title,211 property_type=ptype,212 city=city,213 region=_region_from_latlng(lat, lng),214 price_night=None, # tarif négocié avec l'assureur215 price_label="",216 capacity=det.get("capacity"),217 bedrooms=bedrooms,218 beds=_num(h.get("beds")),219 bathrooms=_num(h.get("bathrooms")),220 description=det.get("description") or "",221 amenities=det.get("amenities") or [],222 details={"relocation": True,223 "sinistar_type": h.get("type") or ""},224 images=images,225 lat=lat,226 lng=lng,227 ).finalize())228 if limit and len(listings) >= limit:229 break230 return listings231