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/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