# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/reservit.py : hôtels québécois en RÉSERVATION DIRECTE via le # moteur Reservit (Interface Technologies, secure.reservit.com) — 247 # établissements du roster data/hotels_engines.json (engine == "reservit", # engine_ids.hotelid requis ; custid surtout 58 = grossiste Québec). # 1 fiche StListing par HÔTEL (pas par chambre). # # API (rétro-ingéniérie du bundle Angular main.a1e6bc8d6849c9de.js — front # moderne « /fo/booking » ; JSON pur, AUCUN anti-bot, requêtes directes) : # base = https://secure.reservit.com/front{custid}-0-{hotelid}-{tabid} # où tabid = identifiant de 8 caractères GÉNÉRÉ CLIENT (arbitraire). # - GET {base}/front.do?m=booking&mode=light&custid=&id=&hotelid=&partid=0 # &langcode=FR (en-têtes Accept: application/json + X-MultiTabID) # → hotel {name, address, city, zipcode, phone, email, url, latitude, # longitude, infosTxt, nearAttrTxt, chkin, curcode, licenseNumber # (= no CITQ), endSaleP (fin de mise en vente DD/MM/YYYY — dans le # PASSÉ ⇒ hôtel mort sur Reservit), photos[] (media.reservit.com), # services[{name,id}]} + user {token, sessionID}. CACHE détail « v1 ». # - GET {base}/booking.do?step=2&fromdate=DD/MM/YYYY&todate=…&nbNight=1 # &roomID=1&nbRooms=1&numAdult(1)=2&numChild(1)=0&agesWithRoomID(1)= # &fromStep=step1¤cy=CAD (+ custid/id/hotelid/partid/langcode) # en-têtes X-SessionID + X-MultiTabID + Authorization: Bearer # + cookie JSESSIONID= (session/token FRAIS d'un front.do # light : jamais cachés) → datas.rooms[].rates[].price.amountBeforeTax # (l'affichage du widget, taxes en sus). Dates au format FR DD/MM/YYYY # (« ReservitFuckedPattern » dans le bundle, sic). FRAIS à chaque sync. # Écartés après essais live : /jreservit/recupSummaryAvailability.do et # POST /api/rs/secure/calendar/price/… (calendrier tout fermé / prix -1), # /rsl/booking/indexmodal.php (customerid ≠ hotelid, inutilisable en 58). # # Mapping : # - external_id = hotelid ; url = booking_url du roster (résa directe). # - custid : engine_ids.custid si numérique ≤ 6 chiffres, sinon extrait du # booking_url, sinon repli « 58 » (vérifié : un custid poubelle — id # Facebook — fonctionne avec 58). # - Séjour témoin : arrivée à J+21, 1 nuit, 2 adultes ; price_night = # minimum de rates[].price.amountBeforeTax toutes chambres retournées. # « Aucune chambre… » / hôtel fermé → fiche quand même, sans prix. # - title/photos/adresse/geo/CITQ/commodités : front.do light (caché) ; # region + ville joliment casée + citq de repli : join du roster. # - capacity = max « N PERSONNES » des typeName du sondage prix (frais). # - Budget : LOUKA_RESERVIT_DETAIL_LIMIT (défaut 250) sondages de prix par # sync (2 requêtes chacun) ; circuit breaker après 10 échecs consécutifs. # - Robustesse : try/except PAR hôtel (log [reservit] sur stderr). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime as _dt import html as _html import json import os import re import secrets import sys from pathlib import Path from urllib.parse import quote import requests from ...connectors._resilient import _secret from ..schema import StListing from .base import StConnector from .sithotels import _property_type, _titre ROSTER = (Path(__file__).resolve().parent.parent.parent.parent / "data" / "hotels_engines.json") HOST = "https://secure.reservit.com" MAX_IMAGES = 20 LEAD_DAYS = 21 # arrivée du séjour témoin (J+21, 1 nuit, 2 adultes) BREAKER_LIMIT = 10 # échecs prix consécutifs avant arrêt des sondages DEFAULT_CUSTID = "58" # grossiste Québec (208 des 247 hôtels du roster) _CAPACITY_RE = re.compile(r"(\d+)\s*PERSONNES", re.I) _BOOKING_URL_CUSTID_RE = re.compile(r"reservit\.com/engine/booking/(\d+)/") def _tabid() -> str: """Reproduit le multiTabID du widget : identifiant client arbitraire.""" return secrets.token_hex(4) _UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") def _sticky_session() -> requests.Session: """Session HTTP dédiée à UN hôtel. secure.reservit.com lie JSESSIONID et token à l'IP appelante : le direct est banni depuis ce nœud après quelques centaines de requêtes, et le proxy TOURNANT de l'escalade standard change d'IP entre front.do et booking.do → « Error Security Filter ». Il faut une IP résidentielle COLLANTE (sessid Oxylabs, ~10 min) le temps des 2 appels.""" s = requests.Session() s.headers["User-Agent"] = _UA user, pwd = _secret("OXYLABS_PROXY_USER"), _secret("OXYLABS_PROXY_PASS") if user and pwd: endpoint = _secret("OXYLABS_PROXY") or "pr.oxylabs.io:7777" puser = f"{user}-cc-CA-sessid-{secrets.token_hex(5)}-sesstime-10" proxy = (f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}" f"@{endpoint}") s.proxies = {"http": proxy, "https": proxy} s.verify = False # noqa: S501 — CA MITM Oxylabs return s def _custid(hotel: dict) -> str: """custid numérique plausible (≤ 6 chiffres), sinon booking_url, sinon 58.""" raw = str((hotel.get("engine_ids") or {}).get("custid") or "").strip() if raw.isdigit() and len(raw) <= 6: return raw m = _BOOKING_URL_CUSTID_RE.search(str(hotel.get("booking_url") or "")) if m and len(m.group(1)) <= 6: return m.group(1) return DEFAULT_CUSTID def _past(date_fr: str) -> bool: """endSaleP « DD/MM/YYYY » dans le passé ⇒ hôtel mort sur Reservit.""" try: d, m, y = str(date_fr or "").strip().split("/") return _dt.date(int(y), int(m), int(d)) < _dt.date.today() except Exception: # noqa: BLE001 return False def _clean(txt: str) -> str: """Déséchappe le HTML (y compris les entités cp1252 « œ » → « œ » vues dans les infosTxt Reservit) et normalise les espaces.""" txt = _html.unescape(str(txt or "")) txt = "".join(bytes([ord(c)]).decode("cp1252", "ignore") if 0x80 <= ord(c) <= 0x9F else c for c in txt) return re.sub(r"[ \t]+", " ", txt).strip() class Reservit(StConnector): source_id = "reservit" request_delay = 0.5 timeout = 40 # -- front.do « light » : métadonnées hôtel + session/token ---------------- def _front_light(self, cid: str, hid: str, sess: requests.Session) -> dict: tab = _tabid() base = f"{HOST}/front{cid}-0-{hid}-{tab}" resp = sess.get(base + "/front.do", params={"m": "booking", "mode": "light", "custid": cid, "id": cid, "hotelid": hid, "partid": "0", "langcode": "FR"}, headers={"Accept": "application/json", "X-MultiTabID": tab}, timeout=self.timeout) resp.raise_for_status() d = resp.json() d["_base"], d["_tab"] = base, tab return d def _meta(self, cid: str, hid: str) -> dict: """Payload STATIQUE cachable « v1 » : l'objet hotel épuré (le token et la session, périssables, ne sont jamais cachés).""" h = self._front_light(cid, hid, _sticky_session()).get("hotel") or {} keep = ("name", "address", "city", "zipcode", "phone", "email", "url", "latitude", "longitude", "infosTxt", "nearAttrTxt", "chkin", "curcode", "minStay", "licenseNumber", "endSaleP") meta = {k: h.get(k) for k in keep if h.get(k) not in (None, "")} meta["photos"] = [p for p in h.get("photos") or [] if isinstance(p, str) and p.startswith("http")] meta["services"] = [s.get("name") for s in h.get("services") or [] if isinstance(s, dict) and s.get("name")] return {"hotel": meta} # -- prix (frais à chaque sync : jamais caché) ------------------------------ def _probe_price(self, cid: str, hid: str) -> tuple[float | None, dict]: """Tarif minimum (1 nuit à J+21, 2 adultes) toutes chambres proposées. Retourne (prix, extras {capacity, room_count}) — (None, {}) si l'hôtel est fermé ou sans disponibilité (pas une erreur). Les 2 appels (front.do puis booking.do) partagent une session à IP collante ; un 2e essai avec une session neuve absorbe les IP mal notées.""" rooms: list = [] for essai in (1, 2): http = _sticky_session() d = self._front_light(cid, hid, http) hotel, user = d.get("hotel") or {}, d.get("user") or {} if _past(hotel.get("endSaleP")): return None, {} # hôtel mort sur Reservit token, sess = user.get("token"), user.get("sessionID") if not token or not sess: return None, {} day = _dt.date.today() + _dt.timedelta(days=LEAD_DAYS) f_fr = day.strftime("%d/%m/%Y") t_fr = (day + _dt.timedelta(days=1)).strftime("%d/%m/%Y") resp = http.get(d["_base"] + "/booking.do", params={"step": "2", "custid": cid, "id": cid, "hotelid": hid, "partid": "0", "langcode": "FR", "currency": "CAD", "fromStep": "step1", "fromdate": f_fr, "todate": t_fr, "fromDate": f_fr, "toDate": t_fr, "nbNight": "1", "roomID": "1", "nbRooms": "1", "numAdult(1)": "2", "numChild(1)": "0", "agesWithRoomID(1)": ""}, headers={"Accept": "application/json", "X-SessionID": sess, "X-MultiTabID": d["_tab"], "Authorization": "Bearer " + token}, cookies={"JSESSIONID": sess}, timeout=self.timeout) try: resp.raise_for_status() rooms = ((resp.json().get("datas") or {}).get("rooms")) or [] break except Exception: # noqa: BLE001 — filtre sécurité / non-JSON if essai == 2: raise best, capacity = None, None for r in rooms: typ = r.get("type") or {} m = _CAPACITY_RE.search(str(typ.get("typeName") or "")) if m and (capacity is None or int(m.group(1)) > capacity): capacity = int(m.group(1)) for rate in r.get("rates") or []: p = rate.get("price") or {} val = p.get("amountBeforeTax") if not isinstance(val, (int, float)): val = p.get("amountAfterTax") if isinstance(val, (int, float)) and 20 <= val <= 20000 \ and (best is None or val < best): best = round(float(val), 2) extras: dict = {} if rooms: extras["room_count"] = len(rooms) if capacity: extras["capacity"] = capacity return best, extras # -- une fiche par hôtel ----------------------------------------------------- def _listing(self, hotel: dict, price_ok: bool) -> tuple[StListing, bool]: """Construit la fiche ; retourne (listing, échec_du_sondage_prix).""" hid = str(hotel["engine_ids"]["hotelid"]).strip() cid = _custid(hotel) # métadonnées Reservit (cachées) — leur absence ne tue pas la fiche : # le join SIT du roster (nom, ville, région, CITQ) a de la valeur seul meta: dict = {} try: meta = self.detail(hid, "v1", lambda c=cid, h=hid: self._meta(c, h)) \ .get("hotel") or {} except Exception as exc: # noqa: BLE001 print(f"[reservit] métadonnées {hotel.get('name')} ({hid}) : " f"{exc}", file=sys.stderr) title = _clean(meta.get("name")) or _titre(hotel.get("name") or "") if title == title.upper(): # « HOTEL RIMOUSKI » → recasage SIT title = _titre(title) city = _clean(hotel.get("city")) or _titre(_clean(meta.get("city"))) region = str(hotel.get("region") or "").replace("--", "–") ptype = _property_type(title) # prix frais (jamais caché) — sauté si breaker ouvert / budget épuisé / # hôtel mort ; un échec du sondage ne fait jamais tomber la fiche price, extras, price_error = None, {}, False if price_ok and not _past(meta.get("endSaleP")): try: price, extras = self._probe_price(cid, hid) except Exception as exc: # noqa: BLE001 price_error = True print(f"[reservit] prix {title} ({cid}/{hid}) : {exc}", file=sys.stderr) # description : textes de l'hôtel (infos + à proximité), sinon # paragraphe éditorial assemblé à partir des faits parts = [_clean(p) for p in (meta.get("infosTxt"), meta.get("nearAttrTxt")) if p] if parts: description = parts[0] if len(parts) > 1: description += "\n\nÀ proximité : " + parts[1] else: feminin = ptype == "Auberge" phrases = [f"{title} est un{'e' if feminin else ''} " f"{ptype.lower()}" + (f" situé{'e' if feminin else ''} à {city}" if city else "") + (f", dans la région {region}" if region else "") + ". Réservation directe en ligne auprès de " "l'établissement (moteur Reservit)."] if hotel.get("citq"): phrases.append("Établissement d'hébergement touristique " f"enregistré (no CITQ {hotel['citq']}).") description = " ".join(phrases) details: dict = {"booking_engine": "reservit"} if extras.get("room_count"): details["room_types"] = extras["room_count"] if meta.get("phone"): details["phone"] = _clean(meta["phone"]) if meta.get("email"): details["email"] = _clean(meta["email"]) if meta.get("zipcode"): details["postal_code"] = _clean(meta["zipcode"]) if meta.get("chkin"): details["checkin"] = str(meta["chkin"])[:5] site = _clean(meta.get("url")) if site.startswith("http") and "reservit.com" not in site: details["website"] = site elif hotel.get("website") \ and "reservit.com" not in str(hotel["website"]): details["website"] = hotel["website"] lat, lng = meta.get("latitude"), meta.get("longitude") return StListing( source=self.source_id, external_id=hid, url=hotel.get("booking_url") or f"{HOST}/engine/booking/{cid}/{hid}/dates?langcode=FR", title=title, property_type=ptype, address=_clean(meta.get("address")), city=city, region=region, price_night=price, price_label=(f"À partir de {price:g} $ / nuit" if price else ""), capacity=(float(extras["capacity"]) if extras.get("capacity") else None), citq=str(hotel.get("citq") or meta.get("licenseNumber") or "").strip(), description=description[:5000], amenities=[_clean(s) for s in meta.get("services") or []], details=details, images=(meta.get("photos") or [])[:MAX_IMAGES], lat=float(lat) if lat is not None else None, lng=float(lng) if lng is not None else None, ), price_error # -- contrat ---------------------------------------------------------------- def fetch(self) -> list[StListing]: try: roster = json.loads(ROSTER.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 print(f"[reservit] roster illisible ({ROSTER}) : {exc}", file=sys.stderr) return [] hotels = [h for h in roster.get("hotels") or [] if h.get("engine") == "reservit" and (h.get("engine_ids") or {}).get("hotelid")] budget = int(os.environ.get("LOUKA_RESERVIT_DETAIL_LIMIT", "250")) listings: list[StListing] = [] vus: set[str] = set() probes = 0 price_failures = 0 # circuit breaker sur le sondage des prix for hotel in hotels: hid = str(hotel["engine_ids"]["hotelid"]).strip() if not hid or hid in vus: continue vus.add(hid) price_ok = price_failures < BREAKER_LIMIT and probes < budget if price_ok: probes += 1 try: lst, price_error = self._listing(hotel, price_ok=price_ok) except Exception as exc: # noqa: BLE001 print(f"[reservit] {hotel.get('name')} ({hid}) : {exc}", file=sys.stderr) continue if price_error: price_failures += 1 if price_failures == BREAKER_LIMIT: print(f"[reservit] {BREAKER_LIMIT} échecs de sondage de " "prix consécutifs : arrêt des requêtes prix " "(fiches sans prix ensuite)", file=sys.stderr) elif price_failures < BREAKER_LIMIT: price_failures = 0 listings.append(lst) return listings