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%
6.1 KB · 156 lines python
Raw Blame History
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