Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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