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
3 days agolast push
HTML 98.9% Python 0.6%
6.9 KB · 175 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/boraboreal.py : Bora Boréal (boraboreal.com) — chalets FLOTTANTS4#   (minibora, boravilla) à Bury (Cantons-de-l'Est) et à Québec, plus un5#   chalet en bois rond ; ~13 unités réservables sur Lodgify6#   (reserver-boraboreal.lodgify.com, protégé Cloudflare → Scrapfly).7#8# Méthode :9#   1. SLUGS : le sitemap Lodgify est vide → la page « louer-maison-flottante »10#      (rendue via Scrapfly, la grille est en JS) liste les 12 chalets11#      flottants ; les pages vitrines boraboreal.com (récupérées en direct)12#      ajoutent les unités hors grille (bora-bois-rond).13#   2. DÉTAIL : chaque page unité Lodgify (Scrapfly sans render_js, cache14#      détail « v1 ») embarque un JSON-LD VacationRental complet :15#      identifier (external_id), priceRange « from 229 CAD/night »,16#      occupancy/chambres, amenityFeature, adresse, geo lat/lng, ~19 photos17#      icdbcdn ; le CITQ est extrait de la description (au-delà de la fenêtre18#      de 2000 caractères de finalize()). Animaux : frais au séjour19#      → pets = « conditions » si l'amenité l'indique.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import json25import re26import sys2728from ..schema import StListing29from .airbnb import _region_from_latlng30from .base import StConnector3132BOOKING = "https://reserver-boraboreal.lodgify.com"33GRID = BOOKING + "/fr/louer-maison-flottante"3435# pages vitrines boraboreal.com (accessibles en direct) qui pointent vers des36# unités Lodgify absentes de la grille37SHOWCASE = [38    "https://boraboreal.com/chalet-flottant-quebec",39    "https://boraboreal.com/mini-chalet-a-louer-estrie",40    "https://boraboreal.com/chalet-6-personnes-estrie",41]4243_TAG_RE = re.compile(r"<[^>]+>")4445_LD_RE = re.compile(r'(?s)<script[^>]*type="application/ld\+json"[^>]*>'46                    r"(.*?)</script>")474849def _strip_html(txt: str) -> str:50    txt = re.sub(r"</p>|<br\s*/?>|</li>", "\n", txt or "")51    txt = _html.unescape(_TAG_RE.sub(" ", txt))52    txt = re.sub(r"[ \t]+", " ", txt)53    return re.sub(r"\n\s+", "\n", txt).strip()545556class BoraBoreal(StConnector):57    source_id = "boraboreal"5859    # -- découverte des slugs --------------------------------------------------60    def _slugs(self) -> list[str]:61        slugs: list[str] = []6263        def add(s: str):64            s = s.strip("/")65            if s and s != "louer-maison-flottante" and s not in slugs:66                slugs.append(s)6768        try:69            grid = self.get_scrapfly(GRID, render_js=True,70                                     rendering_wait=3000)71            for s in re.findall(r'href="(?:%s)?/fr/([\w~-]+)"'72                                % re.escape(BOOKING), grid):73                add(s)74        except Exception as exc:  # noqa: BLE00175            print(f"[boraboreal] grille : {exc}", file=sys.stderr)7677        for page in SHOWCASE:78            try:79                h = self.get(page).text80            except Exception:  # noqa: BLE00181                continue82            for s in re.findall(83                    r"reserver-boraboreal\.lodgify\.com/(?:fr/)?([\w~-]+)", h):84                add(s)85        return slugs8687    # -- page unité Lodgify ----------------------------------------------------88    def _detail(self, slug: str) -> dict:89        h = self.get_scrapfly(f"{BOOKING}/fr/{slug}", render_js=False)90        for block in _LD_RE.findall(h):91            try:92                ld = json.loads(block)93            except ValueError:94                continue95            if ld.get("@type") == "VacationRental":96                return {"ld": ld}97        return {}9899    # -- contrat ---------------------------------------------------------------100    def fetch(self) -> list[StListing]:101        listings: list[StListing] = []102        vus: set[str] = set()103        for slug in self._slugs():104            det = self.detail(slug, "v1", lambda s=slug: self._detail(s))105            ld = det.get("ld") or {}106            if not ld:107                continue108            ext_id = str(ld.get("identifier") or slug)109            if ext_id in vus:110                continue111            vus.add(ext_id)112113            place = ld.get("containsPlace") or {}114            occupancy = (place.get("occupancy") or {}).get("value")115            addr = ld.get("address") or {}116            geo = ld.get("geo") or {}117118            # « from 229 CAD/night » → price_night119            price = None120            m = re.search(r"from\s+([\d.]+)\s*CAD",121                          str(ld.get("priceRange") or ""))122            if m:123                v = float(m.group(1))124                if 20 <= v <= 20000:125                    price = v126127            description = _strip_html(str(ld.get("description") or ""))128            m = re.search(r"CITQ\D{0,25}(\d{6})", description)129            citq = m.group(1) if m else ""130131            amen = [a.get("name") for a in ld.get("amenityFeature") or []132                    if isinstance(a, dict) and a.get("name")133                    and a.get("value") is not False]134            pets = "conditions" if any("animaux" in a.lower()135                                       or "pet" in a.lower()136                                       for a in amen) else None137138            imgs = ld.get("image") or []139            if isinstance(imgs, str):140                imgs = [imgs]141142            city = (addr.get("addressLocality") or "").strip()143            region = (_region_from_latlng(geo.get("latitude"),144                                          geo.get("longitude"))145                      or ("Québec" if slug.endswith("---quebec")146                          else "Cantons-de-l'Est"))147148            listings.append(StListing(149                source=self.source_id,150                external_id=ext_id,151                url=f"{BOOKING}/fr/{slug}",152                title=_html.unescape(str(ld.get("name") or slug)).strip(),153                property_type="Chalet",154                address=(addr.get("streetAddress") or "").strip(),155                city=city,156                region=region,157                price_night=price,158                price_label=(f"à partir de {price:g} $ / nuit"159                             if price else ""),160                capacity=float(occupancy) if occupancy else None,161                bedrooms=(float(place["numberOfBedrooms"])162                          if place.get("numberOfBedrooms") else None),163                pets=pets,164                citq=citq,165                description=description[:5000],166                amenities=amen,167                details={"floating": "bois-rond" not in slug,168                         "postal_code": addr.get("postalCode") or ""},169                images=[u for u in imgs if isinstance(u, str)170                        and u.startswith("https://")][:20],171                lat=geo.get("latitude"),172                lng=geo.get("longitude"),173            ))174        return listings175