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/hebergementcharlevoix.py : Hébergement Charlevoix4# (hebergement-charlevoix.com) — agence de Charlevoix, ~330 chalets.5#6# Méthode : site PHP maison, HTML 100 % serveur. Sitemap /sitemap.xml7# (301 vers le domaine sans www) → URLs détail8# /fr/chalet-a-louer/<ville>/voir/<CODE> (code alphanum stable, ex. ADE-440 ;9# les autres URLs du sitemap sont des pages de catégories/villes/activités).10# Chaque page détail porte un JSON-LD schema.org VacationRental complet :11# occupancy, containsPlace (chambres, sdb, lits par type), petsAllowed,12# address (ville, rue, code postal), geo (⚠️ décimales à VIRGULE),13# offers.lowPrice (plus bas prix/nuit), images 1080×1080 et licenseNum CITQ.14# Les commodités lisibles (FR) viennent des pictos <div class="bulle">.15# La note affichée (4,6/1370) est GLOBALE au site : on l'ignore.16# Pas de lastmod : clé de cache mensuelle.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re22import time2324from ..schema import StListing25from .base import StConnector2627BASE = "https://hebergement-charlevoix.com"28SITEMAP = BASE + "/sitemap.xml"2930_URL_DETAIL = re.compile(31 r"https://(?:www\.)?hebergement-charlevoix\.com/fr/chalet-a-louer/"32 r"([\w-]+)/voir/([\w-]+)/?$")3334# villes desservies hors de la région touristique de Charlevoix35_HORS_CHARLEVOIX = {36 "tadoussac": "Côte-Nord",37 "saint-ferreol-les-neiges": "Québec",38 "saint-tite-des-caps": "Québec",39}404142def _virgule(v) -> float | None:43 """Nombre JSON-LD du site : « 47,568534 » (virgule décimale)."""44 try:45 return float(str(v).replace(",", "."))46 except (TypeError, ValueError):47 return None484950class HebergementCharlevoix(StConnector):51 source_id = "hebergementcharlevoix"5253 # -- inventaire (sitemap) ----------------------------------------------54 def _sitemap_urls(self) -> dict[str, tuple[str, str]]:55 """code -> (url détail, slug ville)."""56 xml = self.get(SITEMAP).text57 urls: dict[str, tuple[str, str]] = {}58 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):59 m = _URL_DETAIL.match(loc.strip())60 if m:61 urls.setdefault(m.group(2), (loc.strip(), m.group(1)))62 return urls6364 # -- page détail (tout est dans le JSON-LD VacationRental) ---------------65 def _detail(self, url: str) -> dict:66 h = self.get(url).text67 d: dict = {}68 data = None69 for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)'70 r"</script>", h, re.S):71 try:72 cand = json.loads(m.group(1), strict=False)73 except ValueError:74 continue75 if isinstance(cand, dict) and cand.get("@type") == "VacationRental":76 data = cand77 break78 if data:79 d["title"] = (data.get("name") or "").strip()80 d["description"] = (data.get("description") or "").strip()81 d["property_type"] = (data.get("additionalType") or "").strip()82 occ = data.get("occupancy") or {}83 if occ.get("value") is not None:84 d["capacity"] = _virgule(occ["value"])85 place = data.get("containsPlace") or {}86 if place.get("numberOfBedrooms") is not None:87 d["bedrooms"] = _virgule(place["numberOfBedrooms"])88 if place.get("numberOfBathroomsTotal") is not None:89 d["bathrooms"] = _virgule(place["numberOfBathroomsTotal"])90 beds = sum(_virgule(b.get("numberOfBeds")) or 091 for b in place.get("bed") or [] if isinstance(b, dict))92 if beds:93 d["beds"] = beds94 if place.get("petsAllowed") is not None:95 d["pets"] = "oui" if place["petsAllowed"] in (96 True, "true", "True", 1) else "non"97 addr = data.get("address") or {}98 d["city"] = (addr.get("addressLocality") or "").strip()99 d["address"] = (addr.get("streetAddress") or "").strip()100 geo = data.get("geo") or {}101 d["lat"] = _virgule(geo.get("latitude"))102 d["lng"] = _virgule(geo.get("longitude"))103 offers = data.get("offers") or {}104 low = _virgule(offers.get("lowPrice"))105 if low:106 d["price_night"] = round(low, 2)107 d["price_label"] = f"à partir de {low:.2f} $ / nuit"108 for feat in data.get("amenityFeature") or []:109 if not isinstance(feat, dict):110 continue111 if feat.get("name") == "licenseNum":112 m2 = re.search(r"(\d{6})", str(feat.get("value") or ""))113 if m2 and m2.group(1) != "000000": # placeholder du site114 d["citq"] = m2.group(1)115 imgs = data.get("image") or []116 if isinstance(imgs, str):117 imgs = [imgs]118 d["images"] = [u for u in imgs if isinstance(u, str)][:20]119120 # commodités lisibles : pictos <div class="bulle">Foyer</div>121 amen = []122 for a in re.findall(r'<div class="bulle"[^>]*>([^<]+)</div>', h):123 a = re.sub(r"\s+", " ", a).strip()124 if a and a not in amen:125 amen.append(a)126 if amen:127 d["amenities"] = amen128 return d129130 # -- contrat --------------------------------------------------------------131 def fetch(self) -> list[StListing]:132 cle = "detail-" + time.strftime("%Y-%m") # pas de lastmod → mensuel133 listings: list[StListing] = []134 for code, (url, ville_slug) in self._sitemap_urls().items():135 try:136 d = self.detail(code, cle, lambda u=url: self._detail(u))137 except Exception: # une fiche cassée ≠ inventaire perdu138 d = {}139 if not d.get("title"):140 continue141 listings.append(StListing(142 source=self.source_id,143 external_id=code, # code interne (ADE-440), stable144 url=url,145 title=d["title"],146 property_type=d.get("property_type") or "Chalet",147 address=d.get("address", ""),148 city=d.get("city", ""),149 region=_HORS_CHARLEVOIX.get(ville_slug, "Charlevoix"),150 price_night=d.get("price_night"),151 price_label=d.get("price_label", ""),152 capacity=d.get("capacity"),153 bedrooms=d.get("bedrooms"),154 beds=d.get("beds"),155 bathrooms=d.get("bathrooms"),156 pets=d.get("pets"),157 citq=d.get("citq", ""),158 description=d.get("description", ""),159 amenities=d.get("amenities") or [],160 images=d.get("images") or [],161 lat=d.get("lat"),162 lng=d.get("lng"),163 ))164 return listings165