# -----------------------------------------------------------------------------
# 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)")
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