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/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 : 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