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%
8.2 KB · 198 lines python
Raw Blame History
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