# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # schema.py : modèle standardisé d'un hébergement court terme (StListing) # ----------------------------------------------------------------------------- """Schéma standard d'un hébergement court terme. Chaque connecteur court terme produit des objets `StListing`. `finalize()` applique la normalisation commune (prix à la nuit, région touristique, commodités dérivées, bornes géographiques du Québec). """ from __future__ import annotations import hashlib import html as _html import json import re from dataclasses import dataclass, field, asdict from ..normalize import strip_accents # Types canoniques d'hébergement (affichage + filtre) PROPERTY_TYPES = [ "Chalet", "Maison", "Condo", "Appartement", "Studio", "Loft", "Chambre", "Gîte", "Auberge", "Hôtel", "Motel", "Dôme", "Yourte", "Mini-maison", "Prêt-à-camper", "Refuge", "Camping", "Autre", ] # Régions touristiques du Québec (canoniques) REGIONS = [ "Abitibi-Témiscamingue", "Bas-Saint-Laurent", "Cantons-de-l'Est", "Centre-du-Québec", "Charlevoix", "Chaudière-Appalaches", "Côte-Nord", "Eeyou Istchee Baie-James", "Gaspésie", "Îles-de-la-Madeleine", "Lanaudière", "Laurentides", "Laval", "Mauricie", "Montérégie", "Montréal", "Nord-du-Québec", "Nunavik", "Outaouais", "Québec", "Saguenay–Lac-Saint-Jean", ] _REGION_KEYS = {strip_accents(r).lower().replace("–", "-").replace("'", ""): r for r in REGIONS} _REGION_ALIASES = { "estrie": "Cantons-de-l'Est", "cantons de lest": "Cantons-de-l'Est", "saguenay": "Saguenay–Lac-Saint-Jean", "lac saint jean": "Saguenay–Lac-Saint-Jean", "lac-saint-jean": "Saguenay–Lac-Saint-Jean", "region de quebec": "Québec", "ville de quebec": "Québec", "capitale nationale": "Québec", "grand montreal": "Montréal", "iles de la madeleine": "Îles-de-la-Madeleine", "baie james": "Eeyou Istchee Baie-James", } def normalize_region(raw: str) -> str: """Ramène un libellé de région libre vers la forme canonique ("" si inconnu).""" if not raw: return "" key = strip_accents(raw).lower().strip() key = re.sub(r"[–—]", "-", key).replace("'", "").replace("’", "") key = re.sub(r"\s+", " ", key) if key in _REGION_ALIASES: return _REGION_ALIASES[key] k2 = key.replace(" ", "-") if k2 in _REGION_KEYS: return _REGION_KEYS[k2] if key in _REGION_KEYS: return _REGION_KEYS[key] for rk, canon in _REGION_KEYS.items(): if rk.replace("-", " ") == key.replace("-", " "): return canon return raw.strip() _PRICE_RE = re.compile(r"(\d[\d\s,.  ]*)\s*\$") def parse_price_night(label: str) -> float | None: """Extrait un prix à la nuit d'un libellé (« 189 $ / nuit », « à partir de 250$ »).""" if not label: return None m = _PRICE_RE.search(label) if not m: return None raw = re.sub(r"[\s  ]", "", m.group(1)) # « 1,250.00 » vs « 1 250,00 » : la dernière ponctuation est la décimale if "," in raw and "." in raw: raw = raw.replace(",", "") if raw.rfind(".") > raw.rfind(",") \ else raw.replace(".", "").replace(",", ".") elif "," in raw: head, _, tail = raw.rpartition(",") raw = f"{head.replace(',', '')}.{tail}" if len(tail) == 2 else raw.replace(",", "") try: val = float(raw) except ValueError: return None lab = strip_accents(label).lower() if "sem" in lab or "/wk" in lab or "week" in lab: # prix à la semaine return round(val / 7, 2) if "mois" in lab or "month" in lab: # prix mensuel : hors sujet ici return None return val if 20 <= val <= 20000 else None _AMEN_FLAGS = { "spa": ("spa", "jacuzzi", "hot tub", "bain a remous", "bain tourbillon"), "pool": ("piscine", "pool"), "waterfront": ("bord de l'eau", "bord de leau", "bord du lac", "acces au lac", "acces lac", "waterfront", "lakefront", "riverain", "bord de riviere", "bord du fleuve", "plage privee"), "sauna": ("sauna",), "wifi": ("wifi", "wi-fi", "internet"), "fireplace": ("foyer", "fireplace", "poele a bois"), "ev_charger": ("borne", "recharge electrique", "ev charg"), } @dataclass class StListing: """Hébergement court terme standardisé Lou-Ka.""" source: str # id de la source (data/sources_ct.json) external_id: str # identifiant chez la source url: str # page de l'annonce chez la source title: str = "" property_type: str = "" # voir PROPERTY_TYPES address: str = "" city: str = "" # municipalité (ex. Saint-Sauveur) region: str = "" # région touristique (ex. Laurentides) price_night: float | None = None # $ CAD / nuit (le plus bas si « à partir de ») price_label: str = "" # texte original (« à partir de 189 $/nuit ») capacity: float | None = None # nombre de personnes bedrooms: float | None = None beds: float | None = None bathrooms: float | None = None pets: str | None = None # "oui" | "non" | "conditions" | None citq: str = "" # numéro d'établissement CITQ si affiché rating: float | None = None # note (normalisée sur 5) reviews: int | None = None # nombre d'avis description: str = "" amenities: list[str] = field(default_factory=list) details: dict = field(default_factory=dict) # drapeaux (spa, pool…) + libres images: list[str] = field(default_factory=list) lat: float | None = None lng: float | None = None @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: blob = json.dumps(asdict(self), sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def finalize(self) -> "StListing": """Normalisation commune — idempotente, ne remplace jamais une valeur explicite du connecteur.""" self.title = _html.unescape(self.title or "").strip() self.address = _html.unescape(self.address or "").strip() self.city = _html.unescape(self.city or "").strip() self.region = normalize_region(self.region) if self.property_type: pt = self.property_type.strip().capitalize() canon = {strip_accents(p).lower(): p for p in PROPERTY_TYPES} self.property_type = canon.get(strip_accents(pt).lower(), pt) if self.price_night is None: self.price_night = parse_price_night(self.price_label) if self.price_night is not None and not (20 <= self.price_night <= 20000): self.price_night = None # bornes du Québec : rejeter coordonnées aberrantes (0/0, inversées…) if self.lat is not None and self.lng is not None: if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): self.lat = self.lng = None # drapeaux dérivés des commodités + description (pour les filtres) haystack = strip_accents(" | ".join(self.amenities) + " | " + self.description[:2000]).lower() for flag, needles in _AMEN_FLAGS.items(): if flag not in self.details and any(n in haystack for n in needles): self.details[flag] = True if self.pets is None: if re.search(r"animaux (acceptes|admis|bienvenus)|pet friendly|" r"chiens? (acceptes?|admis|bienvenus?)", haystack): self.pets = "oui" elif re.search(r"(pas d|aucun|non aux) animaux|animaux (non admis|" r"interdits|refuses)|no pets", haystack): self.pets = "non" if not self.citq: m = re.search(r"citq\D{0,12}(\d{6})", haystack) if m: self.citq = m.group(1) # note sur 10 (Booking) → sur 5 if self.rating is not None and self.rating > 5.01: self.rating = round(self.rating / 2, 2) return self