SPB Git forge

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.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
10.0 KB · 222 lines python
Raw Blame History
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", "normalize_status", "quarantine_reason"]3334# ---------------------------------------------------------------------------35# Statut du cycle de vie billetterie (vague Phase 2) : "" = prévu (défaut),36# et 3 états explicites remontés par les sources qui les publient37# (eventbrite is_cancelled, evenko representation_status, ticketmaster38# dates.status.code / cancelled, laval préfixe « ANNULÉ - » du titre).39# ---------------------------------------------------------------------------40STATUSES = ("", "cancelled", "postponed", "soldout")4142_STATUS_HINTS = (43    ("cancel", "cancelled"), ("annul", "cancelled"),44    ("postpon", "postponed"), ("report", "postponed"), ("rescheduled", "postponed"),45    ("sold_out", "soldout"), ("soldout", "soldout"), ("sold out", "soldout"),46    ("complet", "soldout"),47)484950def normalize_status(raw: str | None) -> str:51    """Normalise un statut source vers la taxonomie interne — jamais inventé."""52    if not raw:53        return ""54    k = str(raw).strip().lower()55    if k in STATUSES:56        return k57    for hint, status in _STATUS_HINTS:58        if hint in k:59            return status60    return ""616263# ---------------------------------------------------------------------------64# Quarantaine qualité (Phase 2) : règles anti-aberrations évaluées à chaque65# sync. Un événement en quarantaine reste en base (comptée, réintégrable dès66# que la donnée source redevient saine) mais n'est PAS publié.67# ---------------------------------------------------------------------------6869# villes canadiennes/américaines fréquentes hors Québec (clés sans accents) —70# un connecteur qui laisse passer une date hors province tombe ici.71_NON_QC_CITIES = {72    "ottawa", "toronto", "vancouver", "calgary", "edmonton", "winnipeg",73    "halifax", "moncton", "fredericton", "hamilton", "mississauga", "kingston",74    "london", "cornwall", "niagara falls", "st catharines", "windsor",75    "saskatoon", "regina", "victoria", "charlottetown", "st john s",76    "boston", "new york", "burlington", "plattsburgh", "paris",77}7879QUARANTINE_HORIZON_DAYS = 730     # date de début à plus de 2 ans = suspecte808182def quarantine_reason(ev: "Event", today: str) -> str | None:83    """Raison de quarantaine, ou None si l'événement est publiable.8485    Règles (consignées Phase 2) :86    · date de début aberrante (avant 2000 — les passés « normaux » sont87      gérés par l'archivage, pas par la quarantaine) ;88    · date de début à plus de 2 ans dans le futur (erreur de saisie source) ;89    · ville manifestement hors Québec (liste de villes connues hors province).90    L'heure 00:00 n'est PAS une cause de quarantaine : elle est affichée91    « heure non confirmée » côté frontend. L'image 404 n'est pas vérifiée92    (coût réseau prohibitif) : le frontend retire les images cassées (onerror).93    """94    from datetime import date, timedelta95    if ev.start_date:96        if ev.start_date < "2000-01-01":97            return f"date de début aberrante ({ev.start_date})"98        try:99            horizon = (date.fromisoformat(today)100                       + timedelta(days=QUARANTINE_HORIZON_DAYS)).isoformat()101        except ValueError:102            horizon = "2100-01-01"103        if ev.start_date > horizon:104            return f"date de début à plus de 2 ans ({ev.start_date})"105    from .normalize import _key106    if ev.city and _key(ev.city) in _NON_QC_CITIES:107        return f"ville hors Québec ({ev.city})"108    return None109110111@dataclass112class Event:113    """Événement standardisé Sorti-Ka."""114115    source: str                       # id de la source (voir data/sources.json)116    external_id: str                  # identifiant chez la source117    url: str                          # fiche de l'événement chez la source118    title: str = ""                   # ex. "Festival d'été de Québec"119    description: str = ""120    categories: list[str] = field(default_factory=list)  # taxonomie canonique121    raw_categories: list[str] = field(default_factory=list)  # libellés source122    audience: str = ""                # ex. "Pour tous", "Adultes, Adolescents"123    venue: str = ""                   # nom du lieu (ex. "Quai Jacques-Cartier")124    address: str = ""                 # adresse civique125    city: str = ""                    # Montréal, Québec, ...126    region: str = ""                  # 1 des 17 régions administratives127    tourist_region: str = ""          # région touristique source (SITQ/ATR)128    postal_code: str = ""129    lat: float | None = None130    lng: float | None = None131    start_date: str | None = None     # ISO "2026-09-11"132    start_time: str | None = None     # "HH:MM" locale QC (None = inconnue)133    end_date: str | None = None       # ISO "2026-09-13"134    end_time: str | None = None       # "HH:MM" locale QC (None = inconnue)135    status: str = ""                  # "" (prévu) | cancelled | postponed | soldout136    artists: list[str] = field(default_factory=list)  # artistes à l'affiche137    is_free: bool | None = None       # gratuit (None = inconnu)138    price_min: float | None = None    # plus bas prix ($ CAD) si payant139    price_label: str = ""             # texte original du coût (traçabilité)140    organizer: str = ""               # organisme / structure responsable141    website: str = ""                 # site officiel de l'événement142    image: str = ""                   # URL d'une image (si la source en offre)143144    @property145    def uid(self) -> str:146        return f"{self.source}:{self.external_id}"147148    def content_hash(self) -> str:149        """Hash du contenu pour la détection de changements (pseudo-webhook)."""150        payload = asdict(self)151        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)152        return hashlib.sha256(blob.encode("utf-8")).hexdigest()153154    def dedup_key(self) -> str:155        """Empreinte inter-sources : titre normalisé + ville + date + HEURE.156157        Un même événement publié par deux sources (ex. SITQ et la ville) doit158        produire la même clé pour être fusionné (patron Lou-Ka / section 12).159160        RÈGLE (Phase 2, 2026-08-19) : l'heure de début fait partie de la clé.161        Un même titre le même jour dans la même ville mais à des heures162        DIFFÉRENTES = des séances distinctes (ex. lepointdevente publie 11163        séances horodatées d'un parcours le même jour) — la fusion d'affichage164        ne doit pas les écraser. Deux sources publiant la même séance à la165        même heure fusionnent toujours ; une fiche sans heure ne fusionne166        qu'avec les autres fiches sans heure (compromis assumé : mieux vaut167        un doublon visible que dix séances invisibles).168        """169        from .normalize import strip_accents170        t = strip_accents(self.title).lower()171        t = "".join(c for c in t if c.isalnum())172        c = strip_accents(self.city).lower().replace(" ", "")173        return hashlib.sha1(174            f"{t}|{c}|{self.start_date or ''}|{self.start_time or ''}"175            .encode()).hexdigest()176177    def finalize(self) -> "Event":178        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.179180        Idempotent ; ne remplace jamais une valeur explicite du connecteur.181        """182        self.title = clean_text(self.title)183        self.description = clean_text(self.description)184        self.venue = clean_text(self.venue)185        self.address = clean_text(self.address)186        self.city = clean_city(self.city)187        self.audience = clean_text(self.audience)188        self.organizer = clean_text(self.organizer)189190        self.start_date = parse_date_iso(self.start_date)191        self.start_time = parse_time(self.start_time)192        self.end_date = parse_date_iso(self.end_date) or self.start_date193        self.end_time = parse_time(self.end_time)194        self.status = normalize_status(self.status)195196        # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés197        seen: set[str] = set()198        cleaned: list[str] = []199        for a in self.artists:200            a = clean_text(a)201            if a and a.lower() not in seen:202                seen.add(a.lower())203                cleaned.append(a)204        self.artists = cleaned205206        if not self.categories:207            self.categories = map_categories(self.raw_categories)208209        if self.is_free is None and self.price_min is None and self.price_label:210            self.is_free, self.price_min, self.price_label = parse_price(self.price_label)211212        self.lat, self.lng = parse_latlng(self.lat, self.lng)213214        # Région administrative : source explicite > région touristique > ville.215        if not is_valid_region(self.region):216            self.region = (region_from_tourist(self.tourist_region)217                           or region_from_city(self.city) or "")218219        if not self.title:220            raise ValueError(f"événement sans titre : {self.uid}")221        return self222