SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.0 KB · 165 lines python
Raw Blame History
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