Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
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)29from .regions import is_valid_region, region_from_city, region_from_tourist3031__all__ = ["Event"]323334@dataclass35class Event:36 """Événement standardisé Sorti-Ka."""3738 source: str # id de la source (voir data/sources.json)39 external_id: str # identifiant chez la source40 url: str # fiche de l'événement chez la source41 title: str = "" # ex. "Festival d'été de Québec"42 description: str = ""43 categories: list[str] = field(default_factory=list) # taxonomie canonique44 raw_categories: list[str] = field(default_factory=list) # libellés source45 audience: str = "" # ex. "Pour tous", "Adultes, Adolescents"46 venue: str = "" # nom du lieu (ex. "Quai Jacques-Cartier")47 address: str = "" # adresse civique48 city: str = "" # Montréal, Québec, ...49 region: str = "" # 1 des 17 régions administratives50 tourist_region: str = "" # région touristique source (SITQ/ATR)51 postal_code: str = ""52 lat: float | None = None53 lng: float | None = None54 start_date: str | None = None # ISO "2026-09-11"55 end_date: str | None = None # ISO "2026-09-13"56 is_free: bool | None = None # gratuit (None = inconnu)57 price_min: float | None = None # plus bas prix ($ CAD) si payant58 price_label: str = "" # texte original du coût (traçabilité)59 organizer: str = "" # organisme / structure responsable60 website: str = "" # site officiel de l'événement61 image: str = "" # URL d'une image (si la source en offre)6263 @property64 def uid(self) -> str:65 return f"{self.source}:{self.external_id}"6667 def content_hash(self) -> str:68 """Hash du contenu pour la détection de changements (pseudo-webhook)."""69 payload = asdict(self)70 blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)71 return hashlib.sha256(blob.encode("utf-8")).hexdigest()7273 def dedup_key(self) -> str:74 """Empreinte inter-sources : titre normalisé + ville + date de début.7576 Un même événement publié par deux sources (ex. SITQ et la ville) doit77 produire la même clé pour être fusionné (patron Lou-Ka / section 12).78 """79 from .normalize import strip_accents80 t = strip_accents(self.title).lower()81 t = "".join(c for c in t if c.isalnum())82 c = strip_accents(self.city).lower().replace(" ", "")83 return hashlib.sha1(f"{t}|{c}|{self.start_date or ''}".encode()).hexdigest()8485 def finalize(self) -> "Event":86 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.8788 Idempotent ; ne remplace jamais une valeur explicite du connecteur.89 """90 self.title = clean_text(self.title)91 self.description = clean_text(self.description)92 self.venue = clean_text(self.venue)93 self.address = clean_text(self.address)94 self.city = clean_city(self.city)95 self.audience = clean_text(self.audience)96 self.organizer = clean_text(self.organizer)9798 self.start_date = parse_date_iso(self.start_date)99 self.end_date = parse_date_iso(self.end_date) or self.start_date100101 if not self.categories:102 self.categories = map_categories(self.raw_categories)103104 if self.is_free is None and self.price_min is None and self.price_label:105 self.is_free, self.price_min, self.price_label = parse_price(self.price_label)106107 self.lat, self.lng = parse_latlng(self.lat, self.lng)108109 # Région administrative : source explicite > région touristique > ville.110 if not is_valid_region(self.region):111 self.region = (region_from_tourist(self.tourist_region)112 or region_from_city(self.city) or "")113114 if not self.title:115 raise ValueError(f"événement sans titre : {self.uid}")116 return self117