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/reservit.py : hôtels québécois en RÉSERVATION DIRECTE via le4# moteur Reservit (Interface Technologies, secure.reservit.com) — 2475# établissements du roster data/hotels_engines.json (engine == "reservit",6# engine_ids.hotelid requis ; custid surtout 58 = grossiste Québec).7# 1 fiche StListing par HÔTEL (pas par chambre).8#9# API (rétro-ingéniérie du bundle Angular main.a1e6bc8d6849c9de.js — front10# moderne « /fo/booking » ; JSON pur, AUCUN anti-bot, requêtes directes) :11# base = https://secure.reservit.com/front{custid}-0-{hotelid}-{tabid}12# où tabid = identifiant de 8 caractères GÉNÉRÉ CLIENT (arbitraire).13# - GET {base}/front.do?m=booking&mode=light&custid=&id=&hotelid=&partid=014# &langcode=FR (en-têtes Accept: application/json + X-MultiTabID)15# → hotel {name, address, city, zipcode, phone, email, url, latitude,16# longitude, infosTxt, nearAttrTxt, chkin, curcode, licenseNumber17# (= no CITQ), endSaleP (fin de mise en vente DD/MM/YYYY — dans le18# PASSÉ ⇒ hôtel mort sur Reservit), photos[] (media.reservit.com),19# services[{name,id}]} + user {token, sessionID}. CACHE détail « v1 ».20# - GET {base}/booking.do?step=2&fromdate=DD/MM/YYYY&todate=…&nbNight=121# &roomID=1&nbRooms=1&numAdult(1)=2&numChild(1)=0&agesWithRoomID(1)=22# &fromStep=step1¤cy=CAD (+ custid/id/hotelid/partid/langcode)23# en-têtes X-SessionID + X-MultiTabID + Authorization: Bearer <token>24# + cookie JSESSIONID=<sessionID> (session/token FRAIS d'un front.do25# light : jamais cachés) → datas.rooms[].rates[].price.amountBeforeTax26# (l'affichage du widget, taxes en sus). Dates au format FR DD/MM/YYYY27# (« ReservitFuckedPattern » dans le bundle, sic). FRAIS à chaque sync.28# Écartés après essais live : /jreservit/recupSummaryAvailability.do et29# POST /api/rs/secure/calendar/price/… (calendrier tout fermé / prix -1),30# /rsl/booking/indexmodal.php (customerid ≠ hotelid, inutilisable en 58).31#32# Mapping :33# - external_id = hotelid ; url = booking_url du roster (résa directe).34# - custid : engine_ids.custid si numérique ≤ 6 chiffres, sinon extrait du35# booking_url, sinon repli « 58 » (vérifié : un custid poubelle — id36# Facebook — fonctionne avec 58).37# - Séjour témoin : arrivée à J+21, 1 nuit, 2 adultes ; price_night =38# minimum de rates[].price.amountBeforeTax toutes chambres retournées.39# « Aucune chambre… » / hôtel fermé → fiche quand même, sans prix.40# - title/photos/adresse/geo/CITQ/commodités : front.do light (caché) ;41# region + ville joliment casée + citq de repli : join du roster.42# - capacity = max « N PERSONNES » des typeName du sondage prix (frais).43# - Budget : LOUKA_RESERVIT_DETAIL_LIMIT (défaut 250) sondages de prix par44# sync (2 requêtes chacun) ; circuit breaker après 10 échecs consécutifs.45# - Robustesse : try/except PAR hôtel (log [reservit] sur stderr).46# -----------------------------------------------------------------------------47from __future__ import annotations4849import datetime as _dt50import html as _html51import json52import os53import re54import secrets55import sys56from pathlib import Path57from urllib.parse import quote5859import requests6061from ...connectors._resilient import _secret62from ..schema import StListing63from .base import StConnector64from .sithotels import _property_type, _titre6566ROSTER = (Path(__file__).resolve().parent.parent.parent.parent67 / "data" / "hotels_engines.json")6869HOST = "https://secure.reservit.com"70MAX_IMAGES = 2071LEAD_DAYS = 21 # arrivée du séjour témoin (J+21, 1 nuit, 2 adultes)72BREAKER_LIMIT = 10 # échecs prix consécutifs avant arrêt des sondages73DEFAULT_CUSTID = "58" # grossiste Québec (208 des 247 hôtels du roster)7475_CAPACITY_RE = re.compile(r"(\d+)\s*PERSONNES", re.I)76_BOOKING_URL_CUSTID_RE = re.compile(r"reservit\.com/engine/booking/(\d+)/")777879def _tabid() -> str:80 """Reproduit le multiTabID du widget : identifiant client arbitraire."""81 return secrets.token_hex(4)828384_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "85 "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")868788def _sticky_session() -> requests.Session:89 """Session HTTP dédiée à UN hôtel. secure.reservit.com lie JSESSIONID et90 token à l'IP appelante : le direct est banni depuis ce nœud après quelques91 centaines de requêtes, et le proxy TOURNANT de l'escalade standard change92 d'IP entre front.do et booking.do → « Error Security Filter ». Il faut une93 IP résidentielle COLLANTE (sessid Oxylabs, ~10 min) le temps des 2 appels."""94 s = requests.Session()95 s.headers["User-Agent"] = _UA96 user, pwd = _secret("OXYLABS_PROXY_USER"), _secret("OXYLABS_PROXY_PASS")97 if user and pwd:98 endpoint = _secret("OXYLABS_PROXY") or "pr.oxylabs.io:7777"99 puser = f"{user}-cc-CA-sessid-{secrets.token_hex(5)}-sesstime-10"100 proxy = (f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}"101 f"@{endpoint}")102 s.proxies = {"http": proxy, "https": proxy}103 s.verify = False # noqa: S501 — CA MITM Oxylabs104 return s105106107def _custid(hotel: dict) -> str:108 """custid numérique plausible (≤ 6 chiffres), sinon booking_url, sinon 58."""109 raw = str((hotel.get("engine_ids") or {}).get("custid") or "").strip()110 if raw.isdigit() and len(raw) <= 6:111 return raw112 m = _BOOKING_URL_CUSTID_RE.search(str(hotel.get("booking_url") or ""))113 if m and len(m.group(1)) <= 6:114 return m.group(1)115 return DEFAULT_CUSTID116117118def _past(date_fr: str) -> bool:119 """endSaleP « DD/MM/YYYY » dans le passé ⇒ hôtel mort sur Reservit."""120 try:121 d, m, y = str(date_fr or "").strip().split("/")122 return _dt.date(int(y), int(m), int(d)) < _dt.date.today()123 except Exception: # noqa: BLE001124 return False125126127def _clean(txt: str) -> str:128 """Déséchappe le HTML (y compris les entités cp1252 « œ » → « œ »129 vues dans les infosTxt Reservit) et normalise les espaces."""130 txt = _html.unescape(str(txt or ""))131 txt = "".join(bytes([ord(c)]).decode("cp1252", "ignore")132 if 0x80 <= ord(c) <= 0x9F else c for c in txt)133 return re.sub(r"[ \t]+", " ", txt).strip()134135136class Reservit(StConnector):137 source_id = "reservit"138 request_delay = 0.5139 timeout = 40140141 # -- front.do « light » : métadonnées hôtel + session/token ----------------142 def _front_light(self, cid: str, hid: str,143 sess: requests.Session) -> dict:144 tab = _tabid()145 base = f"{HOST}/front{cid}-0-{hid}-{tab}"146 resp = sess.get(base + "/front.do",147 params={"m": "booking", "mode": "light",148 "custid": cid, "id": cid, "hotelid": hid,149 "partid": "0", "langcode": "FR"},150 headers={"Accept": "application/json",151 "X-MultiTabID": tab},152 timeout=self.timeout)153 resp.raise_for_status()154 d = resp.json()155 d["_base"], d["_tab"] = base, tab156 return d157158 def _meta(self, cid: str, hid: str) -> dict:159 """Payload STATIQUE cachable « v1 » : l'objet hotel épuré (le token et160 la session, périssables, ne sont jamais cachés)."""161 h = self._front_light(cid, hid, _sticky_session()).get("hotel") or {}162 keep = ("name", "address", "city", "zipcode", "phone", "email", "url",163 "latitude", "longitude", "infosTxt", "nearAttrTxt", "chkin",164 "curcode", "minStay", "licenseNumber", "endSaleP")165 meta = {k: h.get(k) for k in keep if h.get(k) not in (None, "")}166 meta["photos"] = [p for p in h.get("photos") or []167 if isinstance(p, str) and p.startswith("http")]168 meta["services"] = [s.get("name") for s in h.get("services") or []169 if isinstance(s, dict) and s.get("name")]170 return {"hotel": meta}171172 # -- prix (frais à chaque sync : jamais caché) ------------------------------173 def _probe_price(self, cid: str, hid: str) -> tuple[float | None, dict]:174 """Tarif minimum (1 nuit à J+21, 2 adultes) toutes chambres proposées.175 Retourne (prix, extras {capacity, room_count}) — (None, {}) si l'hôtel176 est fermé ou sans disponibilité (pas une erreur). Les 2 appels177 (front.do puis booking.do) partagent une session à IP collante ;178 un 2e essai avec une session neuve absorbe les IP mal notées."""179 rooms: list = []180 for essai in (1, 2):181 http = _sticky_session()182 d = self._front_light(cid, hid, http)183 hotel, user = d.get("hotel") or {}, d.get("user") or {}184 if _past(hotel.get("endSaleP")):185 return None, {} # hôtel mort sur Reservit186 token, sess = user.get("token"), user.get("sessionID")187 if not token or not sess:188 return None, {}189190 day = _dt.date.today() + _dt.timedelta(days=LEAD_DAYS)191 f_fr = day.strftime("%d/%m/%Y")192 t_fr = (day + _dt.timedelta(days=1)).strftime("%d/%m/%Y")193 resp = http.get(d["_base"] + "/booking.do",194 params={"step": "2", "custid": cid, "id": cid,195 "hotelid": hid, "partid": "0",196 "langcode": "FR", "currency": "CAD",197 "fromStep": "step1",198 "fromdate": f_fr, "todate": t_fr,199 "fromDate": f_fr, "toDate": t_fr,200 "nbNight": "1", "roomID": "1",201 "nbRooms": "1", "numAdult(1)": "2",202 "numChild(1)": "0",203 "agesWithRoomID(1)": ""},204 headers={"Accept": "application/json",205 "X-SessionID": sess,206 "X-MultiTabID": d["_tab"],207 "Authorization": "Bearer " + token},208 cookies={"JSESSIONID": sess},209 timeout=self.timeout)210 try:211 resp.raise_for_status()212 rooms = ((resp.json().get("datas") or {}).get("rooms")) or []213 break214 except Exception: # noqa: BLE001 — filtre sécurité / non-JSON215 if essai == 2:216 raise217 best, capacity = None, None218 for r in rooms:219 typ = r.get("type") or {}220 m = _CAPACITY_RE.search(str(typ.get("typeName") or ""))221 if m and (capacity is None or int(m.group(1)) > capacity):222 capacity = int(m.group(1))223 for rate in r.get("rates") or []:224 p = rate.get("price") or {}225 val = p.get("amountBeforeTax")226 if not isinstance(val, (int, float)):227 val = p.get("amountAfterTax")228 if isinstance(val, (int, float)) and 20 <= val <= 20000 \229 and (best is None or val < best):230 best = round(float(val), 2)231 extras: dict = {}232 if rooms:233 extras["room_count"] = len(rooms)234 if capacity:235 extras["capacity"] = capacity236 return best, extras237238 # -- une fiche par hôtel -----------------------------------------------------239 def _listing(self, hotel: dict, price_ok: bool) -> tuple[StListing, bool]:240 """Construit la fiche ; retourne (listing, échec_du_sondage_prix)."""241 hid = str(hotel["engine_ids"]["hotelid"]).strip()242 cid = _custid(hotel)243244 # métadonnées Reservit (cachées) — leur absence ne tue pas la fiche :245 # le join SIT du roster (nom, ville, région, CITQ) a de la valeur seul246 meta: dict = {}247 try:248 meta = self.detail(hid, "v1",249 lambda c=cid, h=hid: self._meta(c, h)) \250 .get("hotel") or {}251 except Exception as exc: # noqa: BLE001252 print(f"[reservit] métadonnées {hotel.get('name')} ({hid}) : "253 f"{exc}", file=sys.stderr)254255 title = _clean(meta.get("name")) or _titre(hotel.get("name") or "")256 if title == title.upper(): # « HOTEL RIMOUSKI » → recasage SIT257 title = _titre(title)258 city = _clean(hotel.get("city")) or _titre(_clean(meta.get("city")))259 region = str(hotel.get("region") or "").replace("--", "–")260 ptype = _property_type(title)261262 # prix frais (jamais caché) — sauté si breaker ouvert / budget épuisé /263 # hôtel mort ; un échec du sondage ne fait jamais tomber la fiche264 price, extras, price_error = None, {}, False265 if price_ok and not _past(meta.get("endSaleP")):266 try:267 price, extras = self._probe_price(cid, hid)268 except Exception as exc: # noqa: BLE001269 price_error = True270 print(f"[reservit] prix {title} ({cid}/{hid}) : {exc}",271 file=sys.stderr)272273 # description : textes de l'hôtel (infos + à proximité), sinon274 # paragraphe éditorial assemblé à partir des faits275 parts = [_clean(p) for p in276 (meta.get("infosTxt"), meta.get("nearAttrTxt")) if p]277 if parts:278 description = parts[0]279 if len(parts) > 1:280 description += "\n\nÀ proximité : " + parts[1]281 else:282 feminin = ptype == "Auberge"283 phrases = [f"{title} est un{'e' if feminin else ''} "284 f"{ptype.lower()}"285 + (f" situé{'e' if feminin else ''} à {city}" if city286 else "")287 + (f", dans la région {region}" if region else "")288 + ". Réservation directe en ligne auprès de "289 "l'établissement (moteur Reservit)."]290 if hotel.get("citq"):291 phrases.append("Établissement d'hébergement touristique "292 f"enregistré (no CITQ {hotel['citq']}).")293 description = " ".join(phrases)294295 details: dict = {"booking_engine": "reservit"}296 if extras.get("room_count"):297 details["room_types"] = extras["room_count"]298 if meta.get("phone"):299 details["phone"] = _clean(meta["phone"])300 if meta.get("email"):301 details["email"] = _clean(meta["email"])302 if meta.get("zipcode"):303 details["postal_code"] = _clean(meta["zipcode"])304 if meta.get("chkin"):305 details["checkin"] = str(meta["chkin"])[:5]306 site = _clean(meta.get("url"))307 if site.startswith("http") and "reservit.com" not in site:308 details["website"] = site309 elif hotel.get("website") \310 and "reservit.com" not in str(hotel["website"]):311 details["website"] = hotel["website"]312313 lat, lng = meta.get("latitude"), meta.get("longitude")314315 return StListing(316 source=self.source_id,317 external_id=hid,318 url=hotel.get("booking_url")319 or f"{HOST}/engine/booking/{cid}/{hid}/dates?langcode=FR",320 title=title,321 property_type=ptype,322 address=_clean(meta.get("address")),323 city=city,324 region=region,325 price_night=price,326 price_label=(f"À partir de {price:g} $ / nuit" if price else ""),327 capacity=(float(extras["capacity"])328 if extras.get("capacity") else None),329 citq=str(hotel.get("citq") or meta.get("licenseNumber")330 or "").strip(),331 description=description[:5000],332 amenities=[_clean(s) for s in meta.get("services") or []],333 details=details,334 images=(meta.get("photos") or [])[:MAX_IMAGES],335 lat=float(lat) if lat is not None else None,336 lng=float(lng) if lng is not None else None,337 ), price_error338339 # -- contrat ----------------------------------------------------------------340 def fetch(self) -> list[StListing]:341 try:342 roster = json.loads(ROSTER.read_text(encoding="utf-8"))343 except Exception as exc: # noqa: BLE001344 print(f"[reservit] roster illisible ({ROSTER}) : {exc}",345 file=sys.stderr)346 return []347 hotels = [h for h in roster.get("hotels") or []348 if h.get("engine") == "reservit"349 and (h.get("engine_ids") or {}).get("hotelid")]350351 budget = int(os.environ.get("LOUKA_RESERVIT_DETAIL_LIMIT", "250"))352 listings: list[StListing] = []353 vus: set[str] = set()354 probes = 0355 price_failures = 0 # circuit breaker sur le sondage des prix356 for hotel in hotels:357 hid = str(hotel["engine_ids"]["hotelid"]).strip()358 if not hid or hid in vus:359 continue360 vus.add(hid)361 price_ok = price_failures < BREAKER_LIMIT and probes < budget362 if price_ok:363 probes += 1364 try:365 lst, price_error = self._listing(hotel, price_ok=price_ok)366 except Exception as exc: # noqa: BLE001367 print(f"[reservit] {hotel.get('name')} ({hid}) : {exc}",368 file=sys.stderr)369 continue370 if price_error:371 price_failures += 1372 if price_failures == BREAKER_LIMIT:373 print(f"[reservit] {BREAKER_LIMIT} échecs de sondage de "374 "prix consécutifs : arrêt des requêtes prix "375 "(fiches sans prix ensuite)", file=sys.stderr)376 elif price_failures < BREAKER_LIMIT:377 price_failures = 0378 listings.append(lst)379 return listings380