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/_expediadetail.py : parseur partagé des pages détail de la4# plateforme Expedia (Vrbo + Expedia — même moteur, même SSR).5#6# La page détail (…/location/pXXXXXX[vb] ou …hXXXXXX.Hotel-Information),7# récupérée via Scrapfly ASP SANS rendu JS, embarque :8# - __APOLLO_STATE__ (JSON.parse("…")) → PropertyInfo :9# . propertyContentSectionGroups(…).aboutThisProperty → description10# (blocs PropertyContentItemMarkup, HTML — inclut souvent le n° CITQ)11# . summary.amenities(…) → commodités localisées (infoItems[].text)12# - microdonnées schema.org SSR : lat/lng (itemProp latitude/longitude),13# capacité (occupancy → value), municipalité (addressLocality)14# - galerie : URLs media.vrbo.com / images.trvl-media.com (~6 photos SSR)15# Vérifié live 2026-08-25 sur p3363083vb (Vrbo) et h130341845 (Expedia).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import json21import re2223_APOLLO_RE = re.compile(24 r'__APOLLO_STATE__\s*=\s*JSON\.parse\("(.*?)(?<!\\)"\)', re.S)25_LAT_RE = re.compile(r'itemProp="latitude" content="(-?[\d.]+)"')26_LNG_RE = re.compile(r'itemProp="longitude" content="(-?[\d.]+)"')27_OCC_RE = re.compile(28 r'itemProp="occupancy".{0,200}?itemProp="value" content="(\d+)"', re.S)29_CITY_RE = re.compile(r'itemProp="addressLocality" content="([^"]+)"')30_IMG_RE = re.compile(31 r'https://(?:media\.vrbo\.com|images\.trvl-media\.com)/lodging/'32 r'[^"\s\\)&?]+')333435def _apollo(html: str) -> dict:36 """Entités du store Apollo SSR ({} si absent/illisible)."""37 m = _APOLLO_RE.search(html or "")38 if not m:39 return {}40 try:41 # la chaîne est un littéral JSON : la re-quoter puis parser deux fois42 return json.loads(json.loads('"' + m.group(1) + '"'))43 except ValueError:44 return {}454647def _markup_texts(node, out: list[str]) -> None:48 """Collecte récursive des blocs PropertyContentItemMarkup (description)."""49 if isinstance(node, dict):50 if node.get("__typename") == "PropertyContentItemMarkup":51 txt = ((node.get("content") or {}).get("text") or "").strip()52 if txt:53 out.append(txt)54 return55 for v in node.values():56 _markup_texts(v, out)57 elif isinstance(node, list):58 for v in node:59 _markup_texts(v, out)606162def _strip_html(raw: str) -> str:63 t = re.sub(r"<br\s*/?>|</p>", "\n", raw)64 t = re.sub(r"<[^>]+>", " ", t)65 t = _html.unescape(t)66 lines = [re.sub(r"\s+", " ", ln).strip() for ln in t.split("\n")]67 return "\n".join(ln for ln in lines if ln).strip()686970def parse_detail(html: str) -> dict:71 """Payload détail {description, amenities, capacity, lat, lng, city,72 images} d'une page hébergement Vrbo/Expedia ({} si page invalide)."""73 store = _apollo(html or "")74 pinfo = next((v for k, v in store.items()75 if k.startswith("PropertyInfo") and isinstance(v, dict)), {})76 if not pinfo and not _LAT_RE.search(html or ""):77 return {} # page vide / redirection hors fiche7879 out: dict = {}8081 # description : sections « À propos de cet hébergement »82 paras: list[str] = []83 for k, v in pinfo.items():84 if k.startswith("propertyContentSectionGroups") and isinstance(v, dict):85 _markup_texts(v.get("aboutThisProperty"), paras)86 if not paras: # repli : éditorial du quartier87 loc = (pinfo.get("summary") or {}).get("location") or {}88 ed = ((loc.get("whatsAround") or {}).get("editorial") or {})89 paras = [t for t in (ed.get("content") or []) if isinstance(t, str)]90 desc = "\n\n".join(_strip_html(p) for p in paras).strip()91 if desc:92 out["description"] = desc[:6000]9394 # commodités localisées (summary.amenities → sections → infoItems)95 amenities: list[str] = []96 for k, v in (pinfo.get("summary") or {}).items():97 if not (k.startswith("amenities") and isinstance(v, dict)):98 continue99 for sec in v.get("amenities") or []:100 for cont in (sec or {}).get("contents") or []:101 for it in (cont or {}).get("infoItems") or []:102 txt = ((it or {}).get("text") or "").strip()103 if txt and txt not in amenities:104 amenities.append(txt)105 if amenities:106 out["amenities"] = amenities[:80]107108 # microdonnées SSR : géo, capacité, municipalité109 m = _LAT_RE.search(html)110 n = _LNG_RE.search(html)111 if m and n:112 try:113 out["lat"], out["lng"] = float(m.group(1)), float(n.group(1))114 except ValueError:115 pass116 m = _OCC_RE.search(html)117 if m:118 out["capacity"] = float(m.group(1))119 m = _CITY_RE.search(html)120 if m:121 out["city"] = _html.unescape(m.group(1)).strip()122123 # galerie SSR : dédupliquée par chemin, servie en 1200 px124 images: list[str] = []125 for u in _IMG_RE.findall(html):126 big = u + "?impolicy=resizecrop&rw=1200&ra=fit"127 if big not in images:128 images.append(big)129 if len(images) >= 15:130 break131 if images:132 out["images"] = images133 return out134135136def apply_detail(lst, d: dict) -> None:137 """Applique le payload détail sans écraser ce que la carte a fourni138 (sauf galerie : on garde la plus grande)."""139 if not d:140 return141 if d.get("description") and len(d["description"]) > len(lst.description or ""):142 lst.description = d["description"]143 if d.get("amenities"):144 seen = {a.lower() for a in lst.amenities}145 for a in d["amenities"]:146 if a.lower() not in seen:147 lst.amenities.append(a)148 seen.add(a.lower())149 for f in ("capacity", "lat", "lng"):150 if d.get(f) is not None and getattr(lst, f, None) is None:151 setattr(lst, f, d[f])152 if d.get("city") and not lst.city:153 lst.city = d["city"]154 if d.get("images") and len(d["images"]) > len(lst.images):155 lst.images = list(d["images"])156