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%
19.4 KB · 415 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/mews.py : hôtels québécois en RÉSERVATION DIRECTE via le moteur4#   Mews (app.mews.com/distributor/<guid>) — 121 établissements du roster5#   data/hotels_engines.json (engine == "mews", GUID dans engine_ids.distributor).6#   1 fiche StListing par HÔTEL (pas par chambre).7#8# API (rétro-ingéniérie du bundle distributor-app.js 5821.0.0, JSON pur) :9#   base POST https://api.mews.com/api/bookingEngine/v1 + corps JSON avec10#   auth ANONYME embarquée dans le payload. En-têtes Origin/Referer11#   app.mews.com OBLIGATOIRES (sinon ~60 % des hôtels : « Operation is not12#   enabled on your current subscription ») :13#     client  = "Mews Distributor 5821.0.0"14#     session = (token + md5(token+client)).upper() où token = chaque caractère15#               de (4 octets aléatoires hex + nowUtc ISO) encodé en décimal16#               sur 3 chiffres (reproduction exacte du widget).17#   - /configurations/get   {ids:[guid], primaryId:guid}18#       → bookingEngines (serviceId, languageCode, currencyCode), services,19#         enterprises (nom/description localisés, adresse, tél, courriel,20#         imageId/introImageId, ianaTimeZoneIdentifier, pricing Net|Gross),21#         ageCategories (Adult/Child par service). CACHE détail « v1 ».22#   - /resourceCategories/getAll {serviceId, extent:{imageAssignments:true}}23#       → catégories de chambres (name/description localisés, normalBedCount,24#         extraBedCount) + imageAssignments (imageId ordonnés). CACHE « v1 ».25#   - /services/getAvailability {serviceId, bookingEngineId, startUtc, endUtc,26#         enterpriseId, fullAmounts:false, languageCode}27#       → dispo par catégorie. ⚠️ startUtc doit être MINUIT LOCAL de l'hôtel28#         converti en UTC (« StartUtc is not start of TimeUnit » sinon) —29#         calculé via zoneinfo(ianaTimeZoneIdentifier). FRAIS à chaque sync.30#   - /services/getPricing {…, occupancyData:[{ageCategoryId, personCount:2}]}31#       → categoryPrices[].occupancyPrices[].rateGroupPrices[].minPrice32#         .totalAmount {grossValue, netValue}. FRAIS à chaque sync.33#34# Mapping :35#   - external_id = GUID distributor ; url = booking_url du roster (résa directe).36#   - Séjour témoin : arrivée à J+21, 1 nuit, 2 adultes ; price_night = tarif37#     minimum toutes catégories DISPONIBLES (netValue si pricing Net — l'affichage38#     du widget, taxes en sus — sinon grossValue). Aucun prix → fiche quand même.39#   - capacity = occupation max de la PLUS GRANDE catégorie (normal+extra) ;40#     nb de catégories dans details["room_types"].41#   - Images : photo de l'hôtel + intro + galeries de chambres (cap 40), CDN42#     https://cdn.mews.com/media/image/<imageId>?quality=85 (vérifié 200).43#   - Textes localisés : fr-CA > fr-* > en-US/en-GB > premier disponible ;44#     description de l'enterprise souvent vide → paragraphe éditorial assemblé45#     à partir des faits (ville/région, catégories, capacité, résa directe).46#   - region / citq / repli city : join du roster par GUID.47#   - Circuit breaker : après 10 échecs consécutifs de sondage prix, les fiches48#     suivantes sortent sans prix (l'API config, cachée, continue seule).49#   - Robustesse : try/except PAR hôtel — un GUID mort ne fait jamais planter50#     fetch() (log [mews] sur stderr).51# -----------------------------------------------------------------------------52from __future__ import annotations5354import datetime as _dt55import hashlib56import json57import os58import sys59from pathlib import Path60from zoneinfo import ZoneInfo6162from ..schema import StListing63from .base import StConnector64from .sithotels import _property_type, _titre6566ROSTER = (Path(__file__).resolve().parent.parent.parent.parent67          / "data" / "hotels_engines.json")6869API = "https://api.mews.com/api/bookingEngine/v1"70CLIENT = "Mews Distributor 5821.0.0"71CDN = "https://cdn.mews.com/media/image/"7273MAX_IMAGES = 4074LEAD_DAYS = 21          # arrivée du séjour témoin (J+21, 1 nuit, 2 adultes)75BREAKER_LIMIT = 10      # échecs prix consécutifs avant arrêt des sondages7677# spaceType Mews qui ne sont PAS de l'hébergement (vu : STATIONNEMENT vendu78# comme catégorie ParkingSpot au Griffintown) — exclus des types de chambres,79# de la capacité ET du minimum de prix80NON_LODGING_SPACES = {"ParkingSpot", "MeetingRoom", "Office", "Desk",81                      "Coworking", "EventVenue"}828384def _session_token() -> str:85    """Reproduit le jeton de session anonyme du widget : (4 octets aléatoires86    en hex + nowUtc ISO), chaque caractère encodé en décimal sur 3 chiffres."""87    raw = os.urandom(4).hex() + _dt.datetime.now(_dt.timezone.utc) \88        .strftime("%Y-%m-%dT%H:%M:%SZ")89    return "".join(f"{ord(c):03d}" for c in raw)909192def _loc(d: dict | None) -> str:93    """Choisit la meilleure variante localisée : fr-CA > fr-* > en > première."""94    if not isinstance(d, dict) or not d:95        return ""96    for k in ("fr-CA", "fr-FR"):97        if d.get(k):98            return str(d[k]).strip()99    for k, v in d.items():100        if k.lower().startswith("fr") and v:101            return str(v).strip()102    for k in ("en-US", "en-GB"):103        if d.get(k):104            return str(d[k]).strip()105    for v in d.values():106        if v:107            return str(v).strip()108    return ""109110111def _img(image_id: str, width: int = 1600) -> str:112    return f"{CDN}{image_id}?quality=85&width={width}"113114115class Mews(StConnector):116    source_id = "mews"117    request_delay = 0.4118    timeout = 40119120    def __init__(self) -> None:121        super().__init__()122        token = _session_token()123        self._auth = {124            "client": CLIENT,125            "session": (token + hashlib.md5(126                (token + CLIENT).encode()).hexdigest()).upper(),127        }128129    # -- API ------------------------------------------------------------------130    def _call(self, path: str, payload: dict, guid: str = "") -> dict:131        """POST bookingEngine v1. ⚠️ Origin + Referer OBLIGATOIRES : sans eux,132        ~60 % des hôtels (plans Mews restreints) répondent « Operation is not133        enabled on your current subscription » — vérifié live."""134        resp = self.post(API + path, json={**self._auth, **payload},135                         headers={"Content-Type": "application/json",136                                  "X-Accept-Casing": "Camel",137                                  "Origin": "https://app.mews.com",138                                  "Referer": "https://app.mews.com/distributor/"139                                             + guid})140        return resp.json()141142    def _config(self, guid: str) -> dict:143        """Configuration + catégories de chambres (payload cachable « v1 »)."""144        conf = self._call("/configurations/get",145                          {"ids": [guid], "primaryId": guid}, guid)146        engines = conf.get("bookingEngines") or []147        engine = next((b for b in engines if b.get("id") == guid),148                      engines[0] if engines else {})149        svc_id = engine.get("serviceId") or ""150        rooms = {}151        if svc_id:152            rooms = self._call("/resourceCategories/getAll",153                               {"serviceId": svc_id,154                                "extent": {"imageAssignments": True}}, guid)155        return {"conf": conf, "rooms": rooms}156157    # -- prix (frais à chaque sync : jamais caché) ------------------------------158    def _probe_price(self, guid: str, conf: dict, engine: dict,159                     enterprise: dict,160                     lodging_ids: set[str] | None = None) -> float | None:161        """Tarif minimum (1 nuit à J+21, 2 adultes) toutes catégories dispo."""162        svc_id = engine.get("serviceId") or ""163        ent_id = enterprise.get("id") or ""164        if not svc_id or not ent_id:165            return None166        try:167            tz = ZoneInfo(enterprise.get("ianaTimeZoneIdentifier")168                          or "America/Montreal")169        except Exception:  # noqa: BLE001170            tz = ZoneInfo("America/Montreal")171        # ⚠️ startUtc doit être le début d'un TimeUnit = minuit LOCAL en UTC172        day = _dt.date.today() + _dt.timedelta(days=LEAD_DAYS)173        start = _dt.datetime(day.year, day.month, day.day, tzinfo=tz) \174            .astimezone(_dt.timezone.utc)175        end = start + _dt.timedelta(days=1)176        fmt = "%Y-%m-%dT%H:%M:%SZ"177        lang = engine.get("languageCode") or "fr-CA"178        currency = engine.get("currencyCode") or "CAD"179180        adult = next((a.get("id") for a in conf.get("ageCategories") or []181                      if a.get("serviceId") == svc_id182                      and a.get("classification") == "Adult"), None) \183            or next((a.get("id") for a in conf.get("ageCategories") or []184                     if a.get("serviceId") == svc_id), None)185        if not adult:186            return None187188        # catégories réellement disponibles pour la nuit témoin189        available: set[str] | None = None190        try:191            av = self._call("/services/getAvailability", {192                "serviceId": svc_id, "bookingEngineId": guid,193                "startUtc": start.strftime(fmt), "endUtc": end.strftime(fmt),194                "enterpriseId": ent_id, "fullAmounts": False,195                "languageCode": lang,196            }, guid)197            available = {c.get("categoryId")198                         for c in av.get("categoryAvailabilities") or []199                         if (c.get("availabilities") or [0])[0] > 0}200        except Exception:  # noqa: BLE001201            available = None      # dispo inconnue : on prend le min global202203        pricing = self._call("/services/getPricing", {204            "serviceId": svc_id, "bookingEngineId": guid,205            "enterpriseId": ent_id,206            "startUtc": start.strftime(fmt), "endUtc": end.strftime(fmt),207            "occupancyData": [{"ageCategoryId": adult, "personCount": 2}],208            "currencyCode": currency, "languageCode": lang,209            "fullAmounts": False, "categoryIds": None, "productIds": None,210            "voucherCode": None, "availabilityBlockId": None,211        }, guid)212        # affichage du widget : net (taxes en sus) si pricing Net, sinon brut213        net_first = (enterprise.get("pricing") or "Net") == "Net"214        best = None215        for cp in pricing.get("categoryPrices") or []:216            if lodging_ids is not None \217                    and cp.get("categoryId") not in lodging_ids:218                continue          # stationnement, salle de réunion…219            if available and cp.get("categoryId") not in available:220                continue221            for op in cp.get("occupancyPrices") or []:222                for rg in op.get("rateGroupPrices") or []:223                    ta = (rg.get("minPrice") or {}).get("totalAmount") or {}224                    val = (ta.get("netValue") if net_first225                           else ta.get("grossValue"))226                    if val is None:227                        val = ta.get("grossValue") if net_first \228                            else ta.get("netValue")229                    if isinstance(val, (int, float)) and 20 <= val <= 20000 \230                            and (best is None or val < best):231                        best = float(val)232        return best233234    # -- une fiche par hôtel -----------------------------------------------------235    def _listing(self, hotel: dict, price_ok: bool) -> tuple[StListing, bool]:236        """Construit la fiche ; retourne (listing, échec_du_sondage_prix)."""237        guid = str(hotel["engine_ids"]["distributor"]).strip()238        det = self.detail(guid, "v1", lambda g=guid: self._config(g))239        conf = det.get("conf") or {}240        rooms = det.get("rooms") or {}241242        engines = conf.get("bookingEngines") or []243        engine = next((b for b in engines if b.get("id") == guid),244                      engines[0] if engines else {})245        svc_id = engine.get("serviceId") or ""246        service = next((s for s in conf.get("services") or []247                        if s.get("id") == svc_id), {})248        enterprises = conf.get("enterprises") or []249        enterprise = next((e for e in enterprises250                           if e.get("id") == service.get("enterpriseId")),251                          enterprises[0] if enterprises else {})252        if not enterprise:253            raise ValueError("configuration Mews sans enterprise")254255        addr = enterprise.get("address") or {}256        title = _loc(enterprise.get("name")) or _titre(hotel.get("name") or "")257        city = str(addr.get("city") or "").strip() or hotel.get("city") or ""258259        # catégories de chambres (hébergement seulement) : capacité, noms, galerie260        cats = sorted((c for c in rooms.get("resourceCategories") or []261                       if c.get("spaceType") not in NON_LODGING_SPACES),262                      key=lambda c: c.get("ordering") or 0)263        lodging_ids = {c.get("id") for c in cats}264        capacity = None265        room_names: list[str] = []266        for c in cats:267            occ = (c.get("normalBedCount") or 0) + (c.get("extraBedCount") or 0)268            if occ and (capacity is None or occ > capacity):269                capacity = occ270            nom = _loc(c.get("name"))271            if nom and nom not in room_names:272                room_names.append(nom)273274        images: list[str] = []275        for iid in (enterprise.get("imageId"),276                    enterprise.get("introImageId")):277            if iid:278                u = _img(iid)279                if u not in images:280                    images.append(u)281        cat_order = {c.get("id"): c.get("ordering") or 0 for c in cats}282        assigns = sorted((a for a in rooms.get("imageAssignments") or []283                          if a.get("categoryId") in lodging_ids),284                         key=lambda a: (cat_order.get(a.get("categoryId"), 99),285                                        a.get("ordering") or 0))286        for a in assigns:287            if len(images) >= MAX_IMAGES:288                break289            if a.get("imageId"):290                u = _img(a["imageId"])291                if u not in images:292                    images.append(u)293294        # prix frais (jamais caché) — sauté si le circuit breaker est ouvert ;295        # un échec du sondage ne fait jamais tomber la fiche (prix absent)296        price, price_error = None, False297        if price_ok:298            try:299                price = self._probe_price(guid, conf, engine, enterprise,300                                          lodging_ids or None)301            except Exception as exc:  # noqa: BLE001302                price_error = True303                print(f"[mews] prix {title} ({guid}) : {exc}",304                      file=sys.stderr)305306        ptype = _property_type(title)307        # « Saguenay--Lac-Saint-Jean » (variante SIT) → tiret cadratin canon308        region = str(hotel.get("region") or "").replace("--", "–")309310        # description : celle de l'hôtel si renseignée, sinon assemblage factuel311        description = _loc(enterprise.get("description"))312        if not description:313            feminin = ptype == "Auberge"314            phrases = [f"{title} est un{'e' if feminin else ''} "315                       f"{ptype.lower()}"316                       + (f" situé{'e' if feminin else ''} à {city}" if city317                          else "")318                       + (f", dans la région {region}" if region else "")319                       + "."]320            if room_names:321                phrases.append(f"{len(room_names)} type"322                               f"{'s' if len(room_names) > 1 else ''} "323                               "d'unités : " + ", ".join(room_names[:8])324                               + ("…" if len(room_names) > 8 else "") + ".")325            if capacity:326                phrases.append("Les plus grandes unités accueillent jusqu'à "327                               f"{capacity:g} personnes.")328            phrases.append("Réservation directe en ligne auprès de "329                           "l'établissement (moteur Mews).")330            if hotel.get("citq"):331                phrases.append("Établissement d'hébergement touristique "332                               f"enregistré (no CITQ {hotel['citq']}).")333            description = " ".join(phrases)334335        details: dict = {"booking_engine": "mews"}336        if cats:337            details["room_types"] = len(cats)338        if room_names:339            details["room_type_names"] = ", ".join(room_names[:12])340        if enterprise.get("telephone"):341            details["phone"] = enterprise["telephone"]342        if enterprise.get("email"):343            details["email"] = enterprise["email"]344        if addr.get("postalCode"):345            details["postal_code"] = addr["postalCode"]346        if hotel.get("website"):347            details["website"] = hotel["website"]348349        # géoloc/adresse : Mews d'abord, sinon repli sur le SIT (via le roster,350        # enrichi lat/lng/address pour les 1 174 hôtels sondés)351        lat, lng = addr.get("latitude"), addr.get("longitude")352        if lat is None or lng is None:353            lat, lng = hotel.get("lat"), hotel.get("lng")354        address = str(addr.get("line1") or "").strip() \355            or str(hotel.get("address") or "").strip()356357        return StListing(358            source=self.source_id,359            external_id=guid,360            url=hotel.get("booking_url")361            or f"https://app.mews.com/distributor/{guid}",362            title=title,363            property_type=ptype,364            address=address,365            city=city,366            region=region,367            price_night=price,368            price_label=(f"À partir de {price:g} $ / nuit" if price else ""),369            capacity=float(capacity) if capacity else None,370            citq=str(hotel.get("citq") or "").strip(),371            description=description[:5000],372            details=details,373            images=images,374            lat=float(lat) if lat is not None else None,375            lng=float(lng) if lng is not None else None,376        ), price_error377378    # -- contrat ----------------------------------------------------------------379    def fetch(self) -> list[StListing]:380        try:381            roster = json.loads(ROSTER.read_text(encoding="utf-8"))382        except Exception as exc:  # noqa: BLE001383            print(f"[mews] roster illisible ({ROSTER}) : {exc}",384                  file=sys.stderr)385            return []386        hotels = [h for h in roster.get("hotels") or []387                  if h.get("engine") == "mews"388                  and (h.get("engine_ids") or {}).get("distributor")]389390        listings: list[StListing] = []391        vus: set[str] = set()392        price_failures = 0           # circuit breaker sur le sondage des prix393        for hotel in hotels:394            guid = str(hotel["engine_ids"]["distributor"]).strip()395            if not guid or guid in vus:396                continue397            vus.add(guid)398            try:399                lst, price_error = self._listing(400                    hotel, price_ok=price_failures < BREAKER_LIMIT)401            except Exception as exc:  # noqa: BLE001402                print(f"[mews] {hotel.get('name')} ({guid}) : {exc}",403                      file=sys.stderr)404                continue405            if price_error:406                price_failures += 1407                if price_failures == BREAKER_LIMIT:408                    print(f"[mews] {BREAKER_LIMIT} échecs de sondage de prix "409                          "consécutifs : arrêt des requêtes prix (fiches "410                          "sans prix ensuite)", file=sys.stderr)411            elif price_failures < BREAKER_LIMIT:412                price_failures = 0413            listings.append(lst)414        return listings415