# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # schema.py : modèle de données standardisé (Event) + normalisation centrale # (patron Lou-Ka : louka/schema.py — Listing → ici Event) # ----------------------------------------------------------------------------- """Schéma standard d'un événement (Event) et normalisation des champs. Chaque connecteur, peu importe la source, doit produire des objets `Event` conformes à ce schéma. La méthode `finalize()` applique ensuite la couche de normalisation commune (sortika/normalize.py + sortika/regions.py) : dates ISO, catégories canoniques, gratuité/prix, région administrative — les connecteurs peuvent donc rester simples et remplir les champs bruts. """ from __future__ import annotations import hashlib import json from dataclasses import asdict, dataclass, field from .normalize import ( clean_city, clean_text, map_categories, parse_date_iso, parse_latlng, parse_price, parse_time, ) from .regions import is_valid_region, region_from_city, region_from_tourist __all__ = ["Event", "normalize_status", "quarantine_reason"] # --------------------------------------------------------------------------- # Statut du cycle de vie billetterie (vague Phase 2) : "" = prévu (défaut), # et 3 états explicites remontés par les sources qui les publient # (eventbrite is_cancelled, evenko representation_status, ticketmaster # dates.status.code / cancelled, laval préfixe « ANNULÉ - » du titre). # --------------------------------------------------------------------------- STATUSES = ("", "cancelled", "postponed", "soldout") _STATUS_HINTS = ( ("cancel", "cancelled"), ("annul", "cancelled"), ("postpon", "postponed"), ("report", "postponed"), ("rescheduled", "postponed"), ("sold_out", "soldout"), ("soldout", "soldout"), ("sold out", "soldout"), ("complet", "soldout"), ) def normalize_status(raw: str | None) -> str: """Normalise un statut source vers la taxonomie interne — jamais inventé.""" if not raw: return "" k = str(raw).strip().lower() if k in STATUSES: return k for hint, status in _STATUS_HINTS: if hint in k: return status return "" # --------------------------------------------------------------------------- # Quarantaine qualité (Phase 2) : règles anti-aberrations évaluées à chaque # sync. Un événement en quarantaine reste en base (comptée, réintégrable dès # que la donnée source redevient saine) mais n'est PAS publié. # --------------------------------------------------------------------------- # villes canadiennes/américaines fréquentes hors Québec (clés sans accents) — # un connecteur qui laisse passer une date hors province tombe ici. _NON_QC_CITIES = { "ottawa", "toronto", "vancouver", "calgary", "edmonton", "winnipeg", "halifax", "moncton", "fredericton", "hamilton", "mississauga", "kingston", "london", "cornwall", "niagara falls", "st catharines", "windsor", "saskatoon", "regina", "victoria", "charlottetown", "st john s", "boston", "new york", "burlington", "plattsburgh", "paris", } QUARANTINE_HORIZON_DAYS = 730 # date de début à plus de 2 ans = suspecte def quarantine_reason(ev: "Event", today: str) -> str | None: """Raison de quarantaine, ou None si l'événement est publiable. Règles (consignées Phase 2) : · date de début aberrante (avant 2000 — les passés « normaux » sont gérés par l'archivage, pas par la quarantaine) ; · date de début à plus de 2 ans dans le futur (erreur de saisie source) ; · ville manifestement hors Québec (liste de villes connues hors province). L'heure 00:00 n'est PAS une cause de quarantaine : elle est affichée « heure non confirmée » côté frontend. L'image 404 n'est pas vérifiée (coût réseau prohibitif) : le frontend retire les images cassées (onerror). """ from datetime import date, timedelta if ev.start_date: if ev.start_date < "2000-01-01": return f"date de début aberrante ({ev.start_date})" try: horizon = (date.fromisoformat(today) + timedelta(days=QUARANTINE_HORIZON_DAYS)).isoformat() except ValueError: horizon = "2100-01-01" if ev.start_date > horizon: return f"date de début à plus de 2 ans ({ev.start_date})" from .normalize import _key if ev.city and _key(ev.city) in _NON_QC_CITIES: return f"ville hors Québec ({ev.city})" return None @dataclass class Event: """Événement standardisé Sorti-Ka.""" source: str # id de la source (voir data/sources.json) external_id: str # identifiant chez la source url: str # fiche de l'événement chez la source title: str = "" # ex. "Festival d'été de Québec" description: str = "" categories: list[str] = field(default_factory=list) # taxonomie canonique raw_categories: list[str] = field(default_factory=list) # libellés source audience: str = "" # ex. "Pour tous", "Adultes, Adolescents" venue: str = "" # nom du lieu (ex. "Quai Jacques-Cartier") address: str = "" # adresse civique city: str = "" # Montréal, Québec, ... region: str = "" # 1 des 17 régions administratives tourist_region: str = "" # région touristique source (SITQ/ATR) postal_code: str = "" lat: float | None = None lng: float | None = None start_date: str | None = None # ISO "2026-09-11" start_time: str | None = None # "HH:MM" locale QC (None = inconnue) end_date: str | None = None # ISO "2026-09-13" end_time: str | None = None # "HH:MM" locale QC (None = inconnue) status: str = "" # "" (prévu) | cancelled | postponed | soldout artists: list[str] = field(default_factory=list) # artistes à l'affiche is_free: bool | None = None # gratuit (None = inconnu) price_min: float | None = None # plus bas prix ($ CAD) si payant price_label: str = "" # texte original du coût (traçabilité) organizer: str = "" # organisme / structure responsable website: str = "" # site officiel de l'événement image: str = "" # URL d'une image (si la source en offre) @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: """Hash du contenu pour la détection de changements (pseudo-webhook).""" payload = asdict(self) blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def dedup_key(self) -> str: """Empreinte inter-sources : titre normalisé + ville + date + HEURE. Un même événement publié par deux sources (ex. SITQ et la ville) doit produire la même clé pour être fusionné (patron Lou-Ka / section 12). RÈGLE (Phase 2, 2026-08-19) : l'heure de début fait partie de la clé. Un même titre le même jour dans la même ville mais à des heures DIFFÉRENTES = des séances distinctes (ex. lepointdevente publie 11 séances horodatées d'un parcours le même jour) — la fusion d'affichage ne doit pas les écraser. Deux sources publiant la même séance à la même heure fusionnent toujours ; une fiche sans heure ne fusionne qu'avec les autres fiches sans heure (compromis assumé : mieux vaut un doublon visible que dix séances invisibles). """ from .normalize import strip_accents t = strip_accents(self.title).lower() t = "".join(c for c in t if c.isalnum()) c = strip_accents(self.city).lower().replace(" ", "") return hashlib.sha1( f"{t}|{c}|{self.start_date or ''}|{self.start_time or ''}" .encode()).hexdigest() def finalize(self) -> "Event": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. """ self.title = clean_text(self.title) self.description = clean_text(self.description) self.venue = clean_text(self.venue) self.address = clean_text(self.address) self.city = clean_city(self.city) self.audience = clean_text(self.audience) self.organizer = clean_text(self.organizer) self.start_date = parse_date_iso(self.start_date) self.start_time = parse_time(self.start_time) self.end_date = parse_date_iso(self.end_date) or self.start_date self.end_time = parse_time(self.end_time) self.status = normalize_status(self.status) # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés seen: set[str] = set() cleaned: list[str] = [] for a in self.artists: a = clean_text(a) if a and a.lower() not in seen: seen.add(a.lower()) cleaned.append(a) self.artists = cleaned if not self.categories: self.categories = map_categories(self.raw_categories) if self.is_free is None and self.price_min is None and self.price_label: self.is_free, self.price_min, self.price_label = parse_price(self.price_label) self.lat, self.lng = parse_latlng(self.lat, self.lng) # Région administrative : source explicite > région touristique > ville. if not is_valid_region(self.region): self.region = (region_from_tourist(self.tourist_region) or region_from_city(self.city) or "") if not self.title: raise ValueError(f"événement sans titre : {self.uid}") return self