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%
13.1 KB · 302 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/expedia.py : Expedia (expedia.ca) — locations de vacances au Québec4#   (onglet « Homes » : maisons, chalets, condos… — PAS les hôtels).5#6# Méthode : même pile que Vrbo (groupe Expedia) — anti-bot Akamai + page7# client-side. Le SSR (__PLUGIN_STATE__ → controllers.stores.searchResults)8# ne porte que les 3 premières cartes (vérifié 2026-08-23), le reste arrive9# par GraphQL après hydratation → Scrapfly ASP avec rendu JS + js_scenario de10# scrolls, puis parsing DOM des cartes `[data-stid="lodging-card-responsive"]`.11# Le filtre « Homes » de la recherche = paramètre d'URL12# `categorySearch=vacation_rentals_option` (exclut les hôtels classiques).13#14# Limites assumées : ~19 cartes rendues par requête (liste virtualisée, comme15# Vrbo) → on multiplie destinations × tris (RECOMMENDED + PRICE_LOW_TO_HIGH)16# pour élargir la couverture ; pas de lat/lng ni d'adresse sur les cartes.17# Recherche SANS dates : Expedia auto-assigne des dates (~2 semaines) et18# affiche un prix par nuit avant taxes → price_label « à partir de … ».19# (AVEC dates explicites la disponibilité ralentit le rendu : 3 cartes et20# aucun prix dans le snapshot — vérifié 2026-08-23, ne pas en remettre.)21# L'inventaire recoupe en partie Vrbo (même groupe) mais avec ses propres ids22# et des exclusivités hôtelières-résidentielles (apparts-hôtels, glamping).23#24# Enrichissement : la page détail hXXXXXX.Hotel-Information (Scrapfly ASP25# SANS rendu JS — le SSR suffit) porte description, commodités, lat/lng et26# ~6 photos (parseur partagé avec Vrbo : _expediadetail.py).27#28# Réglages env : LOUKA_EXPEDIA_LIMIT (nb max d'annonces, pour tester petit),29#                LOUKA_EXPEDIA_DETAIL_LIMIT (fetchs détail par sync, défaut30#                100 ; cache permanent, le parc se complète au fil des syncs).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import os35import re36import sys37from urllib.parse import quote3839from bs4 import BeautifulSoup4041from ..schema import StListing42from . import _expediadetail as _ed43from .base import StConnector444546class _DetailSkip(Exception):47    """Fiche détail sautée (budget épuisé / page invalide) — pas de cache."""4849# (destination Expedia, ville affichée par défaut, région touristique QC)50DESTINATIONS = [51    ("Mont-Tremblant, Quebec, Canada", "Mont-Tremblant", "Laurentides"),52    ("Saint-Sauveur, Quebec, Canada", "Saint-Sauveur", "Laurentides"),53    ("Magog, Quebec, Canada", "Magog", "Cantons-de-l'Est"),54    ("Bromont, Quebec, Canada", "Bromont", "Cantons-de-l'Est"),55    ("Baie-Saint-Paul, Quebec, Canada", "Baie-Saint-Paul", "Charlevoix"),56    ("La Malbaie, Quebec, Canada", "La Malbaie", "Charlevoix"),57    ("Quebec City, Quebec, Canada", "Québec", "Québec"),58    ("Montreal, Quebec, Canada", "Montréal", "Montréal"),59    ("Perce, Quebec, Canada", "Percé", "Gaspésie"),60    ("Rimouski, Quebec, Canada", "Rimouski", "Bas-Saint-Laurent"),61    ("Saguenay, Quebec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"),62    ("Shawinigan, Quebec, Canada", "Shawinigan", "Mauricie"),63    ("Gatineau, Quebec, Canada", "Gatineau", "Outaouais"),64]6566# Deux tris par destination pour dépasser la vingtaine de cartes rendues67SORTS = ["RECOMMENDED", "PRICE_LOW_TO_HIGH"]6869# Mot-clé du libellé type Expedia (en) → type canonique Lou-Ka70TYPE_MAP = {71    "apartment": "Appartement", "apart-hotel": "Appartement",72    "aparthotel": "Appartement", "condo": "Condo", "chalet": "Chalet",73    "cabin": "Chalet", "cottage": "Chalet", "house": "Maison",74    "home": "Maison", "villa": "Maison", "townhouse": "Maison",75    "bungalow": "Maison", "studio": "Studio", "loft": "Loft",76    "room": "Chambre", "guesthouse": "Gîte", "bed & breakfast": "Gîte",77    "lodge": "Auberge", "hostel": "Auberge", "yurt": "Yourte",78    "tiny house": "Mini-maison", "houseboat": "Autre",79}8081_ID_RE = re.compile(r"\.h(\d+)\.Hotel-Information")82_TYPELINE_RE = re.compile(r"^(?:Entire|Private|Shared)\s+(.+?)(?:\s+by\s+Vrbo)?$",83                          re.I)84_SLEEPS_RE = re.compile(r"Sleeps\s+(\d+)")85_BEDROOMS_RE = re.compile(r"(\d+)\s*bedrooms?")86_BATHROOMS_RE = re.compile(r"([\d.]+)\s*bathrooms?")87_RATING_RE = re.compile(r"([\d.]+)\s*out of 10")88_REVIEWS_RE = re.compile(r"\((\d[\d,\s]*)\s*reviews?\)")89_PRICE_RE = re.compile(r"The current price is CA\s*\$([\d,]+)")  # \s : &nbsp;9091# Segments de carte qui ne sont PAS une localité (drapeaux, commodités…)92_NOT_A_PLACE = re.compile(93    r"refundable|reserve now|sign in|member price|pool|hot tub|washer|dryer|"94    r"kitchen|parking|wifi|out of 10|review|current price|previous price|"95    r"total|includes|off\b|ad\b|sleeps|photo gallery|show (previous|next)|"96    r"more information|opens", re.I)97# Hors Québec possible dans les résultats frontaliers (Gatineau → Ottawa…)98_OUT_OF_QC = re.compile(r"ottawa|ontario|vermont|new hampshire|new york|maine",99                        re.I)100101102class Expedia(StConnector):103    source_id = "expedia"104    request_delay = 1.0105106    # -- parsing d'une carte ----------------------------------------------------107    def _parse_card(self, card, city: str, region: str) -> StListing | None:108        link = card.select_one('a[data-stid="open-product-information"]') \109            or card.select_one('a[href*=".Hotel-Information"]')110        href = (link.get("href") if link else "") or ""111        m = _ID_RE.search(href)112        if not m:113            return None   # carte commanditée / lien de connexion114        external_id = m.group(1)115        url = "https://www.expedia.ca" + href.split("?")[0].lstrip()116        if not url.startswith("https://www.expedia.ca/"):117            url = f"https://www.expedia.ca/h{external_id}.Hotel-Information"118119        title = ""120        for h in card.find_all("h3"):121            if "is-visually-hidden" not in " ".join(h.get("class") or []):122                title = h.get_text(strip=True)123                break124        if not title:125            return None126127        segs = list(card.stripped_strings)128        blob = " | ".join(segs)129        if _OUT_OF_QC.search(blob):130            return None131132        # ligne type (« Entire home by Vrbo ») + ligne config (« Sleeps 4 · … »)133        property_type = ""134        capacity = bedrooms = bathrooms = None135        try:136            i_title = segs.index(title)137        except ValueError:138            i_title = 0139        place = ""140        for seg in segs[i_title + 1:]:141            tm = _TYPELINE_RE.match(seg)142            if tm and len(seg) < 60:143                kind = tm.group(1).strip().lower()144                property_type = next(145                    (v for k, v in TYPE_MAP.items() if k in kind), "Autre")146                continue147            if _SLEEPS_RE.search(seg):148                sm = _SLEEPS_RE.search(seg)149                capacity = float(sm.group(1))150                bm = _BEDROOMS_RE.search(seg)151                if bm:152                    bedrooms = float(bm.group(1))153                elif "studio" in seg.lower():154                    bedrooms = 0.0155                am = _BATHROOMS_RE.search(seg)156                if am:157                    bathrooms = float(am.group(1))158                continue159            # première ligne « libre » après titre/type/config = localité160            if (not place and seg != title and len(seg) < 60161                    and not _NOT_A_PLACE.search(seg)162                    and not re.match(r"^[\d($]", seg)):163                place = seg164        if place:165            city = place166167        rating = reviews = None168        rm = _RATING_RE.search(blob)169        if rm:170            try:171                rating = round(float(rm.group(1)) / 2, 2)   # /10 → /5172            except ValueError:173                pass174        vm = _REVIEWS_RE.search(blob)175        if vm:176            reviews = int(re.sub(r"[\s,]", "", vm.group(1)))177178        # prix par nuit avant taxes (dates indicatives passées dans l'URL)179        price_night, price_label = None, ""180        pm = _PRICE_RE.search(blob)181        if pm:182            try:183                price_night = float(pm.group(1).replace(",", ""))184            except ValueError:185                price_night = None186            if price_night:187                price_label = (f"à partir de {price_night:.0f} $ / nuit "188                               "(prochaines dates, avant taxes)")189190        images = []191        for img in card.select("img[src]"):192            src = img.get("src") or ""193            if src.startswith("https://images.trvl-media.com/") \194                    and src not in images:195                images.append(src)196            if len(images) >= 5:197                break198199        return StListing(200            source=self.source_id,201            external_id=external_id,202            url=url,203            title=title,204            property_type=property_type,205            city=city,206            region=region,207            price_night=price_night,208            price_label=price_label,209            capacity=capacity,210            bedrooms=bedrooms,211            bathrooms=bathrooms,212            rating=rating,213            reviews=reviews,214            images=images,215        )216217    # -- enrichissement par la page détail ---------------------------------------218    def _enrich_details(self, listings: list[StListing]) -> None:219        """Visite les fiches détail via le cache self.detail() sous budget :220        les hits de cache sont gratuits, seuls les fetchs réseau comptent."""221        limit = max(0, int(os.environ.get("LOUKA_EXPEDIA_DETAIL_LIMIT", "100")222                           or 100))223        used = enriched = streak = 0224        for lst in listings:225            def fetch_fn(url=lst.url):226                nonlocal used, streak227                if used >= limit or streak >= 5:   # tempête anti-bot : on coupe228                    raise _DetailSkip229                used += 1230                html = self.get_scrapfly(url, render_js=False, asp=True)231                payload = _ed.parse_detail(html)232                if not payload:233                    streak += 1234                    raise _DetailSkip              # blocage/vide : pas de cache235                streak = 0236                return payload237238            try:239                d = self.detail(lst.external_id, "v1", fetch_fn)240            except _DetailSkip:241                continue242            except Exception:  # noqa: BLE001 — une fiche ne bloque pas le run243                continue244            if d:245                _ed.apply_detail(lst, d)246                enriched += 1247        print(f"[expedia] détail : {enriched} annonces enrichies"248              f" ({used}/{limit} fetchs réseau)", file=sys.stderr)249250    # -- contrat -----------------------------------------------------------------251    def fetch(self) -> list[StListing]:252        limit = int(os.environ.get("LOUKA_EXPEDIA_LIMIT", "0") or 0)253254        # scrolls progressifs : déclenche le fetch client + rend les cartes255        scenario = [{"wait": 1500}]256        for y in (4000, 9000, 15000):257            scenario += [{"scroll": {"y": y}}, {"wait": 1500}]258259        listings: dict[str, StListing] = {}260        for dest, city, region in DESTINATIONS:261            for sort in SORTS:262                if limit and len(listings) >= limit:263                    out = list(listings.values())264                    self._enrich_details(out)265                    return out266                url = ("https://www.expedia.ca/Hotel-Search?destination="267                       + quote(dest)268                       + "&adults=2&categorySearch=vacation_rentals_option")269                if sort != "RECOMMENDED":270                    url += f"&sort={sort}"271                cards = []272                for attempt in (1, 2):   # le rendu revient parfois vide273                    try:274                        html = self.get_scrapfly(275                            url, render_js=True, asp=True, rendering_wait=2000,276                            wait_for_selector='[data-stid="lodging-card-responsive"]',277                            js_scenario=scenario)278                    except Exception as exc:  # noqa: BLE001 — une requête ratée ≠ sync ratée279                        print(f"[expedia] {city} ({sort}) : {exc}",280                              file=sys.stderr)281                        continue282                    soup = BeautifulSoup(html or "", "html.parser")283                    cards = soup.select('[data-stid="lodging-card-responsive"]')284                    if cards:285                        break286                    print(f"[expedia] {city} ({sort}) : 0 carte rendue"287                          f" (essai {attempt})", file=sys.stderr)288                n_before = len(listings)289                for card in cards:290                    try:291                        lst = self._parse_card(card, city, region)292                    except Exception:  # noqa: BLE001293                        continue294                    if lst and lst.external_id not in listings:295                        listings[lst.external_id] = lst296                print(f"[expedia] {city} ({sort}) :"297                      f" {len(listings) - n_before} nouvelles"298                      f" (total {len(listings)})", file=sys.stderr)299        out = list(listings.values())300        self._enrich_details(out)301        return out302