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%
17.9 KB · 380 lines python
Raw Blame History
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&currency=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 « &#156; » → « œ »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