# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/hebergementcharlevoix.py : Hébergement Charlevoix # (hebergement-charlevoix.com) — agence de Charlevoix, ~330 chalets. # # Méthode : site PHP maison, HTML 100 % serveur. Sitemap /sitemap.xml # (301 vers le domaine sans www) → URLs détail # /fr/chalet-a-louer//voir/ (code alphanum stable, ex. ADE-440 ; # les autres URLs du sitemap sont des pages de catégories/villes/activités). # Chaque page détail porte un JSON-LD schema.org VacationRental complet : # occupancy, containsPlace (chambres, sdb, lits par type), petsAllowed, # address (ville, rue, code postal), geo (⚠️ décimales à VIRGULE), # offers.lowPrice (plus bas prix/nuit), images 1080×1080 et licenseNum CITQ. # Les commodités lisibles (FR) viennent des pictos
. # La note affichée (4,6/1370) est GLOBALE au site : on l'ignore. # Pas de lastmod : clé de cache mensuelle. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time from ..schema import StListing from .base import StConnector BASE = "https://hebergement-charlevoix.com" SITEMAP = BASE + "/sitemap.xml" _URL_DETAIL = re.compile( r"https://(?:www\.)?hebergement-charlevoix\.com/fr/chalet-a-louer/" r"([\w-]+)/voir/([\w-]+)/?$") # villes desservies hors de la région touristique de Charlevoix _HORS_CHARLEVOIX = { "tadoussac": "Côte-Nord", "saint-ferreol-les-neiges": "Québec", "saint-tite-des-caps": "Québec", } def _virgule(v) -> float | None: """Nombre JSON-LD du site : « 47,568534 » (virgule décimale).""" try: return float(str(v).replace(",", ".")) except (TypeError, ValueError): return None class HebergementCharlevoix(StConnector): source_id = "hebergementcharlevoix" # -- inventaire (sitemap) ---------------------------------------------- def _sitemap_urls(self) -> dict[str, tuple[str, str]]: """code -> (url détail, slug ville).""" xml = self.get(SITEMAP).text urls: dict[str, tuple[str, str]] = {} for loc in re.findall(r"([^<]+)", xml): m = _URL_DETAIL.match(loc.strip()) if m: urls.setdefault(m.group(2), (loc.strip(), m.group(1))) return urls # -- page détail (tout est dans le JSON-LD VacationRental) --------------- def _detail(self, url: str) -> dict: h = self.get(url).text d: dict = {} data = None for m in re.finditer(r']*application/ld\+json[^>]*>(.*?)' r"", h, re.S): try: cand = json.loads(m.group(1), strict=False) except ValueError: continue if isinstance(cand, dict) and cand.get("@type") == "VacationRental": data = cand break if data: d["title"] = (data.get("name") or "").strip() d["description"] = (data.get("description") or "").strip() d["property_type"] = (data.get("additionalType") or "").strip() occ = data.get("occupancy") or {} if occ.get("value") is not None: d["capacity"] = _virgule(occ["value"]) place = data.get("containsPlace") or {} if place.get("numberOfBedrooms") is not None: d["bedrooms"] = _virgule(place["numberOfBedrooms"]) if place.get("numberOfBathroomsTotal") is not None: d["bathrooms"] = _virgule(place["numberOfBathroomsTotal"]) beds = sum(_virgule(b.get("numberOfBeds")) or 0 for b in place.get("bed") or [] if isinstance(b, dict)) if beds: d["beds"] = beds if place.get("petsAllowed") is not None: d["pets"] = "oui" if place["petsAllowed"] in ( True, "true", "True", 1) else "non" addr = data.get("address") or {} d["city"] = (addr.get("addressLocality") or "").strip() d["address"] = (addr.get("streetAddress") or "").strip() geo = data.get("geo") or {} d["lat"] = _virgule(geo.get("latitude")) d["lng"] = _virgule(geo.get("longitude")) offers = data.get("offers") or {} low = _virgule(offers.get("lowPrice")) if low: d["price_night"] = round(low, 2) d["price_label"] = f"à partir de {low:.2f} $ / nuit" for feat in data.get("amenityFeature") or []: if not isinstance(feat, dict): continue if feat.get("name") == "licenseNum": m2 = re.search(r"(\d{6})", str(feat.get("value") or "")) if m2 and m2.group(1) != "000000": # placeholder du site d["citq"] = m2.group(1) imgs = data.get("image") or [] if isinstance(imgs, str): imgs = [imgs] d["images"] = [u for u in imgs if isinstance(u, str)][:20] # commodités lisibles : pictos
Foyer
amen = [] for a in re.findall(r'
]*>([^<]+)
', h): a = re.sub(r"\s+", " ", a).strip() if a and a not in amen: amen.append(a) if amen: d["amenities"] = amen return d # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: cle = "detail-" + time.strftime("%Y-%m") # pas de lastmod → mensuel listings: list[StListing] = [] for code, (url, ville_slug) in self._sitemap_urls().items(): try: d = self.detail(code, cle, lambda u=url: self._detail(u)) except Exception: # une fiche cassée ≠ inventaire perdu d = {} if not d.get("title"): continue listings.append(StListing( source=self.source_id, external_id=code, # code interne (ADE-440), stable url=url, title=d["title"], property_type=d.get("property_type") or "Chalet", address=d.get("address", ""), city=d.get("city", ""), region=_HORS_CHARLEVOIX.get(ville_slug, "Charlevoix"), price_night=d.get("price_night"), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), beds=d.get("beds"), bathrooms=d.get("bathrooms"), pets=d.get("pets"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], images=d.get("images") or [], lat=d.get("lat"), lng=d.get("lng"), )) return listings