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%
9.4 KB · 231 lines python
Raw Blame History
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