SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

5.7 KB · 131 lines python
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèle de données standardisé (Event) + normalisation centrale5#             (patron Lou-Ka : louka/schema.py — Listing → ici Event)6# -----------------------------------------------------------------------------7"""Schéma standard d'un événement (Event) et normalisation des champs.89Chaque connecteur, peu importe la source, doit produire des objets `Event`10conformes à ce schéma. La méthode `finalize()` applique ensuite la couche de11normalisation commune (sortika/normalize.py + sortika/regions.py) : dates ISO,12catégories canoniques, gratuité/prix, région administrative — les connecteurs13peuvent donc rester simples et remplir les champs bruts.14"""15from __future__ import annotations1617import hashlib18import json19from dataclasses import asdict, dataclass, field2021from .normalize import (22    clean_city,23    clean_text,24    map_categories,25    parse_date_iso,26    parse_latlng,27    parse_price,28    parse_time,29)30from .regions import is_valid_region, region_from_city, region_from_tourist3132__all__ = ["Event"]333435@dataclass36class Event:37    """Événement standardisé Sorti-Ka."""3839    source: str                       # id de la source (voir data/sources.json)40    external_id: str                  # identifiant chez la source41    url: str                          # fiche de l'événement chez la source42    title: str = ""                   # ex. "Festival d'été de Québec"43    description: str = ""44    categories: list[str] = field(default_factory=list)  # taxonomie canonique45    raw_categories: list[str] = field(default_factory=list)  # libellés source46    audience: str = ""                # ex. "Pour tous", "Adultes, Adolescents"47    venue: str = ""                   # nom du lieu (ex. "Quai Jacques-Cartier")48    address: str = ""                 # adresse civique49    city: str = ""                    # Montréal, Québec, ...50    region: str = ""                  # 1 des 17 régions administratives51    tourist_region: str = ""          # région touristique source (SITQ/ATR)52    postal_code: str = ""53    lat: float | None = None54    lng: float | None = None55    start_date: str | None = None     # ISO "2026-09-11"56    start_time: str | None = None     # "HH:MM" locale QC (None = inconnue)57    end_date: str | None = None       # ISO "2026-09-13"58    artists: list[str] = field(default_factory=list)  # artistes à l'affiche59    is_free: bool | None = None       # gratuit (None = inconnu)60    price_min: float | None = None    # plus bas prix ($ CAD) si payant61    price_label: str = ""             # texte original du coût (traçabilité)62    organizer: str = ""               # organisme / structure responsable63    website: str = ""                 # site officiel de l'événement64    image: str = ""                   # URL d'une image (si la source en offre)6566    @property67    def uid(self) -> str:68        return f"{self.source}:{self.external_id}"6970    def content_hash(self) -> str:71        """Hash du contenu pour la détection de changements (pseudo-webhook)."""72        payload = asdict(self)73        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)74        return hashlib.sha256(blob.encode("utf-8")).hexdigest()7576    def dedup_key(self) -> str:77        """Empreinte inter-sources : titre normalisé + ville + date de début.7879        Un même événement publié par deux sources (ex. SITQ et la ville) doit80        produire la même clé pour être fusionné (patron Lou-Ka / section 12).81        """82        from .normalize import strip_accents83        t = strip_accents(self.title).lower()84        t = "".join(c for c in t if c.isalnum())85        c = strip_accents(self.city).lower().replace(" ", "")86        return hashlib.sha1(f"{t}|{c}|{self.start_date or ''}".encode()).hexdigest()8788    def finalize(self) -> "Event":89        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.9091        Idempotent ; ne remplace jamais une valeur explicite du connecteur.92        """93        self.title = clean_text(self.title)94        self.description = clean_text(self.description)95        self.venue = clean_text(self.venue)96        self.address = clean_text(self.address)97        self.city = clean_city(self.city)98        self.audience = clean_text(self.audience)99        self.organizer = clean_text(self.organizer)100101        self.start_date = parse_date_iso(self.start_date)102        self.start_time = parse_time(self.start_time)103        self.end_date = parse_date_iso(self.end_date) or self.start_date104105        # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés106        seen: set[str] = set()107        cleaned: list[str] = []108        for a in self.artists:109            a = clean_text(a)110            if a and a.lower() not in seen:111                seen.add(a.lower())112                cleaned.append(a)113        self.artists = cleaned114115        if not self.categories:116            self.categories = map_categories(self.raw_categories)117118        if self.is_free is None and self.price_min is None and self.price_label:119            self.is_free, self.price_min, self.price_label = parse_price(self.price_label)120121        self.lat, self.lng = parse_latlng(self.lat, self.lng)122123        # Région administrative : source explicite > région touristique > ville.124        if not is_valid_region(self.region):125            self.region = (region_from_tourist(self.tourist_region)126                           or region_from_city(self.city) or "")127128        if not self.title:129            raise ValueError(f"événement sans titre : {self.uid}")130        return self131
Files · 86b14940e428b05703dc2df8840968f935f4d40d
Menu

Theme