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
4 days agolast push
HTML 98.9% Python 0.6%
13.9 KB · 334 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/booking.py : Booking.com — locations de vacances au Québec4#   (chalets, maisons de vacances, villas, appartements — PAS les hôtels).5#6# Méthode : les pages /searchresults.fr.html embarquent côté serveur le store7# Apollo (<script data-capla-store-data="apollo">…</script>) avec les 258# premiers résultats structurés (prix, coordonnées, chambres, occupancy…).9# Le site est derrière AWS WAF → Scrapfly ASP (sans rendu JS, le JSON est SSR).10# Le paramètre `offset` n'est PAS respecté côté SSR (vérifié 2026-08-22 :11# offset=25 recoupe 24/25 résultats de la page 1) → on couvre le territoire12# en multipliant destinations × jeux de filtres ht_id (25 max par requête).13#14# ht_id (types d'hébergement Booking) : 220 = maisons de vacances,15# 228 = chalets, 213 = villas, 201 = appartements, 204 = hôtels (exclu).16#17# Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking18# ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif19# → price_label « à partir de … » + price_night (le plus bas trouvé).20#21# Enrichissement : la page détail /hotel/ca/<pageName>.fr.html (Scrapfly ASP22# SANS rendu JS) embarque son propre store Apollo SSR : description complète23# (data-testid="property-description"), commodités localisées (entités24# Instance/SimpleFacility) et galerie (AccommodationPhoto). Vérifié live25# 2026-08-25 sur /hotel/ca/renarde.fr.html.26# Réglage env : LOUKA_BOOKING_DETAIL_LIMIT (fetchs détail par sync, défaut27# 150 ; cache permanent dans louka_ct.db, le parc se complète au fil des syncs).28# -----------------------------------------------------------------------------29from __future__ import annotations3031import datetime32import html as _html33import json34import os35import re36import sys37from urllib.parse import quote3839from ..schema import StListing40from .base import StConnector414243class _DetailSkip(Exception):44    """Fiche détail sautée (budget épuisé / page bloquée) — pas de cache."""4546# (texte de recherche Booking, région touristique QC)47DESTINATIONS = [48    ("Mont-Tremblant", "Laurentides"),49    ("Saint-Sauveur", "Laurentides"),50    ("Magog", "Cantons-de-l'Est"),51    ("Bromont", "Cantons-de-l'Est"),52    ("Baie-Saint-Paul", "Charlevoix"),53    ("La Malbaie", "Charlevoix"),54    ("Québec", "Québec"),55    ("Montréal", "Montréal"),56    ("Percé", "Gaspésie"),57    ("Rimouski", "Bas-Saint-Laurent"),58    ("Saguenay", "Saguenay–Lac-Saint-Jean"),59    ("Shawinigan", "Mauricie"),60    ("Gatineau", "Outaouais"),61]6263# Jeux de filtres par destination (25 résultats max chacun, cf. en-tête)64FILTER_SETS = [65    "ht_id=220",            # maisons de vacances66    "ht_id=228;ht_id=213",  # chalets + villas67    "ht_id=201",            # appartements entiers68]6970# accommodationTypeId Booking → type canonique Lou-Ka71TYPE_MAP = {72    201: "Appartement",73    203: "Auberge",74    208: "Gîte",75    213: "Maison",   # villa76    216: "Gîte",     # maison d'hôtes77    220: "Maison",   # maison de vacances78    222: "Chambre",  # séjour chez l'habitant79    228: "Chalet",80}8182_CAPLA_RE = re.compile(83    r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S)84_IMG_BASE = "https://cf.bstatic.com"85_DESC_RE = re.compile(86    r'data-testid="property-description"[^>]*>(.*?)</(?:p|div)>', re.S)878889class Booking(StConnector):90    source_id = "booking"91    request_delay = 1.09293    # -- extraction -----------------------------------------------------------94    def _search_results(self, html: str) -> list[dict]:95        """Résultats de recherche depuis le store Apollo SSR de la page."""96        m = _CAPLA_RE.search(html or "")97        if not m:98            return []99        try:100            store = json.loads(m.group(1))101        except ValueError:102            try:103                store = json.loads(_html.unescape(m.group(1)))104            except ValueError:105                return []106        queries = (store.get("ROOT_QUERY") or {}).get("searchQueries") or {}107        for key, val in queries.items():108            if key.startswith("search(") and isinstance(val, dict):109                return val.get("results") or []110        return []111112    def _to_listing(self, card: dict, region: str) -> StListing | None:113        basic = card.get("basicPropertyData") or {}114        hotel_id = basic.get("id")115        page_name = basic.get("pageName") or ""116        title = ((card.get("displayName") or {}).get("text") or "").strip()117        if not hotel_id or not page_name or not title:118            return None119        loc = basic.get("location") or {}120        if (loc.get("countryCode") or "ca").lower() != "ca":121            return None  # jamais hors Canada122123        # prix : total du séjour (2 nuits, dates indicatives) → prix/nuit plancher124        price_night, price_label = None, ""125        pdi = card.get("priceDisplayInfoIrene") or {}126        amount = (((pdi.get("displayPrice") or {}).get("amountPerStay") or {})127                  .get("amountUnformatted"))128        if isinstance(amount, (int, float)) and amount > 0:129            price_night = round(float(amount) / self._nights, 2)130            price_label = (f"à partir de {price_night:.0f} $ / nuit "131                           f"(séjour de {self._nights} nuits)")132133        # configuration des unités (chambres, lits, sdb, superficie)134        bedrooms = beds = bathrooms = None135        area = ""136        muc = card.get("matchingUnitConfigurations") or {}137        common = muc.get("commonConfiguration") or {}138        if common:139            bedrooms = common.get("nbBedrooms")140            beds = common.get("nbAllBeds")141            bathrooms = common.get("nbBathrooms")142            la = common.get("localizedArea") or {}143            if la.get("localizedArea"):144                area = f"{la['localizedArea']} {la.get('unit') or 'm²'}"145146        # capacité : occupancy max des blocs tarifaires retournés147        capacity = None148        for blk in card.get("blocks") or []:149            occ = (blk.get("blockId") or {}).get("occupancy")150            if isinstance(occ, (int, float)):151                capacity = max(capacity or 0, occ)152153        rating = reviews = None154        rev = basic.get("reviews") or {}155        if rev.get("showScore") and rev.get("totalScore"):156            rating = round(float(rev["totalScore"]) / 2, 2)  # /10 → /5157            reviews = rev.get("reviewsCount")158159        images = []160        rel = ((((basic.get("photos") or {}).get("main") or {})161                .get("highResUrl") or {}).get("relativeUrl"))162        if rel:163            images.append(_IMG_BASE + rel)164165        type_id = basic.get("accommodationTypeId")166        details: dict = {"booking_type_id": type_id}167        if area:168            details["superficie"] = area169        main_dist = (card.get("location") or {}).get("mainDistance")170        if main_dist:171            details["distance_centre"] = main_dist172173        return StListing(174            source=self.source_id,175            external_id=str(hotel_id),176            url=f"https://www.booking.com/hotel/ca/{page_name}.fr.html",177            title=title,178            property_type=TYPE_MAP.get(type_id, "Autre"),179            address=(loc.get("address") or "").strip(),180            city=(loc.get("city") or "").strip(),181            region=region,182            price_night=price_night,183            price_label=price_label,184            capacity=float(capacity) if capacity else None,185            bedrooms=float(bedrooms) if bedrooms else None,186            beds=float(beds) if beds else None,187            bathrooms=float(bathrooms) if bathrooms else None,188            rating=rating,189            reviews=int(reviews) if reviews else None,190            description=((card.get("description") or {}).get("text") or "").strip(),191            details=details,192            images=images,193            lat=loc.get("latitude"),194            lng=loc.get("longitude"),195        )196197    # -- page détail ------------------------------------------------------------198    @staticmethod199    def _parse_detail(html: str) -> dict:200        """Payload {description, amenities, images} d'une page détail Booking201        ({} si page bloquée/invalide)."""202        out: dict = {}203204        # description complète (SSR) — HTML → texte205        m = _DESC_RE.search(html or "")206        if m:207            txt = re.sub(r"<br\s*/?>|</p>", "\n", m.group(1))208            txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt))209            lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")]210            desc = "\n".join(ln for ln in lines if ln).strip()211            if desc:212                out["description"] = desc[:6000]213214        # store Apollo de la page détail : commodités localisées + galerie215        mm = _CAPLA_RE.search(html or "")216        if mm:217            try:218                store = json.loads(mm.group(1))219            except ValueError:220                try:221                    store = json.loads(_html.unescape(mm.group(1)))222                except ValueError:223                    store = {}224            amenities: list[str] = []225            for key, val in store.items():226                if not isinstance(val, dict):227                    continue228                name = ""229                if key.startswith("Instance:"):          # équipements du lieu230                    name = (val.get("title") or "").strip()231                elif key.startswith("SimpleFacility:"):  # équipements des unités232                    name = (val.get("name") or "").strip()233                if name and name not in amenities:234                    amenities.append(name)235            if amenities:236                out["amenities"] = amenities[:80]237238            images: list[str] = []239            for key, val in store.items():240                if not (key.startswith("AccommodationPhoto:")241                        and isinstance(val, dict)):242                    continue243                for k2, v2 in val.items():244                    if k2.startswith("resource(") and isinstance(v2, dict) \245                            and v2.get("relativeUrl"):246                        rel = re.sub(r"/(?:square|max)\w+/", "/max1024x768/",247                                     v2["relativeUrl"], count=1)248                        u = _IMG_BASE + rel249                        if u not in images:250                            images.append(u)251                        break252                if len(images) >= 15:253                    break254            if images:255                out["images"] = images256        return out257258    def _enrich_details(self, listings: list[StListing]) -> None:259        """Visite les fiches détail via le cache self.detail() sous budget :260        les hits de cache sont gratuits, seuls les fetchs réseau comptent."""261        limit = max(0, int(os.environ.get("LOUKA_BOOKING_DETAIL_LIMIT", "150")262                           or 150))263        used = enriched = streak = 0264        for lst in listings:265            def fetch_fn(url=lst.url):266                nonlocal used, streak267                if used >= limit or streak >= 5:   # tempête anti-bot : on coupe268                    raise _DetailSkip269                used += 1270                res = self.scrapfly(url, render_js=False, asp=True)271                if res.get("status_code") in (404, 410):272                    return {}                      # fiche retirée : cacher vide273                payload = self._parse_detail(res.get("content") or "")274                if not payload:275                    streak += 1276                    raise _DetailSkip              # blocage/vide : pas de cache277                streak = 0278                return payload279280            try:281                d = self.detail(lst.external_id, "v1", fetch_fn)282            except _DetailSkip:283                continue284            except Exception:  # noqa: BLE001 — une fiche ne bloque pas le run285                continue286            if not d:287                continue288            if d.get("description") and len(d["description"]) > \289                    len(lst.description or ""):290                lst.description = d["description"]291            if d.get("amenities"):292                seen = {a.lower() for a in lst.amenities}293                for a in d["amenities"]:294                    if a.lower() not in seen:295                        lst.amenities.append(a)296                        seen.add(a.lower())297            if d.get("images") and len(d["images"]) > len(lst.images):298                lst.images = list(d["images"])299            enriched += 1300        print(f"[booking] détail : {enriched} annonces enrichies"301              f" ({used}/{limit} fetchs réseau)", file=sys.stderr)302303    # -- contrat ---------------------------------------------------------------304    def fetch(self) -> list[StListing]:305        today = datetime.date.today()306        checkin = (today + datetime.timedelta(days=30)).isoformat()307        checkout = (today + datetime.timedelta(days=32)).isoformat()308        self._nights = 2309310        listings: dict[str, StListing] = {}311        for dest, region in DESTINATIONS:312            for nflt in FILTER_SETS:313                url = ("https://www.booking.com/searchresults.fr.html"314                       f"?ss={quote(dest)}&nflt={quote(nflt)}"315                       f"&checkin={checkin}&checkout={checkout}"316                       "&group_adults=2&no_rooms=1"317                       "&selected_currency=CAD&lang=fr")318                try:319                    html = self.get_scrapfly(url, render_js=False, asp=True)320                    cards = self._search_results(html)321                except Exception as exc:  # noqa: BLE001 — une requête ratée ≠ sync ratée322                    print(f"[booking] {dest} ({nflt}) : {exc}", file=sys.stderr)323                    continue324                for card in cards:325                    try:326                        lst = self._to_listing(card, region)327                    except Exception:  # noqa: BLE001328                        continue329                    if lst and lst.external_id not in listings:330                        listings[lst.external_id] = lst331        out = list(listings.values())332        self._enrich_details(out)333        return out334