# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/_expediadetail.py : parseur partagé des pages détail de la # plateforme Expedia (Vrbo + Expedia — même moteur, même SSR). # # La page détail (…/location/pXXXXXX[vb] ou …hXXXXXX.Hotel-Information), # récupérée via Scrapfly ASP SANS rendu JS, embarque : # - __APOLLO_STATE__ (JSON.parse("…")) → PropertyInfo : # . propertyContentSectionGroups(…).aboutThisProperty → description # (blocs PropertyContentItemMarkup, HTML — inclut souvent le n° CITQ) # . summary.amenities(…) → commodités localisées (infoItems[].text) # - microdonnées schema.org SSR : lat/lng (itemProp latitude/longitude), # capacité (occupancy → value), municipalité (addressLocality) # - galerie : URLs media.vrbo.com / images.trvl-media.com (~6 photos SSR) # Vérifié live 2026-08-25 sur p3363083vb (Vrbo) et h130341845 (Expedia). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re _APOLLO_RE = re.compile( r'__APOLLO_STATE__\s*=\s*JSON\.parse\("(.*?)(? dict: """Entités du store Apollo SSR ({} si absent/illisible).""" m = _APOLLO_RE.search(html or "") if not m: return {} try: # la chaîne est un littéral JSON : la re-quoter puis parser deux fois return json.loads(json.loads('"' + m.group(1) + '"')) except ValueError: return {} def _markup_texts(node, out: list[str]) -> None: """Collecte récursive des blocs PropertyContentItemMarkup (description).""" if isinstance(node, dict): if node.get("__typename") == "PropertyContentItemMarkup": txt = ((node.get("content") or {}).get("text") or "").strip() if txt: out.append(txt) return for v in node.values(): _markup_texts(v, out) elif isinstance(node, list): for v in node: _markup_texts(v, out) def _strip_html(raw: str) -> str: t = re.sub(r"|

", "\n", raw) t = re.sub(r"<[^>]+>", " ", t) t = _html.unescape(t) lines = [re.sub(r"\s+", " ", ln).strip() for ln in t.split("\n")] return "\n".join(ln for ln in lines if ln).strip() def parse_detail(html: str) -> dict: """Payload détail {description, amenities, capacity, lat, lng, city, images} d'une page hébergement Vrbo/Expedia ({} si page invalide).""" store = _apollo(html or "") pinfo = next((v for k, v in store.items() if k.startswith("PropertyInfo") and isinstance(v, dict)), {}) if not pinfo and not _LAT_RE.search(html or ""): return {} # page vide / redirection hors fiche out: dict = {} # description : sections « À propos de cet hébergement » paras: list[str] = [] for k, v in pinfo.items(): if k.startswith("propertyContentSectionGroups") and isinstance(v, dict): _markup_texts(v.get("aboutThisProperty"), paras) if not paras: # repli : éditorial du quartier loc = (pinfo.get("summary") or {}).get("location") or {} ed = ((loc.get("whatsAround") or {}).get("editorial") or {}) paras = [t for t in (ed.get("content") or []) if isinstance(t, str)] desc = "\n\n".join(_strip_html(p) for p in paras).strip() if desc: out["description"] = desc[:6000] # commodités localisées (summary.amenities → sections → infoItems) amenities: list[str] = [] for k, v in (pinfo.get("summary") or {}).items(): if not (k.startswith("amenities") and isinstance(v, dict)): continue for sec in v.get("amenities") or []: for cont in (sec or {}).get("contents") or []: for it in (cont or {}).get("infoItems") or []: txt = ((it or {}).get("text") or "").strip() if txt and txt not in amenities: amenities.append(txt) if amenities: out["amenities"] = amenities[:80] # microdonnées SSR : géo, capacité, municipalité m = _LAT_RE.search(html) n = _LNG_RE.search(html) if m and n: try: out["lat"], out["lng"] = float(m.group(1)), float(n.group(1)) except ValueError: pass m = _OCC_RE.search(html) if m: out["capacity"] = float(m.group(1)) m = _CITY_RE.search(html) if m: out["city"] = _html.unescape(m.group(1)).strip() # galerie SSR : dédupliquée par chemin, servie en 1200 px images: list[str] = [] for u in _IMG_RE.findall(html): big = u + "?impolicy=resizecrop&rw=1200&ra=fit" if big not in images: images.append(big) if len(images) >= 15: break if images: out["images"] = images return out def apply_detail(lst, d: dict) -> None: """Applique le payload détail sans écraser ce que la carte a fourni (sauf galerie : on garde la plus grande).""" if not d: return if d.get("description") and len(d["description"]) > len(lst.description or ""): lst.description = d["description"] if d.get("amenities"): seen = {a.lower() for a in lst.amenities} for a in d["amenities"]: if a.lower() not in seen: lst.amenities.append(a) seen.add(a.lower()) for f in ("capacity", "lat", "lng"): if d.get(f) is not None and getattr(lst, f, None) is None: setattr(lst, f, d[f]) if d.get("city") and not lst.city: lst.city = d["city"] if d.get("images") and len(d["images"]) > len(lst.images): lst.images = list(d["images"])