# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/boraboreal.py : Bora Boréal (boraboreal.com) — chalets FLOTTANTS # (minibora, boravilla) à Bury (Cantons-de-l'Est) et à Québec, plus un # chalet en bois rond ; ~13 unités réservables sur Lodgify # (reserver-boraboreal.lodgify.com, protégé Cloudflare → Scrapfly). # # Méthode : # 1. SLUGS : le sitemap Lodgify est vide → la page « louer-maison-flottante » # (rendue via Scrapfly, la grille est en JS) liste les 12 chalets # flottants ; les pages vitrines boraboreal.com (récupérées en direct) # ajoutent les unités hors grille (bora-bois-rond). # 2. DÉTAIL : chaque page unité Lodgify (Scrapfly sans render_js, cache # détail « v1 ») embarque un JSON-LD VacationRental complet : # identifier (external_id), priceRange « from 229 CAD/night », # occupancy/chambres, amenityFeature, adresse, geo lat/lng, ~19 photos # icdbcdn ; le CITQ est extrait de la description (au-delà de la fenêtre # de 2000 caractères de finalize()). Animaux : frais au séjour # → pets = « conditions » si l'amenité l'indique. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re import sys from ..schema import StListing from .airbnb import _region_from_latlng from .base import StConnector BOOKING = "https://reserver-boraboreal.lodgify.com" GRID = BOOKING + "/fr/louer-maison-flottante" # pages vitrines boraboreal.com (accessibles en direct) qui pointent vers des # unités Lodgify absentes de la grille SHOWCASE = [ "https://boraboreal.com/chalet-flottant-quebec", "https://boraboreal.com/mini-chalet-a-louer-estrie", "https://boraboreal.com/chalet-6-personnes-estrie", ] _TAG_RE = re.compile(r"<[^>]+>") _LD_RE = re.compile(r'(?s)]*type="application/ld\+json"[^>]*>' r"(.*?)") def _strip_html(txt: str) -> str: txt = re.sub(r"

||", "\n", txt or "") txt = _html.unescape(_TAG_RE.sub(" ", txt)) txt = re.sub(r"[ \t]+", " ", txt) return re.sub(r"\n\s+", "\n", txt).strip() class BoraBoreal(StConnector): source_id = "boraboreal" # -- découverte des slugs -------------------------------------------------- def _slugs(self) -> list[str]: slugs: list[str] = [] def add(s: str): s = s.strip("/") if s and s != "louer-maison-flottante" and s not in slugs: slugs.append(s) try: grid = self.get_scrapfly(GRID, render_js=True, rendering_wait=3000) for s in re.findall(r'href="(?:%s)?/fr/([\w~-]+)"' % re.escape(BOOKING), grid): add(s) except Exception as exc: # noqa: BLE001 print(f"[boraboreal] grille : {exc}", file=sys.stderr) for page in SHOWCASE: try: h = self.get(page).text except Exception: # noqa: BLE001 continue for s in re.findall( r"reserver-boraboreal\.lodgify\.com/(?:fr/)?([\w~-]+)", h): add(s) return slugs # -- page unité Lodgify ---------------------------------------------------- def _detail(self, slug: str) -> dict: h = self.get_scrapfly(f"{BOOKING}/fr/{slug}", render_js=False) for block in _LD_RE.findall(h): try: ld = json.loads(block) except ValueError: continue if ld.get("@type") == "VacationRental": return {"ld": ld} return {} # -- contrat --------------------------------------------------------------- def fetch(self) -> list[StListing]: listings: list[StListing] = [] vus: set[str] = set() for slug in self._slugs(): det = self.detail(slug, "v1", lambda s=slug: self._detail(s)) ld = det.get("ld") or {} if not ld: continue ext_id = str(ld.get("identifier") or slug) if ext_id in vus: continue vus.add(ext_id) place = ld.get("containsPlace") or {} occupancy = (place.get("occupancy") or {}).get("value") addr = ld.get("address") or {} geo = ld.get("geo") or {} # « from 229 CAD/night » → price_night price = None m = re.search(r"from\s+([\d.]+)\s*CAD", str(ld.get("priceRange") or "")) if m: v = float(m.group(1)) if 20 <= v <= 20000: price = v description = _strip_html(str(ld.get("description") or "")) m = re.search(r"CITQ\D{0,25}(\d{6})", description) citq = m.group(1) if m else "" amen = [a.get("name") for a in ld.get("amenityFeature") or [] if isinstance(a, dict) and a.get("name") and a.get("value") is not False] pets = "conditions" if any("animaux" in a.lower() or "pet" in a.lower() for a in amen) else None imgs = ld.get("image") or [] if isinstance(imgs, str): imgs = [imgs] city = (addr.get("addressLocality") or "").strip() region = (_region_from_latlng(geo.get("latitude"), geo.get("longitude")) or ("Québec" if slug.endswith("---quebec") else "Cantons-de-l'Est")) listings.append(StListing( source=self.source_id, external_id=ext_id, url=f"{BOOKING}/fr/{slug}", title=_html.unescape(str(ld.get("name") or slug)).strip(), property_type="Chalet", address=(addr.get("streetAddress") or "").strip(), city=city, region=region, price_night=price, price_label=(f"à partir de {price:g} $ / nuit" if price else ""), capacity=float(occupancy) if occupancy else None, bedrooms=(float(place["numberOfBedrooms"]) if place.get("numberOfBedrooms") else None), pets=pets, citq=citq, description=description[:5000], amenities=amen, details={"floating": "bois-rond" not in slug, "postal_code": addr.get("postalCode") or ""}, images=[u for u in imgs if isinstance(u, str) and u.startswith("https://")][:20], lat=geo.get("latitude"), lng=geo.get("longitude"), )) return listings