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# schema.py : modèle standardisé d'un hébergement court terme (StListing)4# -----------------------------------------------------------------------------5"""Schéma standard d'un hébergement court terme.67Chaque connecteur court terme produit des objets `StListing`. `finalize()`8applique la normalisation commune (prix à la nuit, région touristique,9commodités dérivées, bornes géographiques du Québec).10"""11from __future__ import annotations1213import hashlib14import html as _html15import json16import re17from dataclasses import dataclass, field, asdict1819from ..normalize import strip_accents2021# Types canoniques d'hébergement (affichage + filtre)22PROPERTY_TYPES = [23 "Chalet", "Maison", "Condo", "Appartement", "Studio", "Loft", "Chambre",24 "Gîte", "Auberge", "Hôtel", "Motel", "Dôme", "Yourte", "Mini-maison",25 "Prêt-à-camper", "Refuge", "Camping", "Autre",26]2728# Régions touristiques du Québec (canoniques)29REGIONS = [30 "Abitibi-Témiscamingue", "Bas-Saint-Laurent", "Cantons-de-l'Est",31 "Centre-du-Québec", "Charlevoix", "Chaudière-Appalaches", "Côte-Nord",32 "Eeyou Istchee Baie-James", "Gaspésie", "Îles-de-la-Madeleine",33 "Lanaudière", "Laurentides", "Laval", "Mauricie", "Montérégie",34 "Montréal", "Nord-du-Québec", "Nunavik", "Outaouais", "Québec",35 "Saguenay–Lac-Saint-Jean",36]3738_REGION_KEYS = {strip_accents(r).lower().replace("–", "-").replace("'", ""): r39 for r in REGIONS}40_REGION_ALIASES = {41 "estrie": "Cantons-de-l'Est",42 "cantons de lest": "Cantons-de-l'Est",43 "saguenay": "Saguenay–Lac-Saint-Jean",44 "lac saint jean": "Saguenay–Lac-Saint-Jean",45 "lac-saint-jean": "Saguenay–Lac-Saint-Jean",46 "region de quebec": "Québec",47 "ville de quebec": "Québec",48 "capitale nationale": "Québec",49 "grand montreal": "Montréal",50 "iles de la madeleine": "Îles-de-la-Madeleine",51 "baie james": "Eeyou Istchee Baie-James",52}535455def normalize_region(raw: str) -> str:56 """Ramène un libellé de région libre vers la forme canonique ("" si inconnu)."""57 if not raw:58 return ""59 key = strip_accents(raw).lower().strip()60 key = re.sub(r"[–—]", "-", key).replace("'", "").replace("’", "")61 key = re.sub(r"\s+", " ", key)62 if key in _REGION_ALIASES:63 return _REGION_ALIASES[key]64 k2 = key.replace(" ", "-")65 if k2 in _REGION_KEYS:66 return _REGION_KEYS[k2]67 if key in _REGION_KEYS:68 return _REGION_KEYS[key]69 for rk, canon in _REGION_KEYS.items():70 if rk.replace("-", " ") == key.replace("-", " "):71 return canon72 return raw.strip()737475_PRICE_RE = re.compile(r"(\d[\d\s,. ]*)\s*\$")767778def parse_price_night(label: str) -> float | None:79 """Extrait un prix à la nuit d'un libellé (« 189 $ / nuit », « à partir de 250$ »)."""80 if not label:81 return None82 m = _PRICE_RE.search(label)83 if not m:84 return None85 raw = re.sub(r"[\s ]", "", m.group(1))86 # « 1,250.00 » vs « 1 250,00 » : la dernière ponctuation est la décimale87 if "," in raw and "." in raw:88 raw = raw.replace(",", "") if raw.rfind(".") > raw.rfind(",") \89 else raw.replace(".", "").replace(",", ".")90 elif "," in raw:91 head, _, tail = raw.rpartition(",")92 raw = f"{head.replace(',', '')}.{tail}" if len(tail) == 2 else raw.replace(",", "")93 try:94 val = float(raw)95 except ValueError:96 return None97 lab = strip_accents(label).lower()98 if "sem" in lab or "/wk" in lab or "week" in lab: # prix à la semaine99 return round(val / 7, 2)100 if "mois" in lab or "month" in lab: # prix mensuel : hors sujet ici101 return None102 return val if 20 <= val <= 20000 else None103104105_AMEN_FLAGS = {106 "spa": ("spa", "jacuzzi", "hot tub", "bain a remous", "bain tourbillon"),107 "pool": ("piscine", "pool"),108 "waterfront": ("bord de l'eau", "bord de leau", "bord du lac", "acces au lac",109 "acces lac", "waterfront", "lakefront", "riverain",110 "bord de riviere", "bord du fleuve", "plage privee"),111 "sauna": ("sauna",),112 "wifi": ("wifi", "wi-fi", "internet"),113 "fireplace": ("foyer", "fireplace", "poele a bois"),114 "ev_charger": ("borne", "recharge electrique", "ev charg"),115}116117118@dataclass119class StListing:120 """Hébergement court terme standardisé Lou-Ka."""121122 source: str # id de la source (data/sources_ct.json)123 external_id: str # identifiant chez la source124 url: str # page de l'annonce chez la source125 title: str = ""126 property_type: str = "" # voir PROPERTY_TYPES127 address: str = ""128 city: str = "" # municipalité (ex. Saint-Sauveur)129 region: str = "" # région touristique (ex. Laurentides)130 price_night: float | None = None # $ CAD / nuit (le plus bas si « à partir de »)131 price_label: str = "" # texte original (« à partir de 189 $/nuit »)132 capacity: float | None = None # nombre de personnes133 bedrooms: float | None = None134 beds: float | None = None135 bathrooms: float | None = None136 pets: str | None = None # "oui" | "non" | "conditions" | None137 citq: str = "" # numéro d'établissement CITQ si affiché138 rating: float | None = None # note (normalisée sur 5)139 reviews: int | None = None # nombre d'avis140 description: str = ""141 amenities: list[str] = field(default_factory=list)142 details: dict = field(default_factory=dict) # drapeaux (spa, pool…) + libres143 images: list[str] = field(default_factory=list)144 lat: float | None = None145 lng: float | None = None146147 @property148 def uid(self) -> str:149 return f"{self.source}:{self.external_id}"150151 def content_hash(self) -> str:152 blob = json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)153 return hashlib.sha256(blob.encode("utf-8")).hexdigest()154155 def finalize(self) -> "StListing":156 """Normalisation commune — idempotente, ne remplace jamais une valeur157 explicite du connecteur."""158 self.title = _html.unescape(self.title or "").strip()159 self.address = _html.unescape(self.address or "").strip()160 self.city = _html.unescape(self.city or "").strip()161 self.region = normalize_region(self.region)162 if self.property_type:163 pt = self.property_type.strip().capitalize()164 canon = {strip_accents(p).lower(): p for p in PROPERTY_TYPES}165 self.property_type = canon.get(strip_accents(pt).lower(), pt)166167 if self.price_night is None:168 self.price_night = parse_price_night(self.price_label)169 if self.price_night is not None and not (20 <= self.price_night <= 20000):170 self.price_night = None171172 # bornes du Québec : rejeter coordonnées aberrantes (0/0, inversées…)173 if self.lat is not None and self.lng is not None:174 if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):175 self.lat = self.lng = None176177 # drapeaux dérivés des commodités + description (pour les filtres)178 haystack = strip_accents(" | ".join(self.amenities) + " | "179 + self.description[:2000]).lower()180 for flag, needles in _AMEN_FLAGS.items():181 if flag not in self.details and any(n in haystack for n in needles):182 self.details[flag] = True183 if self.pets is None:184 if re.search(r"animaux (acceptes|admis|bienvenus)|pet friendly|"185 r"chiens? (acceptes?|admis|bienvenus?)", haystack):186 self.pets = "oui"187 elif re.search(r"(pas d|aucun|non aux) animaux|animaux (non admis|"188 r"interdits|refuses)|no pets", haystack):189 self.pets = "non"190 if not self.citq:191 m = re.search(r"citq\D{0,12}(\d{6})", haystack)192 if m:193 self.citq = m.group(1)194 # note sur 10 (Booking) → sur 5195 if self.rating is not None and self.rating > 5.01:196 self.rating = round(self.rating / 2, 2)197 return self198