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.

Vague 2 d'enrichissement : heure précise, artistes, 3 nouveaux connecteurs

- Colonnes additives events.start_time (HH:MM locale QC) et events.artists
  (JSON) + migration auto à la connexion ; Event.finalize normalise l'heure
  (parse_time) et déduplique les artistes.
- Heure captée partout où la source la publie : lepointdevente (cartes de
  liste « 17 août 2026, 20h00 » + fiches), evenko (show_time epoch →
  America/Montreal, respect hide_show_time), Laval et Sherbrooke (flux UTC →
  date+heure locales, minuit local = toute la journée), Montréal (JSON-LD des
  fiches pendant l'enrichissement og:image, cache rétro-compatible), atuvu.
- Artistes : headliners evenko (explicite), lineup Bandsintown, attractions
  Ticketmaster, et extraction conservatrice des titres lpdv (patrons sûrs
  « Artiste - Salle » / « X en concert/rodage », liste de mots interdits).
- Nouveau connecteur Ville de Brossard (API REST WordPress city-event +
  bannière/JSON-LD des fiches, cache incrémental) — 251 événements.
- Nouveau connecteur Bandsintown (dates QC des artistes connus de la base,
  rotation 100 artistes/sync, app_id js_www.sorti-ka.com testé accepté) —
  108 concerts géolocalisés avec heure.
- Nouveau connecteur Ticketmaster Discovery API, complet mais « prêt — clé
  requise » (TICKETMASTER_API_KEY absent de .env ; skip propre + .env.example).
- Gatineau, Trois-Rivières, Lévis, Terrebonne : étudiées et documentées
  « abandonnée » dans sources.json (aucun flux exploitable, vérifié 2026-08-18).
- Dédup à l'affichage : rang prix > GPS > image > description ; /api/stats
  expose « sources » (10 actives) pour le hub groupe-ka ; SSR : heure dans le
  JSON-LD (offset québécois) + performer ; frontend : heure sur les cartes et
  fiches, « À l'affiche », libellés des nouvelles sources.
- Tests : 22 → 32 (parse_time, utc_to_local, artistes, migration, TM/BIT/
  Brossard sur structures réelles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 7d9d1b0 Browse files

22 changed files +1,029 −46

added .env.example +23 −0
@@ -0,0 +1,23 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: .env.example
4+# Desc: Gabarit des secrets Sorti-Ka — copier en `.env` et remplir.
5+# Le vrai `.env` n'est JAMAIS commité (voir .gitignore).
6+# ==============================================================================
7+
8+# Scrapfly — contournement Cloudflare (requis seulement pour le connecteur Atuvu)
9+SCRAPFLY_API_KEY=
10+
11+# Ticketmaster Discovery API — clé gratuite : https://developer.ticketmaster.com
12+# Sans clé, le connecteur ticketmaster s'annonce « clé manquante » et ne fait rien.
13+TICKETMASTER_API_KEY=
14+
15+# Bandsintown — app_id d'identification (le format « js_<domaine> » des widgets
16+# officiels est accepté par l'API publique ; défaut : js_www.sorti-ka.com)
17+#BANDSINTOWN_APP_ID=js_www.sorti-ka.com
18+
19+# Sessions web + SSO Groupe KA
20+SESSION_SECRET=
21+KA_SSO_SECRET=
22+SORTIKA_BASE_URL=https://www.sorti-ka.com
23+KA_HUB_URL=https://www.groupe-ka.com
modified .gitignore +1 −0
@@ -15,3 +15,4 @@ __pycache__/
15 data/sortika.db-shm 15 data/sortika.db-shm
16 data/sortika.db-wal 16 data/sortika.db-wal
17 data/sortika.db 17 data/sortika.db
18+data/*.bak-*
modified README.md +12 −8
@@ -36,15 +36,19 @@ Sorti·Ka **n'est pas une billetterie** : c'est un **index fidèle**. Aucun prix
36 | **evenko** | promoteur — robots `Allow: /api/search*` | proxy Algolia (~460 grands concerts : Centre Bell, MTELUS…) | 36 | **evenko** | promoteur — robots `Allow: /api/search*` | proxy Algolia (~460 grands concerts : Centre Bell, MTELUS…) |
37 | **La Vitrine** | Content-Signal `search=yes` | sitemap + fiches HTML, cache incrémental (~5 100 représentations) | 37 | **La Vitrine** | Content-Signal `search=yes` | sitemap + fiches HTML, cache incrémental (~5 100 représentations) |
38 | **Atuvu.ca** | Cloudflare → **Scrapfly** | sitemaps lastmod + fiches, rattrapage progressif | 38 | **Atuvu.ca** | Cloudflare → **Scrapfly** | sitemaps lastmod + fiches, rattrapage progressif |
39−| **Ville de Sherbrooke** | calendrier municipal public | JSON embarqué — 1 requête (~270 événements) | 39+| **Ville de Sherbrooke** | calendrier municipal public | JSON embarqué — 1 requête (~270 événements, heures locales) |
40−| **Ville de Laval** | données ouvertes CC 4.0 | JSON officiel (~290 activités) | 40+| **Ville de Laval** | données ouvertes CC 4.0 | JSON officiel (~290 activités, heures locales) |
41+| **Ville de Brossard** | API REST WordPress publique | `/wp-json/wp/v2/city-event` + fiches (bannière ou JSON-LD, ~400 événements) |
42+| **Bandsintown** | API publique par artiste | dates QC des artistes connus de la base (rotation 100 artistes/sync) |
43+| **Ticketmaster** | Discovery API officielle | **prêt — clé requise** (`TICKETMASTER_API_KEY`, gratuite) |
41 44
42 - **Déduplication inter-sources** — empreinte titre + ville + date : un même concert publié par 3 sources = **une seule carte**, la fiche la plus riche gagne 45 - **Déduplication inter-sources** — empreinte titre + ville + date : un même concert publié par 3 sources = **une seule carte**, la fiche la plus riche gagne
43 - **Répertoire des municipalités MAMH** — **1 250 villes** + arrondissements + alias, homonymes résolus par population 46 - **Répertoire des municipalités MAMH** — **1 250 villes** + arrondissements + alias, homonymes résolus par population
44 - **Persistance robuste** — upsert par hash de contenu, **délai de grâce 2 syncs**, alerte de dérive, historique de syncs 47 - **Persistance robuste** — upsert par hash de contenu, **délai de grâce 2 syncs**, alerte de dérive, historique de syncs
45 - **Page /stats** — tableau de bord analytique + **rapport PDF Groupe-KA** 48 - **Page /stats** — tableau de bord analytique + **rapport PDF Groupe-KA**
46 - **Widget KA Agent** — bulle de chat Groupe KA intégrée au frontend 49 - **Widget KA Agent** — bulle de chat Groupe KA intégrée au frontend
47−- **19 tests** sur fixtures réelles, **zéro réseau** 50+- **Heure précise (`start_time`) et artistes (`artists`)** — captés quand la source les publie (billetteries, calendriers municipaux), jamais inventés
51+- **32 tests** sur fixtures réelles, **zéro réseau**
48 52
49 ## Stack technique 53 ## Stack technique
50 54
@@ -68,7 +72,7 @@ Sorti·Ka **n'est pas une billetterie** : c'est un **index fidèle**. Aucun prix
68 | Couche | Rôle | Fichiers | 72 | Couche | Rôle | Fichiers |
69 |---|---|---| 73 |---|---|---|
70 | **Connecteurs** | 1 module Python par source : API JSON internes (Algolia evenko, Tourinsoft, CKAN), listes HTML, Scrapfly, caches incrémentaux | `sortika/connectors/*.py` | 74 | **Connecteurs** | 1 module Python par source : API JSON internes (Algolia evenko, Tourinsoft, CKAN), listes HTML, Scrapfly, caches incrémentaux | `sortika/connectors/*.py` |
71−| **Schéma** | `Event` standardisé : titre, catégories canoniques, lieu, ville, **région (17)**, lat/lng, dates ISO, gratuité/prix avec `price_label` source | `sortika/schema.py` | 75+| **Schéma** | `Event` standardisé : titre, catégories canoniques, lieu, ville, **région (17)**, lat/lng, dates ISO + **heure locale**, **artistes**, gratuité/prix avec `price_label` source | `sortika/schema.py` |
72 | **Normalisation** | dates FR/ISO, prix, **14 catégories**, villes, entités HTML | `sortika/normalize.py` | 76 | **Normalisation** | dates FR/ISO, prix, **14 catégories**, villes, entités HTML | `sortika/normalize.py` |
73 | **Régions** | Répertoire MAMH : 1 250 villes + arrondissements + alias | `sortika/regions.py` + `data/villes_regions.json` | 77 | **Régions** | Répertoire MAMH : 1 250 villes + arrondissements + alias | `sortika/regions.py` + `data/villes_regions.json` |
74 | **Persistance** | upsert par hash, délai de grâce, alerte de dérive | `sortika/db.py` | 78 | **Persistance** | upsert par hash, délai de grâce, alerte de dérive | `sortika/db.py` |
@@ -92,13 +96,13 @@ Sorti·Ka **n'est pas une billetterie** : c'est un **index fidèle**. Aucun prix
92 96
93 ```bash 97 ```bash
94 python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 98 python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
95−.venv/bin/python -m pytest tests/ -q # 19 tests (fixtures réelles, zéro réseau) 99+.venv/bin/python -m pytest tests/ -q # 32 tests (fixtures réelles, zéro réseau)
96−.venv/bin/python run.py sync # ingestion des 8 sources 100+.venv/bin/python run.py sync # ingestion des 10 sources actives
97 .venv/bin/python run.py web --port 8120 # API + frontend + SSR 101 .venv/bin/python run.py web --port 8120 # API + frontend + SSR
98 .venv/bin/python run.py watch --interval 3600 # boucle d'ingestion horaire 102 .venv/bin/python run.py watch --interval 3600 # boucle d'ingestion horaire
99 ``` 103 ```
100 104
101−**Secrets** : `SCRAPFLY_API_KEY` dans `.env` (requis seulement pour le connecteur Atuvu). 105+**Secrets** (gabarit : `.env.example`) : `SCRAPFLY_API_KEY` (connecteur Atuvu) ; `TICKETMASTER_API_KEY` (active le connecteur Ticketmaster, clé gratuite).
102 106
103 ## Déploiement 107 ## Déploiement
104 108
@@ -107,7 +111,7 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
107 - **Domaine** : **[www.sorti-ka.com](https://www.sorti-ka.com)** (tunnel ngrok) 111 - **Domaine** : **[www.sorti-ka.com](https://www.sorti-ka.com)** (tunnel ngrok)
108 - **PM2** (3 processus) : 112 - **PM2** (3 processus) :
109 - **`sorti-ka-web`** — uvicorn sur le port **8120** (API + frontend + SSR) 113 - **`sorti-ka-web`** — uvicorn sur le port **8120** (API + frontend + SSR)
110− - **`sorti-ka-sync`** — watcher **horaire** (mise à jour continue des 8 sources) 114+ - **`sorti-ka-sync`** — watcher **horaire** (mise à jour continue des 10 sources actives)
111 - **`sorti-ka-ngrok`** — tunnel vers **www.sorti-ka.com** 115 - **`sorti-ka-ngrok`** — tunnel vers **www.sorti-ka.com**
112 - **Registre** : `~/Desktop/cluster-skill/cluster-deployments.json` 116 - **Registre** : `~/Desktop/cluster-skill/cluster-deployments.json`
113 117
modified data/sources.json +88 −6
@@ -17,7 +17,7 @@
17 "name": "Ville de Montréal — Événements publics", 17 "name": "Ville de Montréal — Événements publics",
18 "url": "https://donnees.montreal.ca/dataset/evenements-publics", 18 "url": "https://donnees.montreal.ca/dataset/evenements-publics",
19 "type": "donnees-ouvertes", 19 "type": "donnees-ouvertes",
20− "extraction": "API CKAN datastore_search (JSON, paginée) — venue = emplacement (si vrai nom de lieu ; aujourd'hui la ville n'y publie que le mode En salle/À l'extérieur/En ligne) ou titre_adresse ; address = adresse_principale + adresse_secondaire", 20+ "extraction": "API CKAN datastore_search (JSON, paginée) — venue = emplacement (si vrai nom de lieu) ou titre_adresse ; address = adresse_principale + adresse_secondaire ; le jeu de données ne publie pas l'heure → heure locale captée dans le JSON-LD des fiches montreal.ca (« startDate »:\"…T19:30:00-04:00\") lors de l'enrichissement og:image incrémental (cache, plafond MTL_IMG_MAX/sync)",
21 "acces_legal": "Licence CC BY 4.0 (donnees.montreal.ca) — attribution : Ville de Montréal", 21 "acces_legal": "Licence CC BY 4.0 (donnees.montreal.ca) — attribution : Ville de Montréal",
22 "couverture": "Événements municipaux, île de Montréal", 22 "couverture": "Événements municipaux, île de Montréal",
23 "cadence": "horaire (watcher PM2)", 23 "cadence": "horaire (watcher PM2)",
@@ -28,7 +28,7 @@
28 "name": "Le point de vente — billetterie québécoise", 28 "name": "Le point de vente — billetterie québécoise",
29 "url": "https://lepointdevente.com/billets", 29 "url": "https://lepointdevente.com/billets",
30 "type": "billetterie", 30 "type": "billetterie",
31− "extraction": "pages de liste HTML rendues serveur (paginées), parse des cartes + fiches détail /billets/<id> incrémentales (cache data/lepointdevente_cache.json, 150 fiches/sync, re-visite 7 j) : description og:description, heure précise, tarifs JSON réels via /plugins/rates/<id> → price_min/price_label/is_free", 31+ "extraction": "pages de liste HTML rendues serveur (paginées), parse des cartes (titre, date FR + heure « 17 août 2026, 20h00 » → start_time, salle, ville, image) + fiches détail /billets/<id> incrémentales (cache data/lepointdevente_cache.json, 150 fiches/sync, re-visite 7 j) : description og:description, heure précise, tarifs JSON réels via /plugins/rates/<id> → price_min/price_label/is_free ; artistes extraits des titres par patrons sûrs seulement (« Artiste - Salle », « X en concert »)",
32 "acces_legal": "robots.txt : « User-Agent: * / Allow: * » — GET throttlés, identification honnête, lien direct vers chaque billetterie", 32 "acces_legal": "robots.txt : « User-Agent: * / Allow: * » — GET throttlés, identification honnête, lien direct vers chaque billetterie",
33 "couverture": "Concerts, humour, théâtre, festivals — partout au Québec (~2 500 événements à venir)", 33 "couverture": "Concerts, humour, théâtre, festivals — partout au Québec (~2 500 événements à venir)",
34 "cadence": "horaire (watcher PM2)", 34 "cadence": "horaire (watcher PM2)",
@@ -39,7 +39,7 @@
39 "name": "Ville de Laval — Calendrier des activités", 39 "name": "Ville de Laval — Calendrier des activités",
40 "url": "https://www.donneesquebec.ca/recherche/dataset/calendrier-des-activites", 40 "url": "https://www.donneesquebec.ca/recherche/dataset/calendrier-des-activites",
41 "type": "donnees-ouvertes", 41 "type": "donnees-ouvertes",
42− "extraction": "fichier JSON officiel (Données Québec)", 42+ "extraction": "fichier JSON officiel (Données Québec) — EventDate/EndDate publiés en UTC (vérifié 2026-08) → convertis en date + heure locales Québec (minuit local = « toute la journée », pas d'heure)",
43 "acces_legal": "Licence CC 4.0 (Données Québec) — Ville de Laval", 43 "acces_legal": "Licence CC 4.0 (Données Québec) — Ville de Laval",
44 "couverture": "Activités municipales, Laval", 44 "couverture": "Activités municipales, Laval",
45 "cadence": "horaire (watcher PM2)", 45 "cadence": "horaire (watcher PM2)",
@@ -50,7 +50,7 @@
50 "name": "Ville de Sherbrooke — Calendrier des événements", 50 "name": "Ville de Sherbrooke — Calendrier des événements",
51 "url": "https://www.sherbrooke.ca/fr/culture-sports-et-loisirs/calendrier-des-activites", 51 "url": "https://www.sherbrooke.ca/fr/culture-sports-et-loisirs/calendrier-des-activites",
52 "type": "municipal", 52 "type": "municipal",
53− "extraction": "JSON embarqué window['TRANSFER_CACHE'] (CMS Maruche) — 1 requête, ~270 événements avec images, gratuité, catégories", 53+ "extraction": "la page rend TOUT le calendrier dans window['TRANSFER_CACHE'] (JSON embarqué, CMS Maruche) → 1 seule requête, ~270 événements (nom, périodes en UTC → date + heure locales Québec, gratuité `payant`, catégories, clientèles, images)",
54 "acces_legal": "page municipale publique (référencée sur Données Québec) — GET direct, identification honnête", 54 "acces_legal": "page municipale publique (référencée sur Données Québec) — GET direct, identification honnête",
55 "couverture": "Événements municipaux et culturels, Sherbrooke (Estrie)", 55 "couverture": "Événements municipaux et culturels, Sherbrooke (Estrie)",
56 "cadence": "horaire (watcher PM2)", 56 "cadence": "horaire (watcher PM2)",
@@ -97,7 +97,7 @@
97 "name": "evenko — grands concerts et festivals", 97 "name": "evenko — grands concerts et festivals",
98 "url": "https://evenko.ca/fr/evenements", 98 "url": "https://evenko.ca/fr/evenements",
99 "type": "promoteur", 99 "type": "promoteur",
100− "extraction": "API /api/search (proxy Algolia) — format percé : GET ?body=base64({params:[query,options],lang:'fr-CA'}), filters entity_type:evenko_show, ~5 requêtes/sync ; gratuité : champ free (sondé null sur 100 % des hits, 2026-08) + étiquettes « gratuit » (event_tag/promotional_tag) ; aucun montant exposé par l'index", 100+ "extraction": "API /api/search (proxy Algolia) — format percé : GET ?body=base64({params:[query,options],lang:'fr-CA'}), filters entity_type:evenko_show, ~5 requêtes/sync ; heure locale du spectacle via show_time (epoch → America/Montreal, respect de hide_show_time) ; artistes = champ headliners explicite ; gratuité : champ free (sondé null sur 100 % des hits, 2026-08) + étiquettes « gratuit » (event_tag/promotional_tag) ; aucun montant exposé par l'index",
101 "acces_legal": "robots.txt : Allow /api/search* (explicite) — lien billets = URL Ticketmaster officielle", 101 "acces_legal": "robots.txt : Allow /api/search* (explicite) — lien billets = URL Ticketmaster officielle",
102 "couverture": "Grands concerts (Centre Bell, Place Bell, MTELUS…), festivals (Osheaga, ÎleSoniq…)", 102 "couverture": "Grands concerts (Centre Bell, Place Bell, MTELUS…), festivals (Osheaga, ÎleSoniq…)",
103 "cadence": "horaire (watcher PM2)", 103 "cadence": "horaire (watcher PM2)",
@@ -108,7 +108,7 @@
108 "name": "Atuvu.ca — calendrier culturel", 108 "name": "Atuvu.ca — calendrier culturel",
109 "url": "https://atuvu.ca/", 109 "url": "https://atuvu.ca/",
110 "type": "agregateur", 110 "type": "agregateur",
111− "extraction": "Scrapfly (Cloudflare) — sitemaps lastmod + fiches .event-play, cache incrémental data/atuvu_cache.json, plafond 120 fiches/sync", 111+ "extraction": "Scrapfly (Cloudflare) — sitemaps lastmod + fiches .event-play (date FR + heure « 20h00 » → start_time), cache incrémental data/atuvu_cache.json, plafond 120 fiches/sync",
112 "acces_legal": "robots.txt : Allow / + sitemap public", 112 "acces_legal": "robots.txt : Allow / + sitemap public",
113 "couverture": "Sorties culturelles, Grand Montréal", 113 "couverture": "Sorties culturelles, Grand Montréal",
114 "cadence": "horaire (watcher PM2, incrémental)", 114 "cadence": "horaire (watcher PM2, incrémental)",
@@ -124,6 +124,88 @@
124 "couverture": "Spectacles et sorties culturelles, Grand Montréal", 124 "couverture": "Spectacles et sorties culturelles, Grand Montréal",
125 "cadence": "horaire (watcher PM2, incrémental)", 125 "cadence": "horaire (watcher PM2, incrémental)",
126 "statut": "actif" 126 "statut": "actif"
127+ },
128+ {
129+ "id": "brossard",
130+ "name": "Ville de Brossard — Calendrier des événements",
131+ "url": "https://brossard.ca/calendrier-des-evenements/",
132+ "type": "municipal",
133+ "extraction": "API REST WordPress publique /wp-json/wp/v2/city-event (JSON paginé, ~400 fiches) + dates/heures par fiche : bannière « Cet événement aura lieu le … » (fiches ville) ou JSON-LD Event (fiches biblio.brossard.ca) — cache incrémental data/brossard_cache.json, 80 fiches/sync, re-visite 7 j ; repli : date entre parenthèses dans le titre",
134+ "acces_legal": "API REST WordPress publique (aucune authentification) — GET throttlés 0,8 s, identification honnête",
135+ "couverture": "Événements municipaux et de bibliothèque, Brossard (Montérégie)",
136+ "cadence": "horaire (watcher PM2, incrémental)",
137+ "statut": "actif"
138+ },
139+ {
140+ "id": "ticketmaster",
141+ "name": "Ticketmaster — Discovery API (Québec)",
142+ "url": "https://developer.ticketmaster.com/products-and-docs/apis/discovery-api/v2/",
143+ "type": "billetterie",
144+ "extraction": "API officielle Discovery v2 — GET JSON paginé (countryCode=CA & stateCode=QC, size=200, tri par date) : nom, dates + heure locale, salle géolocalisée (venues lat/lng), fourchette de prix (priceRanges min/max), images 16:9, classifications → catégories, attractions → artistes, URL de billetterie officielle ; throttle 0,25 s (quota 5 req/s)",
145+ "acces_legal": "API officielle publique avec clé gratuite (developer.ticketmaster.com) — conditions Ticketmaster Developer respectées",
146+ "couverture": "Grands spectacles, sports et concerts — partout au Québec",
147+ "cadence": "horaire (watcher PM2) dès que la clé est fournie",
148+ "statut": "prêt — clé requise",
149+ "note": "Nécessite TICKETMASTER_API_KEY dans .env (clé gratuite). Sans clé, le connecteur s'annonce « clé manquante » et ne retourne rien."
150+ },
151+ {
152+ "id": "bandsintown",
153+ "name": "Bandsintown — dates de concerts par artiste",
154+ "url": "https://rest.bandsintown.com/",
155+ "type": "agregateur",
156+ "extraction": "API publique /artists/{artiste}/events (app_id format widget « js_www.sorti-ka.com », testé accepté 2026-08 ; un app_id arbitraire simple est refusé) — pas de recherche géographique : on interroge les artistes déjà extraits de la base (evenko/lepointdevente/ticketmaster, événements à venir), plafond 100 artistes/sync avec rotation et cache data/bandsintown_cache.json (re-visite 3 j) ; on ne garde que les dates au Québec (venue.region=QC) : datetime local (date+heure), salle géolocalisée, lineup → artistes, free → gratuité, lien billets",
157+ "acces_legal": "API publique Bandsintown (app_id d'identification) — usage index + lien vers la fiche originale",
158+ "couverture": "Concerts au Québec des artistes en tournée connus de la base",
159+ "cadence": "horaire (watcher PM2, rotation d'artistes)",
160+ "statut": "actif"
161+ },
162+ {
163+ "id": "gatineau",
164+ "name": "Ville de Gatineau — Événements",
165+ "url": "https://www.gatineau.ca/",
166+ "type": "municipal",
167+ "extraction": "n/d",
168+ "acces_legal": "n/d",
169+ "couverture": "Gatineau (Outaouais)",
170+ "cadence": "n/d",
171+ "statut": "abandonnee",
172+ "note": "Aucun jeu de données « événements » sur Données Québec (portail Gatineau : géomatique seulement) et site 100 % JavaScript sans API découverte (vérifié 2026-08-18)"
173+ },
174+ {
175+ "id": "trois-rivieres",
176+ "name": "Ville de Trois-Rivières — Événements",
177+ "url": "https://www.v3r.net/",
178+ "type": "municipal",
179+ "extraction": "n/d",
180+ "acces_legal": "n/d",
181+ "couverture": "Trois-Rivières (Mauricie)",
182+ "cadence": "n/d",
183+ "statut": "abandonnee",
184+ "note": "API REST WordPress verrouillée (401 rest_not_logged_in) et aucun jeu « événements » sur Données Québec (vérifié 2026-08-18)"
185+ },
186+ {
187+ "id": "levis",
188+ "name": "Ville de Lévis — Calendrier",
189+ "url": "https://www.ville.levis.qc.ca/",
190+ "type": "municipal",
191+ "extraction": "n/d",
192+ "acces_legal": "n/d",
193+ "couverture": "Lévis (Chaudière-Appalaches)",
194+ "cadence": "n/d",
195+ "statut": "abandonnee",
196+ "note": "Site derrière un WAF (403 sur GET identifiés) et aucun jeu « événements » sur Données Québec (vérifié 2026-08-18)"
197+ },
198+ {
199+ "id": "terrebonne",
200+ "name": "Ville de Terrebonne — Calendrier",
201+ "url": "https://terrebonne.ca/calendrier/",
202+ "type": "municipal",
203+ "extraction": "n/d",
204+ "acces_legal": "n/d",
205+ "couverture": "Terrebonne (Lanaudière)",
206+ "cadence": "n/d",
207+ "statut": "abandonnee",
208+ "note": "Cloudflare bloque tout GET non navigateur (403) et aucun jeu « événements » sur Données Québec (vérifié 2026-08-18)"
127 } 209 }
128 ] 210 ]
129 } 211 }
\ No newline at end of file
modified frontend/index.html +11 −2
@@ -572,7 +572,8 @@ const CAT_LABELS = {
572 const SRC_LABELS = { 572 const SRC_LABELS = {
573 "sitq":"SIT Québec — Ministère du Tourisme","montreal":"Ville de Montréal", 573 "sitq":"SIT Québec — Ministère du Tourisme","montreal":"Ville de Montréal",
574 "laval":"Ville de Laval","lepointdevente":"Le point de vente", 574 "laval":"Ville de Laval","lepointdevente":"Le point de vente",
575− "evenko":"evenko","atuvu":"Atuvu.ca","lavitrine":"La Vitrine","sherbrooke":"Ville de Sherbrooke" 575+ "evenko":"evenko","atuvu":"Atuvu.ca","lavitrine":"La Vitrine","sherbrooke":"Ville de Sherbrooke",
576+ "brossard":"Ville de Brossard","ticketmaster":"Ticketmaster","bandsintown":"Bandsintown"
576 }; 577 };
577 const MONTHS = ["jan","fév","mar","avr","mai","juin","juil","août","sep","oct","nov","déc"]; 578 const MONTHS = ["jan","fév","mar","avr","mai","juin","juil","août","sep","oct","nov","déc"];
578 const MONTHS_FULL = ["janvier","février","mars","avril","mai","juin","juillet", 579 const MONTHS_FULL = ["janvier","février","mars","avril","mai","juin","juillet",
@@ -583,11 +584,17 @@ function fmtDate(iso, full=false){
583 const [y,m,d] = iso.split("-").map(Number); 584 const [y,m,d] = iso.split("-").map(Number);
584 return `${d} ${(full?MONTHS_FULL:MONTHS)[m-1]} ${y}`; 585 return `${d} ${(full?MONTHS_FULL:MONTHS)[m-1]} ${y}`;
585 } 586 }
587+function fmtTime(t){
588+ if(!t) return "";
589+ const [h,m] = t.split(":");
590+ return `${Number(h)} h ${m}`;
591+}
586 function dateBadge(ev, full=false){ 592 function dateBadge(ev, full=false){
587 if(!ev.start_date) return "Date à confirmer"; 593 if(!ev.start_date) return "Date à confirmer";
588 if(ev.end_date && ev.end_date !== ev.start_date) 594 if(ev.end_date && ev.end_date !== ev.start_date)
589 return `${fmtDate(ev.start_date, full)} → ${fmtDate(ev.end_date, full)}`; 595 return `${fmtDate(ev.start_date, full)} → ${fmtDate(ev.end_date, full)}`;
590− return fmtDate(ev.start_date, full); 596+ return fmtDate(ev.start_date, full)
597+ + (ev.start_time ? ` · ${fmtTime(ev.start_time)}` : "");
591 } 598 }
592 function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; } 599 function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
593 function priceBadge(ev){ 600 function priceBadge(ev){
@@ -677,6 +684,8 @@ async function showDetail(uid){
677 684
678 const facts = []; 685 const facts = [];
679 facts.push(["Quand", dateBadge(ev, true)]); 686 facts.push(["Quand", dateBadge(ev, true)]);
687+ if(ev.artists && ev.artists.length)
688+ facts.push(["À l'affiche", ev.artists.map(esc).join(", ")]);
680 if(ev.venue) facts.push(["Lieu", esc(ev.venue)]); 689 if(ev.venue) facts.push(["Lieu", esc(ev.venue)]);
681 if(ev.address) facts.push(["Adresse", esc(ev.address)]); 690 if(ev.address) facts.push(["Adresse", esc(ev.address)]);
682 if(ev.city) facts.push(["Ville", esc(ev.city)]); 691 if(ev.city) facts.push(["Ville", esc(ev.city)]);
modified sortika/connectors/atuvu.py +3 −1
@@ -19,7 +19,7 @@ import re
19 import time 19 import time
20 from datetime import date, timedelta 20 from datetime import date, timedelta
21 21
22−from ..normalize import clean_text, parse_date_fr 22+from ..normalize import clean_text, parse_date_fr, parse_time
23 from ..schema import Event 23 from ..schema import Event
24 from .base import BaseConnector 24 from .base import BaseConnector
25 25
@@ -81,6 +81,7 @@ class AtuvuConnector(BaseConnector):
81 continue 81 continue
82 rows.append({ 82 rows.append({
83 "external_id": f"{slug}-{day}", 83 "external_id": f"{slug}-{day}",
84+ "start_time": parse_time(date_txt), # « … - 20h00 » de la fiche
84 "url": url, "title": title, "image": image, 85 "url": url, "title": title, "image": image,
85 "raw_categories": cats, "venue": clean_text(venue), 86 "raw_categories": cats, "venue": clean_text(venue),
86 "city": clean_text(city or ""), "start_date": day, 87 "city": clean_text(city or ""), "start_date": day,
@@ -118,6 +119,7 @@ class AtuvuConnector(BaseConnector):
118 raw_categories=row.get("raw_categories") or [], 119 raw_categories=row.get("raw_categories") or [],
119 venue=row.get("venue", ""), city=row.get("city", ""), 120 venue=row.get("venue", ""), city=row.get("city", ""),
120 start_date=row.get("start_date"), 121 start_date=row.get("start_date"),
122+ start_time=row.get("start_time"),
121 end_date=row.get("end_date"), 123 end_date=row.get("end_date"),
122 price_min=row.get("price_min"), 124 price_min=row.get("price_min"),
123 price_label=row.get("price_label", ""), 125 price_label=row.get("price_label", ""),
added sortika/connectors/bandsintown.py +153 −0
@@ -0,0 +1,153 @@
1+# -----------------------------------------------------------------------------
2+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/bandsintown.py : connecteur Bandsintown (API publique par artiste)
5+# Source : rest.bandsintown.com/artists/{artiste}/events — API publique
6+# (app_id d'identification ; le format « js_<domaine> » des
7+# widgets officiels est accepté, testé 2026-08 — un app_id
8+# arbitraire simple est refusé « not authorized »).
9+# Approche : l'API ne permet pas de recherche géographique → on interroge
10+# les artistes déjà connus de la base (colonne `artists` extraite
11+# d'evenko / lepointdevente / ticketmaster, événements à venir)
12+# et on ne garde que leurs dates au QUÉBEC (venue.region = QC).
13+# Volume : plafond BIT_ARTIST_MAX artistes/sync (défaut 100), rotation par
14+# ancienneté via cache disque (data/bandsintown_cache.json) ; les
15+# événements des artistes déjà sondés sont ré-émis depuis le
16+# cache entre deux visites (re-visite après 3 jours).
17+# Champs : datetime local (date + heure), salle géolocalisée (lat/lng),
18+# lineup → artistes, `free` → is_free, offres/billets → url,
19+# image de l'artiste.
20+# -----------------------------------------------------------------------------
21+from __future__ import annotations
22+
23+import os
24+import time
25+import urllib.parse
26+
27+from ..schema import Event
28+from .base import BaseConnector
29+
30+API = "https://rest.bandsintown.com/artists/{artist}/events"
31+APP_ID = os.environ.get("BANDSINTOWN_APP_ID", "js_www.sorti-ka.com")
32+ARTIST_MAX = int(os.environ.get("BIT_ARTIST_MAX", "100")) # artistes/sync
33+REFRESH_AFTER = 3 * 86400 # re-visite d'un artiste (tournées ajoutées)
34+
35+_QC_REGIONS = {"qc", "quebec", "québec"}
36+
37+
38+def _known_artists() -> list[str]:
39+ """Artistes des événements à venir déjà en base (sources billetterie)."""
40+ import json
41+ from datetime import date
42+
43+ from .. import db
44+ today = date.today().isoformat()
45+ con = db.connect()
46+ rows = con.execute(
47+ "SELECT artists FROM events WHERE active=1 AND artists IS NOT NULL "
48+ "AND artists != '[]' AND (end_date >= ? OR start_date >= ?) "
49+ "AND source IN ('evenko','lepointdevente','ticketmaster')",
50+ (today, today)).fetchall()
51+ con.close()
52+ seen, out = set(), []
53+ for r in rows:
54+ for a in json.loads(r["artists"] or "[]"):
55+ k = a.strip().lower()
56+ if k and k not in seen:
57+ seen.add(k)
58+ out.append(a.strip())
59+ return out
60+
61+
62+class BandsintownConnector(BaseConnector):
63+ source_id = "bandsintown"
64+ request_delay = 0.5
65+
66+ def _artist_events(self, artist: str) -> list[dict]:
67+ """Dates québécoises à venir d'un artiste (champs utiles seulement)."""
68+ url = API.format(artist=urllib.parse.quote(artist, safe=""))
69+ data = self.get_json(url, params={"app_id": APP_ID, "date": "upcoming"})
70+ if not isinstance(data, list): # {"Message": …} = refus/erreur
71+ raise RuntimeError(f"réponse inattendue pour {artist!r} : {data}")
72+ rows = []
73+ for e in data:
74+ venue = e.get("venue") or {}
75+ region = (venue.get("region") or "").strip().lower()
76+ country = (venue.get("country") or "").strip().lower()
77+ if country != "canada" or region not in _QC_REGIONS:
78+ continue
79+ dt = e.get("datetime") or "" # heure locale de la salle
80+ art = e.get("artist") or {}
81+ rows.append({
82+ "id": str(e.get("id") or ""),
83+ "url": e.get("url") or "",
84+ "title": e.get("title") or "",
85+ "description": e.get("description") or "",
86+ "artist_name": art.get("name") or artist,
87+ "lineup": [a for a in e.get("lineup") or [] if a],
88+ "venue": venue.get("name") or "",
89+ "address": venue.get("street_address") or "",
90+ "city": venue.get("city") or "",
91+ "postal_code": venue.get("postal_code") or "",
92+ "lat": venue.get("latitude"),
93+ "lng": venue.get("longitude"),
94+ "date": dt[:10] or None,
95+ "time": dt[11:16] or None,
96+ "free": e.get("free"),
97+ "image": art.get("image_url") or "",
98+ })
99+ return rows
100+
101+ def fetch(self) -> list[Event]:
102+ artists = _known_artists()
103+ cache = self.load_cache()
104+ # rotation : jamais sondés d'abord, puis les plus anciens ; on garde en
105+ # cache les artistes toujours présents en base (sinon purge douce)
106+ keyed = {a.lower(): a for a in artists}
107+ cache = {k: v for k, v in cache.items() if k in keyed}
108+ now = time.time()
109+ todo = sorted(
110+ (k for k in keyed
111+ if now - (cache.get(k) or {}).get("fetched_at", 0) > REFRESH_AFTER),
112+ key=lambda k: (cache.get(k) or {}).get("fetched_at", 0))[:ARTIST_MAX]
113+
114+ for k in todo:
115+ try:
116+ cache[k] = {"fetched_at": now,
117+ "events": self._artist_events(keyed[k])}
118+ except Exception as exc: # un artiste raté ne bloque pas la source
119+ print(f"[sorti-ka] bandsintown : artiste ignoré {keyed[k]!r} : {exc}")
120+ cache.setdefault(k, {"fetched_at": 0, "events": []})
121+ cache[k]["fetched_at"] = now # ne pas boucler dessus
122+ self.save_cache(cache)
123+
124+ events, seen = [], set()
125+ for entry in cache.values():
126+ for row in entry.get("events") or []:
127+ if not row.get("id") or row["id"] in seen:
128+ continue
129+ seen.add(row["id"])
130+ title = row["title"] or row["artist_name"]
131+ events.append(Event(
132+ source=self.source_id,
133+ external_id=row["id"],
134+ url=row["url"] or "https://www.bandsintown.com",
135+ title=title,
136+ description=row.get("description") or "",
137+ raw_categories=["Concert"],
138+ venue=row.get("venue", ""),
139+ address=row.get("address", ""),
140+ city=row.get("city", ""),
141+ postal_code=row.get("postal_code", ""),
142+ lat=row.get("lat"), lng=row.get("lng"),
143+ start_date=row.get("date"),
144+ start_time=(None if row.get("time") == "00:00"
145+ else row.get("time")),
146+ end_date=row.get("date"),
147+ artists=row.get("lineup") or [row["artist_name"]],
148+ # `free` vaut false par défaut chez Bandsintown : seul un
149+ # true explicite est significatif (on n'invente rien)
150+ is_free=(True if row.get("free") is True else None),
151+ image=row.get("image", ""),
152+ ))
153+ return events
added sortika/connectors/brossard.py +170 −0
@@ -0,0 +1,170 @@
1+# -----------------------------------------------------------------------------
2+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/brossard.py : connecteur Ville de Brossard — calendrier des
5+# événements municipaux (brossard.ca, Montérégie)
6+# Source : API REST WordPress publique — type de contenu `city-event`
7+# (~400 événements : bibliothèque, conférences, ateliers,
8+# spectacles familiaux). Fraîcheur vérifiée 2026-08 (fiches
9+# publiées en août 2026 pour des dates jusqu'en décembre 2026).
10+# Extraction: 1) liste JSON /wp-json/wp/v2/city-event (paginée, champs
11+# title/link/content/modified) ; 2) dates + heures par fiche :
12+# bannière « Cet événement aura lieu le 6 décembre 2026, de
13+# 15 h 00 à 16 h 00 » (fiches ville) ou JSON-LD Event
14+# (fiches biblio.brossard.ca, redirection suivie) — cache disque
15+# incrémental data/brossard_cache.json, plafond
16+# BROSSARD_DETAIL_MAX fiches/sync, re-visite 7 jours.
17+# Accès : API REST WordPress publique (aucune authentification), GET
18+# throttlés, identification honnête. robots.txt : Allow.
19+# Prix : non publié par la source → jamais inventé.
20+# -----------------------------------------------------------------------------
21+from __future__ import annotations
22+
23+import json
24+import os
25+import re
26+import time
27+
28+from ..normalize import clean_text, parse_date_fr, parse_time
29+from ..schema import Event
30+from .base import BaseConnector
31+
32+API = "https://brossard.ca/wp-json/wp/v2/city-event"
33+PAGE_SIZE = 100
34+DETAIL_MAX = int(os.environ.get("BROSSARD_DETAIL_MAX", "80")) # fiches/sync
35+REFRESH_AFTER = 7 * 86400
36+
37+_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',
38+ re.S)
39+_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')
40+# « Cet événement aura lieu le 6 décembre 2026, de 15 h 00 à 16 h 00 »
41+# « … aura lieu du 3 juillet 2026 au 5 juillet 2026 »
42+_BANNER_RE = re.compile(
43+ r"aura lieu\s+(?:le\s+(?P<jour>\d{1,2}(?:er)?\s+\S+\s+\d{4})"
44+ r"(?:,?\s*(?:de|à)\s+(?P<heure>\d{1,2}\s*h\s*(?:\d{2})?))?"
45+ r"|du\s+(?P<du>\d{1,2}(?:er)?\s+\S+\s+\d{4})\s+au\s+"
46+ r"(?P<au>\d{1,2}(?:er)?\s+\S+\s+\d{4}))", re.I)
47+
48+
49+class BrossardConnector(BaseConnector):
50+ source_id = "brossard"
51+ request_delay = 0.8
52+
53+ # -- liste REST ---------------------------------------------------------
54+ def _list(self) -> list[dict]:
55+ out, page = [], 1
56+ while True:
57+ try:
58+ batch = self.get_json(API, params={
59+ "per_page": PAGE_SIZE, "page": page, "orderby": "modified",
60+ "order": "desc",
61+ "_fields": "id,link,title,content,modified"})
62+ except Exception:
63+ if page > 1: # « rest_post_invalid_page_number » = fin
64+ break
65+ raise
66+ if not isinstance(batch, list) or not batch:
67+ break
68+ out += batch
69+ if len(batch) < PAGE_SIZE:
70+ break
71+ page += 1
72+ return out
73+
74+ # -- fiche : dates/heure/lieu (bannière ville OU JSON-LD biblio) ----------
75+ def _parse_fiche(self, html: str) -> dict:
76+ info: dict = {}
77+ for block in _LD_RE.findall(html):
78+ if '"Event"' not in block:
79+ continue
80+ try:
81+ ld = json.loads(block)
82+ except ValueError:
83+ continue
84+ if isinstance(ld, list):
85+ ld = ld[0] if ld else {}
86+ if ld.get("@type") != "Event":
87+ continue
88+ start, end = str(ld.get("startDate") or ""), str(ld.get("endDate") or "")
89+ info["start_date"] = start[:10] or None
90+ info["end_date"] = end[:10] or None
91+ info["time"] = parse_time(start)
92+ loc = ld.get("location") or []
93+ loc = loc[0] if isinstance(loc, list) and loc else loc
94+ if isinstance(loc, dict):
95+ info["venue"] = clean_text(str(loc.get("name") or ""))
96+ addr = loc.get("address") or {}
97+ if isinstance(addr, dict):
98+ info["address"] = clean_text(str(addr.get("streetAddress") or ""))
99+ info["postal_code"] = clean_text(str(addr.get("postalCode") or ""))
100+ if ld.get("image"):
101+ info["image"] = str(ld["image"])
102+ return info
103+ m = _BANNER_RE.search(clean_text(html[:200000]))
104+ if m:
105+ if m.group("jour"):
106+ info["start_date"] = parse_date_fr(m.group("jour"))
107+ info["end_date"] = info["start_date"]
108+ info["time"] = parse_time(m.group("heure") or "")
109+ else:
110+ info["start_date"] = parse_date_fr(m.group("du"))
111+ info["end_date"] = parse_date_fr(m.group("au"))
112+ img = _OG_IMG_RE.search(html)
113+ if img:
114+ info.setdefault("image", img.group(1))
115+ return info
116+
117+ def _enrich(self, records: list[dict]) -> dict:
118+ """Cache url → {fetched_at, start_date, …}, incrémental et plafonné.
119+ Fiches jamais vues d'abord (les plus récemment modifiées en premier —
120+ la liste REST est déjà triée), puis re-visite roulante après 7 jours."""
121+ cache = self.load_cache()
122+ current = {r["link"] for r in records}
123+ cache = {u: c for u, c in cache.items() if u in current}
124+ now = time.time()
125+ candidates = [r["link"] for r in records
126+ if r["link"] not in cache
127+ or now - cache[r["link"]].get("fetched_at", 0) > REFRESH_AFTER]
128+ for url in candidates[:DETAIL_MAX]:
129+ try:
130+ info = self._parse_fiche(self.get(url).text)
131+ except Exception: # fiche cassée/404 : on ne bloque pas la source
132+ print(f"[sorti-ka] brossard : fiche ignorée {url}")
133+ info = {}
134+ info["fetched_at"] = now
135+ cache[url] = info
136+ self.save_cache(cache)
137+ return cache
138+
139+ def fetch(self) -> list[Event]:
140+ records = [r for r in self._list()
141+ if r.get("link") and (r.get("title") or {}).get("rendered")]
142+ cache = self._enrich(records)
143+ events: list[Event] = []
144+ for rec in records:
145+ info = cache.get(rec["link"]) or {}
146+ title = clean_text(rec["title"]["rendered"])
147+ # repli : beaucoup de titres portent la date « (27 novembre 2026) »
148+ start = info.get("start_date") or parse_date_fr(title)
149+ if not start:
150+ continue # fiche pas encore visitée et titre sans date
151+ events.append(Event(
152+ source=self.source_id,
153+ external_id=str(rec["id"]),
154+ url=rec["link"],
155+ title=title,
156+ description=clean_text(
157+ (rec.get("content") or {}).get("rendered") or "")[:600],
158+ raw_categories=[title],
159+ venue=info.get("venue", ""),
160+ address=info.get("address", ""),
161+ postal_code=info.get("postal_code", ""),
162+ city="Brossard",
163+ region="Montérégie",
164+ start_date=start,
165+ start_time=info.get("time"),
166+ end_date=info.get("end_date") or start,
167+ organizer="Ville de Brossard",
168+ image=info.get("image", ""),
169+ ))
170+ return events
modified sortika/connectors/evenko.py +18 −0
@@ -46,6 +46,16 @@ def _unix_to_iso(ts) -> str | None:
46 return None 46 return None
47 47
48 48
49+def _unix_to_local_time(ts) -> str | None:
50+ """show_time (epoch) → heure locale du spectacle ("19:30"), Québec."""
51+ from zoneinfo import ZoneInfo
52+ try:
53+ t = datetime.fromtimestamp(int(ts), tz=ZoneInfo("America/Montreal"))
54+ return None if t.strftime("%H:%M") == "00:00" else t.strftime("%H:%M")
55+ except (TypeError, ValueError, OSError):
56+ return None
57+
58+
49 def _ticket_url(hit: dict) -> str: 59 def _ticket_url(hit: dict) -> str:
50 """URL billets officielle (fr_CA) depuis additional_information.""" 60 """URL billets officielle (fr_CA) depuis additional_information."""
51 reg = (hit.get("additional_information") or {}).get("regular_ticket") or {} 61 reg = (hit.get("additional_information") or {}).get("regular_ticket") or {}
@@ -96,6 +106,12 @@ class EvenkoConnector(BaseConnector):
96 if thumb.startswith("//"): 106 if thumb.startswith("//"):
97 thumb = "https:" + thumb 107 thumb = "https:" + thumb
98 day = _unix_to_iso(hit.get("show_date")) or _unix_to_iso(hit.get("show_time")) 108 day = _unix_to_iso(hit.get("show_date")) or _unix_to_iso(hit.get("show_time"))
109+ # heure précise : show_time (epoch) sauf si la source demande
110+ # explicitement de la cacher (hide_show_time)
111+ start_time = (None if hit.get("hide_show_time")
112+ else _unix_to_local_time(hit.get("show_time")))
113+ # artistes : champ explicite `headliners` de la source
114+ artists = [str(a) for a in hit.get("headliners") or [] if a]
99 # gratuité : champ `free` (aujourd'hui toujours null côté 115 # gratuité : champ `free` (aujourd'hui toujours null côté
100 # source, gardé au cas où il serait re-rempli) + étiquettes 116 # source, gardé au cas où il serait re-rempli) + étiquettes
101 # explicites « gratuit » (event_tag / promotional_tag). 117 # explicites « gratuit » (event_tag / promotional_tag).
@@ -116,7 +132,9 @@ class EvenkoConnector(BaseConnector):
116 venue=venue.get("name") or "", 132 venue=venue.get("name") or "",
117 city=venue.get("city") or "", 133 city=venue.get("city") or "",
118 start_date=day, 134 start_date=day,
135+ start_time=start_time,
119 end_date=day, 136 end_date=day,
137+ artists=artists,
120 is_free=is_free, 138 is_free=is_free,
121 organizer="evenko", 139 organizer="evenko",
122 image=thumb, 140 image=thumb,
modified sortika/connectors/laval.py +9 −2
@@ -10,6 +10,7 @@
10 # ----------------------------------------------------------------------------- 10 # -----------------------------------------------------------------------------
11 from __future__ import annotations 11 from __future__ import annotations
12 12
13+from ..normalize import utc_to_local
13 from ..schema import Event 14 from ..schema import Event
14 from .base import BaseConnector 15 from .base import BaseConnector
15 16
@@ -38,6 +39,11 @@ class LavalConnector(BaseConnector):
38 # → l'id externe inclut la date pour ne pas écraser les occurrences 39 # → l'id externe inclut la date pour ne pas écraser les occurrences
39 slug = page.rsplit("/", 1)[-1].removesuffix(".aspx") 40 slug = page.rsplit("/", 1)[-1].removesuffix(".aspx")
40 day = (rec.get("EventDate") or "")[:10] 41 day = (rec.get("EventDate") or "")[:10]
42+ # EventDate/EndDate sont publiés en UTC (vérifié 2026-08 : les
43+ # activités d'été tombent à 18h30 locales une fois converties) →
44+ # date + heure locales Québec ; minuit local = « toute la journée ».
45+ start_local, time_local = utc_to_local(rec.get("EventDate"))
46+ end_local, _ = utc_to_local(rec.get("EndDate"))
41 events.append(Event( 47 events.append(Event(
42 source=self.source_id, 48 source=self.source_id,
43 external_id=f"{slug}-{day}" if day else slug, 49 external_id=f"{slug}-{day}" if day else slug,
@@ -51,8 +57,9 @@ class LavalConnector(BaseConnector):
51 venue=", ".join(_labels(rec.get("Locations"))), 57 venue=", ".join(_labels(rec.get("Locations"))),
52 city="Laval", 58 city="Laval",
53 region="Laval", 59 region="Laval",
54− start_date=rec.get("EventDate"), 60+ start_date=start_local or rec.get("EventDate"),
55− end_date=rec.get("EndDate"), 61+ start_time=time_local,
62+ end_date=end_local or rec.get("EndDate"),
56 organizer="Ville de Laval", 63 organizer="Ville de Laval",
57 image=(SITE + image) if image.startswith("/") else image, 64 image=(SITE + image) if image.startswith("/") else image,
58 )) 65 ))
modified sortika/connectors/lepointdevente.py +7 −1
@@ -25,7 +25,7 @@ import os
25 import re 25 import re
26 import time 26 import time
27 27
28−from ..normalize import clean_text, parse_date_fr 28+from ..normalize import artists_from_title, clean_text, parse_date_fr, parse_time
29 from ..schema import Event 29 from ..schema import Event
30 from .base import BaseConnector 30 from .base import BaseConnector
31 31
@@ -84,6 +84,10 @@ class LePointDeVenteConnector(BaseConnector):
84 venue=venue, 84 venue=venue,
85 city=city, 85 city=city,
86 start_date=parse_date_fr(date_txt), 86 start_date=parse_date_fr(date_txt),
87+ # la carte de liste donne déjà l'heure : « 17 août 2026, 20h00 »
88+ start_time=parse_time(date_txt),
89+ # patrons sûrs seulement (« Artiste - Salle », « X en concert »)
90+ artists=artists_from_title(title, venue),
87 image=image or "", 91 image=image or "",
88 )) 92 ))
89 return events 93 return events
@@ -165,6 +169,8 @@ class LePointDeVenteConnector(BaseConnector):
165 info = cache.get(ev.url) or {} 169 info = cache.get(ev.url) or {}
166 if info.get("description") and not ev.description: 170 if info.get("description") and not ev.description:
167 ev.description = info["description"] 171 ev.description = info["description"]
172+ if info.get("time"): # heure précise de la fiche ("19h30")
173+ ev.start_time = info["time"]
168 if "is_free" in info: # tarifs réels de la billetterie 174 if "is_free" in info: # tarifs réels de la billetterie
169 ev.is_free = info["is_free"] 175 ev.is_free = info["is_free"]
170 ev.price_min = info.get("price_min") 176 ev.price_min = info.get("price_min")
modified sortika/connectors/montreal.py +33 −13
@@ -26,6 +26,9 @@ PAGE = 1000
26 IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300")) 26 IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300"))
27 27
28 _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') 28 _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')
29+# heure locale de la fiche montreal.ca (JSON-LD Event, offset Québec inclus) :
30+# "startDate":"2026-10-14T19:30:00-04:00" → "19:30"
31+_LD_TIME_RE = re.compile(r'"startDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):')
29 32
30 # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom 33 # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom
31 # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières 34 # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières
@@ -45,33 +48,50 @@ class MontrealConnector(BaseConnector):
45 source_id = "montreal" 48 source_id = "montreal"
46 request_delay = 0.4 49 request_delay = 0.4
47 50
48− def _enrich_images(self, events: list[Event]) -> None: 51+ def _enrich_fiches(self, events: list[Event]) -> None:
49− """og:image des fiches montreal.ca — incrémental, cache disque, 52+ """og:image + heure précise des fiches montreal.ca — incrémental,
50− pool de threads (6). Le cache mémorise aussi les fiches SANS image 53+ cache disque, pool de threads (6). Le jeu de données ouvert ne publie
51− ("") pour ne pas les re-visiter, et seuls les événements à venir 54+ pas l'heure, mais chaque fiche montreal.ca l'expose en JSON-LD
52− sont visités (pas les archives). 55+ ("startDate":"…T19:30:00-04:00"). Le cache mémorise aussi les fiches
56+ sans image/heure pour ne pas les re-visiter ; seuls les événements à
57+ venir sont visités (pas les archives). Les entrées héritées (chaîne
58+ image seule, avant la vague 2) sont re-visitées en priorité basse pour
59+ acquérir l'heure, dans le même plafond MTL_IMG_MAX.
53 """ 60 """
54 from datetime import date 61 from datetime import date
55 today = date.today().isoformat() 62 today = date.today().isoformat()
56 cache = self.load_cache() 63 cache = self.load_cache()
57 to_fetch = [] 64 to_fetch = []
58 for ev in events: 65 for ev in events:
59− if ev.url in cache: 66+ entry = cache.get(ev.url)
67+ if isinstance(entry, dict): # déjà au format vague 2
60 continue 68 continue
61 last_day = (ev.end_date or ev.start_date or "")[:10] 69 last_day = (ev.end_date or ev.start_date or "")[:10]
62 if last_day >= today and len(to_fetch) < IMG_MAX: 70 if last_day >= today and len(to_fetch) < IMG_MAX:
63− to_fetch.append(ev.url) 71+ # None = jamais visité (prioritaire) ; str = hérité (backfill)
72+ to_fetch.append((entry is not None, ev.url))
73+ to_fetch = [u for _, u in sorted(to_fetch, key=lambda t: t[0])][:IMG_MAX]
64 74
65 def worker(url, session): 75 def worker(url, session):
66− m = _OG_IMG_RE.search(session.get(url, timeout=15).text) 76+ html = session.get(url, timeout=15).text
67− return m.group(1) if m else "" 77+ img = _OG_IMG_RE.search(html)
78+ tm = _LD_TIME_RE.search(html)
79+ return {"image": img.group(1) if img else "",
80+ "time": tm.group(1) if tm else None}
68 81
69− for url, img in self.fetch_many(to_fetch, worker, max_workers=6).items(): 82+ for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items():
70− cache[url] = img or "" 83+ cache[url] = info or {"image": "", "time": None}
71 self.save_cache(cache) 84 self.save_cache(cache)
72 for ev in events: 85 for ev in events:
86+ entry = cache.get(ev.url)
87+ if isinstance(entry, str): # cache hérité : image seule
88+ entry = {"image": entry, "time": None}
89+ if not isinstance(entry, dict):
90+ continue
73 if not ev.image: 91 if not ev.image:
74− ev.image = cache.get(ev.url) or "" 92+ ev.image = entry.get("image") or ""
93+ if entry.get("time") and entry["time"] != "00:00":
94+ ev.start_time = entry["time"]
75 95
76 def fetch(self) -> list[Event]: 96 def fetch(self) -> list[Event]:
77 events: list[Event] = [] 97 events: list[Event] = []
@@ -126,5 +146,5 @@ class MontrealConnector(BaseConnector):
126 offset += PAGE 146 offset += PAGE
127 if offset >= int(result.get("total", 0)): 147 if offset >= int(result.get("total", 0)):
128 break 148 break
129− self._enrich_images(events) 149+ self._enrich_fiches(events)
130 return events 150 return events
modified sortika/connectors/sherbrooke.py +6 −2
@@ -15,6 +15,7 @@ from __future__ import annotations
15 15
16 import json 16 import json
17 17
18+from ..normalize import utc_to_local
18 from ..schema import Event 19 from ..schema import Event
19 from .base import BaseConnector 20 from .base import BaseConnector
20 21
@@ -63,8 +64,10 @@ class SherbrookeConnector(BaseConnector):
63 periods = rec.get("periods") or [] 64 periods = rec.get("periods") or []
64 if not title or not full_url or not periods: 65 if not title or not full_url or not periods:
65 continue 66 continue
66− start = (periods[0].get("dateDebut") or "")[:10] 67+ # les périodes sont publiées en UTC (« …T04:00:00Z » = minuit
67− end = (periods[-1].get("dateFin") or "")[:10] 68+ # local, placeholder « toute la journée ») → date + heure locales
69+ start, start_time = utc_to_local(periods[0].get("dateDebut"))
70+ end, _ = utc_to_local(periods[-1].get("dateFin"))
68 71
69 cats, audience = [], [] 72 cats, audience = [], []
70 c = rec.get("categories") or {} 73 c = rec.get("categories") or {}
@@ -95,6 +98,7 @@ class SherbrookeConnector(BaseConnector):
95 city="Sherbrooke", 98 city="Sherbrooke",
96 region="Estrie", 99 region="Estrie",
97 start_date=start or None, 100 start_date=start or None,
101+ start_time=start_time,
98 end_date=end or start or None, 102 end_date=end or start or None,
99 is_free=(not payant) if isinstance(payant, bool) else None, 103 is_free=(not payant) if isinstance(payant, bool) else None,
100 organizer="Ville de Sherbrooke", 104 organizer="Ville de Sherbrooke",
added sortika/connectors/ticketmaster.py +122 −0
@@ -0,0 +1,122 @@
1+# -----------------------------------------------------------------------------
2+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/ticketmaster.py : connecteur Ticketmaster Discovery API (Québec)
5+# Source : app.ticketmaster.com/discovery/v2/events — API officielle
6+# publique (clé gratuite sur developer.ticketmaster.com).
7+# Extraction: GET JSON paginé (countryCode=CA & stateCode=QC, size=200) —
8+# nom, dates + heure locale, salle géolocalisée (venues lat/lng),
9+# fourchette de prix (priceRanges min/max), images 16:9,
10+# classifications (segment/genre) → catégories, attractions →
11+# artistes, URL de billetterie officielle.
12+# Accès : API officielle avec clé — quota 5 req/s & 5 000 req/jour
13+# (throttle 0,25 s ; ~10 requêtes/sync, pagination plafonnée par
14+# l'API à 1 000 éléments par requête profonde).
15+# Clé : TICKETMASTER_API_KEY dans .env (voir .env.example). Sans clé,
16+# le connecteur s'annonce « clé manquante » et ne retourne rien —
17+# statut « prêt — clé requise » dans data/sources.json.
18+# -----------------------------------------------------------------------------
19+from __future__ import annotations
20+
21+import os
22+
23+from ..schema import Event
24+from .base import BaseConnector
25+
26+API = "https://app.ticketmaster.com/discovery/v2/events.json"
27+PAGE_SIZE = 200
28+DEEP_PAGE_LIMIT = 1000 # l'API refuse page*size >= 1000 (pagination profonde)
29+
30+
31+def _best_image(images: list[dict]) -> str:
32+ """Plus grande image 16:9 (sinon la plus large disponible)."""
33+ if not images:
34+ return ""
35+ ranked = sorted(images, key=lambda i: ((i.get("ratio") == "16_9"),
36+ i.get("width") or 0), reverse=True)
37+ return ranked[0].get("url") or ""
38+
39+
40+class TicketmasterConnector(BaseConnector):
41+ source_id = "ticketmaster"
42+ request_delay = 0.25 # max 5 req/s (quota officiel)
43+
44+ def _page(self, key: str, page: int) -> dict:
45+ return self.get_json(API, params={
46+ "apikey": key, "countryCode": "CA", "stateCode": "QC",
47+ "locale": "fr-ca,en", "size": PAGE_SIZE, "page": page,
48+ "sort": "date,asc"})
49+
50+ def _hit_to_event(self, hit: dict) -> Event | None:
51+ ext_id = hit.get("id") or ""
52+ title = hit.get("name") or ""
53+ if not ext_id or not title:
54+ return None
55+ emb = hit.get("_embedded") or {}
56+ venues = emb.get("venues") or [{}]
57+ v = venues[0]
58+ loc = v.get("location") or {}
59+ dates = (hit.get("dates") or {}).get("start") or {}
60+ end = ((hit.get("dates") or {}).get("end") or {}).get("localDate")
61+ # classifications → libellés source (segment + genre + sous-genre)
62+ cats: list[str] = []
63+ for c in hit.get("classifications") or []:
64+ for k in ("segment", "genre", "subGenre"):
65+ name = (c.get(k) or {}).get("name") or ""
66+ if name and name.lower() != "undefined" and name not in cats:
67+ cats.append(name)
68+ # fourchette de prix officielle (si publiée par la billetterie)
69+ price_min, price_label = None, ""
70+ ranges = [r for r in hit.get("priceRanges") or []
71+ if r.get("min") is not None]
72+ if ranges:
73+ lo = min(float(r["min"]) for r in ranges)
74+ hi = max(float(r.get("max") or r["min"]) for r in ranges)
75+ price_min = lo if lo > 0 else None
76+ price_label = (f"{lo:g} $ à {hi:g} $ (Ticketmaster)" if hi > lo
77+ else f"{lo:g} $ (Ticketmaster)")
78+ artists = [a.get("name") for a in emb.get("attractions") or []
79+ if a.get("name")]
80+ return Event(
81+ source=self.source_id,
82+ external_id=ext_id,
83+ url=hit.get("url") or "",
84+ title=title,
85+ description=hit.get("info") or hit.get("pleaseNote") or "",
86+ raw_categories=cats,
87+ venue=v.get("name") or "",
88+ address=((v.get("address") or {}).get("line1") or ""),
89+ city=((v.get("city") or {}).get("name") or ""),
90+ postal_code=v.get("postalCode") or "",
91+ lat=loc.get("latitude"),
92+ lng=loc.get("longitude"),
93+ start_date=dates.get("localDate"),
94+ start_time=(None if dates.get("timeTBA") or dates.get("noSpecificTime")
95+ else dates.get("localTime")),
96+ end_date=end or dates.get("localDate"),
97+ artists=artists,
98+ price_min=price_min,
99+ price_label=price_label,
100+ is_free=(False if price_min else None),
101+ organizer=((hit.get("promoter") or {}).get("name") or ""),
102+ image=_best_image(hit.get("images") or []),
103+ )
104+
105+ def fetch(self) -> list[Event]:
106+ key = os.environ.get("TICKETMASTER_API_KEY", "").strip()
107+ if not key:
108+ print("[sorti-ka] ticketmaster : clé manquante "
109+ "(TICKETMASTER_API_KEY dans .env — gratuite sur "
110+ "developer.ticketmaster.com) — connecteur prêt, sync sautée")
111+ return []
112+ events: list[Event] = []
113+ page, total_pages = 0, 1
114+ while page < total_pages and (page + 1) * PAGE_SIZE <= DEEP_PAGE_LIMIT:
115+ data = self._page(key, page)
116+ total_pages = int((data.get("page") or {}).get("totalPages") or 0)
117+ for hit in (data.get("_embedded") or {}).get("events") or []:
118+ ev = self._hit_to_event(hit)
119+ if ev is not None:
120+ events.append(ev)
121+ page += 1
122+ return events
modified sortika/db.py +25 −4
@@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS events (
47 lat REAL, 47 lat REAL,
48 lng REAL, 48 lng REAL,
49 start_date TEXT, 49 start_date TEXT,
50+ start_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
50 end_date TEXT, 51 end_date TEXT,
52+ artists TEXT, -- JSON (artistes à l'affiche)
51 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu 53 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu
52 price_min REAL, 54 price_min REAL,
53 price_label TEXT, 55 price_label TEXT,
@@ -105,9 +107,23 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
105 except sqlite3.OperationalError: 107 except sqlite3.OperationalError:
106 pass 108 pass
107 con.executescript(_SCHEMA) 109 con.executescript(_SCHEMA)
110+ _migrate(con)
108 return con 111 return con
109 112
110 113
114+# Colonnes additives (vague 2) : les bases créées avant leur ajout sont
115+# migrées à la connexion — jamais de changement destructif.
116+_ADDITIVE_COLUMNS = {"start_time": "TEXT", "artists": "TEXT"}
117+
118+
119+def _migrate(con: sqlite3.Connection) -> None:
120+ existing = {r["name"] for r in con.execute("PRAGMA table_info(events)")}
121+ for col, typ in _ADDITIVE_COLUMNS.items():
122+ if col not in existing:
123+ con.execute(f"ALTER TABLE events ADD COLUMN {col} {typ}")
124+ con.commit()
125+
126+
111 def _event_row(ev: Event, now: float) -> dict: 127 def _event_row(ev: Event, now: float) -> dict:
112 return { 128 return {
113 "uid": ev.uid, "source": ev.source, "external_id": ev.external_id, 129 "uid": ev.uid, "source": ev.source, "external_id": ev.external_id,
@@ -117,7 +133,9 @@ def _event_row(ev: Event, now: float) -> dict:
117 "audience": ev.audience, "venue": ev.venue, "address": ev.address, 133 "audience": ev.audience, "venue": ev.venue, "address": ev.address,
118 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region, 134 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region,
119 "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng, 135 "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng,
120− "start_date": ev.start_date, "end_date": ev.end_date, 136+ "start_date": ev.start_date, "start_time": ev.start_time,
137+ "end_date": ev.end_date,
138+ "artists": json.dumps(ev.artists, ensure_ascii=False),
121 "is_free": (None if ev.is_free is None else int(ev.is_free)), 139 "is_free": (None if ev.is_free is None else int(ev.is_free)),
122 "price_min": ev.price_min, "price_label": ev.price_label, 140 "price_min": ev.price_min, "price_label": ev.price_label,
123 "organizer": ev.organizer, "website": ev.website, "image": ev.image, 141 "organizer": ev.organizer, "website": ev.website, "image": ev.image,
@@ -154,13 +172,15 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
154 """INSERT INTO events (uid, source, external_id, url, title, 172 """INSERT INTO events (uid, source, external_id, url, title,
155 description, categories, raw_categories, audience, venue, 173 description, categories, raw_categories, audience, venue,
156 address, city, region, tourist_region, postal_code, lat, lng, 174 address, city, region, tourist_region, postal_code, lat, lng,
157− start_date, end_date, is_free, price_min, price_label, 175+ start_date, start_time, end_date, artists, is_free,
176+ price_min, price_label,
158 organizer, website, image, dedup_key, content_hash, 177 organizer, website, image, dedup_key, content_hash,
159 first_seen, last_seen, updated_at, miss_count, active) 178 first_seen, last_seen, updated_at, miss_count, active)
160 VALUES (:uid,:source,:external_id,:url,:title,:description, 179 VALUES (:uid,:source,:external_id,:url,:title,:description,
161 :categories,:raw_categories,:audience,:venue,:address,:city, 180 :categories,:raw_categories,:audience,:venue,:address,:city,
162 :region,:tourist_region,:postal_code,:lat,:lng,:start_date, 181 :region,:tourist_region,:postal_code,:lat,:lng,:start_date,
163− :end_date,:is_free,:price_min,:price_label,:organizer, 182+ :start_time,:end_date,:artists,:is_free,:price_min,
183+ :price_label,:organizer,
164 :website,:image,:dedup_key,:content_hash, 184 :website,:image,:dedup_key,:content_hash,
165 :now,:now,:now,0,1)""", row) 185 :now,:now,:now,0,1)""", row)
166 stats["added"] += 1 186 stats["added"] += 1
@@ -172,7 +192,8 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
172 venue=:venue, address=:address, city=:city, region=:region, 192 venue=:venue, address=:address, city=:city, region=:region,
173 tourist_region=:tourist_region, postal_code=:postal_code, 193 tourist_region=:tourist_region, postal_code=:postal_code,
174 lat=:lat, lng=:lng, start_date=:start_date, 194 lat=:lat, lng=:lng, start_date=:start_date,
175− end_date=:end_date, is_free=:is_free, price_min=:price_min, 195+ start_time=:start_time, end_date=:end_date,
196+ artists=:artists, is_free=:is_free, price_min=:price_min,
176 price_label=:price_label, organizer=:organizer, 197 price_label=:price_label, organizer=:organizer,
177 website=:website, image=:image, dedup_key=:dedup_key, 198 website=:website, image=:image, dedup_key=:dedup_key,
178 content_hash=:content_hash, last_seen=:now, updated_at=:now, 199 content_hash=:content_hash, last_seen=:now, updated_at=:now,
modified sortika/normalize.py +117 −0
@@ -91,6 +91,123 @@ def parse_date_fr(raw: str | None) -> str | None:
91 return f"{y:04d}-{mo:02d}-{d:02d}" 91 return f"{y:04d}-{mo:02d}-{d:02d}"
92 92
93 93
94+# ---------------------------------------------------------------------------
95+# Heures → "HH:MM" (heure locale du Québec)
96+# ---------------------------------------------------------------------------
97+
98+_TIME_ISO_RE = re.compile(r"[T\s](\d{2}):(\d{2})")
99+_TIME_FR_RE = re.compile(r"(?<!\d)(\d{1,2})\s*[h:]\s*(\d{2})?(?!\d)", re.I)
100+
101+
102+def parse_time(raw: str | None) -> str | None:
103+ """Extrait une heure "HH:MM" d'un champ source, sans jamais inventer.
104+
105+ Accepte "19h30", "19 h", "19:30", "2026-09-11T19:30:00". Retourne None si
106+ aucune heure plausible (minuit "00:00" est traité comme un placeholder
107+ « toute la journée » par les connecteurs qui le savent, pas ici).
108+ """
109+ if not raw:
110+ return None
111+ s = str(raw).strip()
112+ m = _TIME_ISO_RE.search(s) or _TIME_FR_RE.search(s)
113+ if not m:
114+ return None
115+ h, mn = int(m.group(1)), int(m.group(2) or 0)
116+ if not (0 <= h <= 23 and 0 <= mn <= 59):
117+ return None
118+ return f"{h:02d}:{mn:02d}"
119+
120+
121+def utc_to_local(raw: str | None) -> tuple[str | None, str | None]:
122+ """Horodatage UTC ("2026-08-01T22:30:00[Z]") → (date, heure) locales QC.
123+
124+ Pour les sources qui publient leurs horaires en UTC (Laval, Sherbrooke —
125+ vérifié sur données réelles 2026-08). Heure None si minuit local
126+ (placeholder « toute la journée »).
127+ """
128+ if not raw:
129+ return None, None
130+ from datetime import datetime, timezone
131+ from zoneinfo import ZoneInfo
132+ try:
133+ dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
134+ if dt.tzinfo is None:
135+ dt = dt.replace(tzinfo=timezone.utc)
136+ loc = dt.astimezone(ZoneInfo("America/Montreal"))
137+ except (ValueError, TypeError):
138+ return parse_date_iso(raw), None
139+ t = loc.strftime("%H:%M")
140+ return loc.strftime("%Y-%m-%d"), (None if t == "00:00" else t)
141+
142+
143+# ---------------------------------------------------------------------------
144+# Artistes — extraction conservatrice depuis les titres (billetteries).
145+# Seuls les patrons sûrs sont retenus : on n'invente jamais un artiste.
146+# ---------------------------------------------------------------------------
147+
148+# un « candidat artiste » ne doit contenir aucun de ces mots (événement, pas
149+# un nom d'artiste) — comparaison sans accents, minuscules
150+_ARTIST_STOP = (
151+ "festival", "soiree", "party", "spectacle", "concert", "show", "gala",
152+ "hommage", "tribute", "edition", "tournoi", "atelier", "conference",
153+ "exposition", "marche", "salon", "cabaret", "soir", "matinee", "camp",
154+ "cours", "formation", "vs", " et ses ", "karaoke", "open mic", "jam",
155+ "collecte", "brunch", "souper", "diner", "experience", "tour ",
156+ # types de billets / accès (jamais des artistes)
157+ "acces", "passe", "passeport", "billet", "forfait", "journalier",
158+ "entree", "admission", "reservation", "table ", "presente", "stationnement",
159+ "supplementaire",
160+)
161+# séparateurs « Artiste <sep> reste » (le reste doit matcher la salle, sinon
162+# on ne conclut rien) ; l'ordre n'importe pas
163+_ARTIST_SEPS = (" - ", " – ", " — ", " | ", " : ")
164+# suffixes sûrs : « X en concert », « X en spectacle », « X en rodage »…
165+_ARTIST_SUFFIX_RE = re.compile(
166+ r"^(.{2,60}?)\s+(?:enfin\s+)?en\s+"
167+ r"(concert|spectacle|rodage|tourn[eé]e|suppl[eé]mentaire)", re.I)
168+
169+
170+def _clean_artist(cand: str) -> str:
171+ """Nettoie un candidat : séparateurs/ponctuation résiduels en bordure."""
172+ return clean_text(cand).strip(" -–—:|·,")
173+
174+
175+def _plausible_artist(cand: str) -> bool:
176+ cand = _clean_artist(cand)
177+ if not (2 <= len(cand) <= 60) or len(cand.split()) > 6:
178+ return False
179+ k = f" {_key(cand)} "
180+ if any(w in k for w in _ARTIST_STOP):
181+ return False
182+ if not re.search(r"[a-zA-ZÀ-ÿ]", cand):
183+ return False
184+ return True
185+
186+
187+def artists_from_title(title: str, venue: str = "") -> list[str]:
188+ """Extrait le ou les artistes d'un titre de billetterie — patrons sûrs
189+ seulement :
190+ · « Artiste - Salle » / « Artiste : Salle » où le suffixe EST la salle
191+ connue de l'événement (aucune ambiguïté) ;
192+ · « Artiste en concert/spectacle/rodage/tournée ».
193+ Retourne [] au moindre doute.
194+ """
195+ t = clean_text(title)
196+ if not t:
197+ return []
198+ vk = _key(venue)
199+ if vk:
200+ for sep in _ARTIST_SEPS:
201+ if sep in t:
202+ left, right = t.split(sep, 1)
203+ if _key(right) == vk and _plausible_artist(left):
204+ return [_clean_artist(left)]
205+ m = _ARTIST_SUFFIX_RE.match(t)
206+ if m and _plausible_artist(m.group(1)):
207+ return [_clean_artist(m.group(1))]
208+ return []
209+
210+
94 # --------------------------------------------------------------------------- 211 # ---------------------------------------------------------------------------
95 # Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka : 212 # Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :
96 # ne jamais afficher un coût sans savoir d'où il vient (price_label conservé). 213 # ne jamais afficher un coût sans savoir d'où il vient (price_label conservé).
modified sortika/schema.py +14 −0
@@ -25,6 +25,7 @@ from .normalize import (
25 parse_date_iso, 25 parse_date_iso,
26 parse_latlng, 26 parse_latlng,
27 parse_price, 27 parse_price,
28+ parse_time,
28 ) 29 )
29 from .regions import is_valid_region, region_from_city, region_from_tourist 30 from .regions import is_valid_region, region_from_city, region_from_tourist
30 31
@@ -52,7 +53,9 @@ class Event:
52 lat: float | None = None 53 lat: float | None = None
53 lng: float | None = None 54 lng: float | None = None
54 start_date: str | None = None # ISO "2026-09-11" 55 start_date: str | None = None # ISO "2026-09-11"
56+ start_time: str | None = None # "HH:MM" locale QC (None = inconnue)
55 end_date: str | None = None # ISO "2026-09-13" 57 end_date: str | None = None # ISO "2026-09-13"
58+ artists: list[str] = field(default_factory=list) # artistes à l'affiche
56 is_free: bool | None = None # gratuit (None = inconnu) 59 is_free: bool | None = None # gratuit (None = inconnu)
57 price_min: float | None = None # plus bas prix ($ CAD) si payant 60 price_min: float | None = None # plus bas prix ($ CAD) si payant
58 price_label: str = "" # texte original du coût (traçabilité) 61 price_label: str = "" # texte original du coût (traçabilité)
@@ -96,8 +99,19 @@ class Event:
96 self.organizer = clean_text(self.organizer) 99 self.organizer = clean_text(self.organizer)
97 100
98 self.start_date = parse_date_iso(self.start_date) 101 self.start_date = parse_date_iso(self.start_date)
102+ self.start_time = parse_time(self.start_time)
99 self.end_date = parse_date_iso(self.end_date) or self.start_date 103 self.end_date = parse_date_iso(self.end_date) or self.start_date
100 104
105+ # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés
106+ 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 = cleaned
114+
101 if not self.categories: 115 if not self.categories:
102 self.categories = map_categories(self.raw_categories) 116 self.categories = map_categories(self.raw_categories)
103 117
modified sortika/seo.py +25 −1
@@ -42,10 +42,25 @@ _SRC_LABELS = {
42 "sitq": "SIT Québec — Ministère du Tourisme", "montreal": "Ville de Montréal", 42 "sitq": "SIT Québec — Ministère du Tourisme", "montreal": "Ville de Montréal",
43 "laval": "Ville de Laval", "lepointdevente": "Le point de vente", 43 "laval": "Ville de Laval", "lepointdevente": "Le point de vente",
44 "evenko": "evenko", "atuvu": "Atuvu.ca", "lavitrine": "La Vitrine", 44 "evenko": "evenko", "atuvu": "Atuvu.ca", "lavitrine": "La Vitrine",
45− "sherbrooke": "Ville de Sherbrooke", 45+ "sherbrooke": "Ville de Sherbrooke", "brossard": "Ville de Brossard",
46+ "ticketmaster": "Ticketmaster", "bandsintown": "Bandsintown",
46 } 47 }
47 48
48 49
50+def _tz_offset(day: str) -> str:
51+ """Décalage ISO du Québec pour une date donnée ("-04:00" ou "-05:00")."""
52+ from datetime import datetime
53+ from zoneinfo import ZoneInfo
54+ try:
55+ dt = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("America/Montreal"))
56+ off = dt.utcoffset()
57+ sign = "-" if off.total_seconds() < 0 else "+"
58+ secs = abs(int(off.total_seconds()))
59+ return f"{sign}{secs // 3600:02d}:{(secs % 3600) // 60:02d}"
60+ except Exception:
61+ return "-05:00"
62+
63+
49 def _e(s) -> str: 64 def _e(s) -> str:
50 return H.escape(str(s or ""), quote=True) 65 return H.escape(str(s or ""), quote=True)
51 66
@@ -85,8 +100,15 @@ def _jsonld(ev: dict) -> dict:
85 } 100 }
86 if ev.get("start_date"): 101 if ev.get("start_date"):
87 ld["startDate"] = ev["start_date"] 102 ld["startDate"] = ev["start_date"]
103+ if ev.get("start_time"): # heure précise connue → ISO 8601 complet
104+ ld["startDate"] += f"T{ev['start_time']}:00{_tz_offset(ev['start_date'])}"
88 if ev.get("end_date"): 105 if ev.get("end_date"):
89 ld["endDate"] = ev["end_date"] 106 ld["endDate"] = ev["end_date"]
107+ artists = json.loads(ev.get("artists") or "[]") \
108+ if isinstance(ev.get("artists"), str) else (ev.get("artists") or [])
109+ if artists:
110+ ld["performer"] = [{"@type": "PerformingGroup", "name": a}
111+ for a in artists]
90 if ev.get("description"): 112 if ev.get("description"):
91 ld["description"] = ev["description"][:400] 113 ld["description"] = ev["description"][:400]
92 if ev.get("image"): 114 if ev.get("image"):
@@ -122,6 +144,8 @@ def event_page(uid: str) -> HTMLResponse:
122 when = ev.get("start_date") or "" 144 when = ev.get("start_date") or ""
123 if ev.get("end_date") and ev["end_date"] != ev.get("start_date"): 145 if ev.get("end_date") and ev["end_date"] != ev.get("start_date"):
124 when += f" au {ev['end_date']}" 146 when += f" au {ev['end_date']}"
147+ elif ev.get("start_time"):
148+ when += f" à {ev['start_time'].replace(':', ' h ')}"
125 title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}" 149 title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}"
126 desc = (ev.get("description") or 150 desc = (ev.get("description") or
127 f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie " 151 f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie "
modified sortika/web.py +14 −3
@@ -34,7 +34,7 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
34 34
35 def _row_to_dict(row) -> dict: 35 def _row_to_dict(row) -> dict:
36 d = dict(row) 36 d = dict(row)
37− for k in ("categories", "raw_categories"): 37+ for k in ("categories", "raw_categories", "artists"):
38 d[k] = json.loads(d.get(k) or "[]") 38 d[k] = json.loads(d.get(k) or "[]")
39 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"]) 39 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])
40 d.pop("content_hash", None) 40 d.pop("content_hash", None)
@@ -103,10 +103,12 @@ def list_events(
103 103
104 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié 104 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié
105 # par plusieurs sources = une seule carte, la fiche la plus riche gagne 105 # par plusieurs sources = une seule carte, la fiche la plus riche gagne
106− # (prix connu > image > description), les autres restent accessibles par uid. 106+ # (prix connu > GPS > image > description), les autres restent conservées
107+ # en base et accessibles par uid.
107 ranked = ( 108 ranked = (
108 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY " 109 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "
109− "(CASE WHEN price_min IS NOT NULL THEN 4 ELSE 0 END) + " 110+ "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "
111+ "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "
110 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + " 112 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "
111 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn " 113 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "
112 f"FROM events WHERE {sql_where}") 114 f"FROM events WHERE {sql_where}")
@@ -163,7 +165,16 @@ def stats() -> dict:
163 "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''" 165 "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"
164 ).fetchone()[0] 166 ).fetchone()[0]
165 con.close() 167 con.close()
168+ # nombre de sources branchées (statut actif du registre) — lu par le hub
169+ # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.
170+ try:
171+ reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
172+ n_sources = sum(1 for s in reg.get("sources", [])
173+ if s.get("statut") == "actif")
174+ except Exception:
175+ n_sources = 0
166 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free, 176 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,
177+ "sources": n_sources,
167 "cities": cities, "by_region": by_region, 178 "cities": cities, "by_region": by_region,
168 "by_category": dict(sorted(by_category.items(), 179 "by_category": dict(sorted(by_category.items(),
169 key=lambda kv: -kv[1]))} 180 key=lambda kv: -kv[1]))}
modified tests/test_connectors.py +115 −1
@@ -25,7 +25,8 @@ def _patch_json(monkeypatch, connector, payloads):
25 25
26 def test_registry_autodiscovers(): 26 def test_registry_autodiscovers():
27 for sid in ("sitq", "montreal", "lepointdevente", "laval", 27 for sid in ("sitq", "montreal", "lepointdevente", "laval",
28− "evenko", "atuvu", "lavitrine"): 28+ "evenko", "atuvu", "lavitrine", "sherbrooke",
29+ "brossard", "ticketmaster", "bandsintown"):
29 assert sid in CONNECTORS, sid 30 assert sid in CONNECTORS, sid
30 31
31 32
@@ -43,6 +44,10 @@ def test_evenko_parses_fixture(monkeypatch):
43 assert ev.start_date and ev.start_date.startswith("20") 44 assert ev.start_date and ev.start_date.startswith("20")
44 assert ev.url.startswith("http") # billets Ticketmaster / salle 45 assert ev.url.startswith("http") # billets Ticketmaster / salle
45 assert "musique" in ev.categories or ev.categories 46 assert "musique" in ev.categories or ev.categories
47+ # vague 2 : heure locale du show_time + artistes du champ headliners
48+ deep = next(e for e in events if "Deep Purple" in e.title)
49+ assert deep.artists == ["Deep Purple"]
50+ assert deep.start_time and len(deep.start_time) == 5 # "HH:MM"
46 51
47 52
48 def test_atuvu_parses_fixture(): 53 def test_atuvu_parses_fixture():
@@ -92,6 +97,11 @@ def test_lepointdevente_parses_fixture():
92 assert ev.city == "Montréal" and ev.region == "Montréal" 97 assert ev.city == "Montréal" and ev.region == "Montréal"
93 assert ev.venue == "Club Soda" 98 assert ev.venue == "Club Soda"
94 assert ev.start_date == "2026-09-06" 99 assert ev.start_date == "2026-09-06"
100+ # vague 2 : la carte de liste porte l'heure (« …, 20h00 ») quand la source
101+ # la publie (JOAT est multi-jours sans heure : start_time None, jamais inventé)
102+ assert ev.start_time is None
103+ assert sum(1 for e in events if e.start_time) >= 35
104+ assert all(len(e.start_time) == 5 for e in events if e.start_time)
95 assert ev.url.startswith("https://lepointdevente.com/billets/") 105 assert ev.url.startswith("https://lepointdevente.com/billets/")
96 # dates FR parsées et régions rattachées hors Montréal aussi 106 # dates FR parsées et régions rattachées hors Montréal aussi
97 stq = next(e for e in events if e.city == "Saint-Agapit") 107 stq = next(e for e in events if e.city == "Saint-Agapit")
@@ -146,3 +156,107 @@ def test_montreal_parses_fixture(monkeypatch):
146 # « Gratuit » de la fixture → is_free True, jamais un prix inventé 156 # « Gratuit » de la fixture → is_free True, jamais un prix inventé
147 frees = [e for e in events if e.is_free] 157 frees = [e for e in events if e.is_free]
148 assert frees and all(e.price_min is None for e in frees) 158 assert frees and all(e.price_min is None for e in frees)
159+
160+
161+def test_sherbrooke_times_converted_to_local(monkeypatch):
162+ """Les périodes UTC deviennent date + heure locales (placeholder = None)."""
163+ from sortika.connectors.sherbrooke import SherbrookeConnector
164+ c = SherbrookeConnector()
165+ html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8")
166+ monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())
167+ events = [e.finalize() for e in c.fetch()]
168+ # fixture : 11:30:00Z (été) → 07:30 locale ; 00:00:00Z → 20:00 locale
169+ times = {e.start_time for e in events}
170+ assert "07:30" in times or "20:00" in times
171+
172+
173+def test_ticketmaster_without_key_is_ready_but_skipped(monkeypatch):
174+ from sortika.connectors.ticketmaster import TicketmasterConnector
175+ monkeypatch.delenv("TICKETMASTER_API_KEY", raising=False)
176+ assert TicketmasterConnector().fetch() == [] # « clé manquante », pas d'erreur
177+
178+
179+def test_ticketmaster_parses_hit():
180+ """Parse d'un hit Discovery v2 (structure officielle documentée)."""
181+ from sortika.connectors.ticketmaster import TicketmasterConnector
182+ hit = {
183+ "id": "G5vYZ9x1", "name": "Les Cowboys Fringants",
184+ "url": "https://www.ticketmaster.ca/x/G5vYZ9x1",
185+ "dates": {"start": {"localDate": "2026-09-20", "localTime": "19:30:00"}},
186+ "classifications": [{"segment": {"name": "Music"},
187+ "genre": {"name": "Rock"}}],
188+ "priceRanges": [{"min": 45.0, "max": 120.0}],
189+ "images": [{"ratio": "16_9", "width": 1024, "url": "https://img/x.jpg"},
190+ {"ratio": "4_3", "width": 305, "url": "https://img/s.jpg"}],
191+ "_embedded": {
192+ "venues": [{"name": "Centre Bell",
193+ "city": {"name": "Montréal"},
194+ "postalCode": "H4B 5G0",
195+ "address": {"line1": "1909 Avenue des Canadiens"},
196+ "location": {"latitude": "45.496", "longitude": "-73.569"}}],
197+ "attractions": [{"name": "Les Cowboys Fringants"}]},
198+ }
199+ ev = TicketmasterConnector()._hit_to_event(hit).finalize()
200+ assert ev.uid == "ticketmaster:G5vYZ9x1"
201+ assert ev.start_date == "2026-09-20" and ev.start_time == "19:30"
202+ assert ev.venue == "Centre Bell" and ev.city == "Montréal"
203+ assert ev.lat and 45 < ev.lat < 46
204+ assert ev.price_min == 45.0 and "120" in ev.price_label
205+ assert ev.artists == ["Les Cowboys Fringants"]
206+ assert ev.image == "https://img/x.jpg"
207+ assert "musique" in ev.categories
208+
209+
210+def test_bandsintown_filters_quebec(monkeypatch):
211+ """Seules les dates au Québec sont gardées (structure API réelle 2026-08)."""
212+ from sortika.connectors.bandsintown import BandsintownConnector
213+ c = BandsintownConnector()
214+ payload = [
215+ {"id": "1038531745", "url": "https://www.bandsintown.com/e/1038531745",
216+ "datetime": "2026-08-18T18:45:00", "title": "",
217+ "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},
218+ "venue": {"name": "RBC Amphitheatre", "city": "Toronto",
219+ "country": "Canada", "region": "ON",
220+ "latitude": "43.62", "longitude": "-79.41"},
221+ "lineup": ["Deep Purple"], "offers": [], "free": False},
222+ {"id": "1038531746", "url": "https://www.bandsintown.com/e/1038531746",
223+ "datetime": "2026-09-02T20:00:00", "title": "",
224+ "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},
225+ "venue": {"name": "Centre Vidéotron", "city": "Québec",
226+ "country": "Canada", "region": "QC",
227+ "latitude": "46.828", "longitude": "-71.247"},
228+ "lineup": ["Deep Purple"], "offers": [], "free": False},
229+ ]
230+ monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)
231+ rows = c._artist_events("Deep Purple")
232+ assert len(rows) == 1
233+ r = rows[0]
234+ assert r["city"] == "Québec" and r["venue"] == "Centre Vidéotron"
235+ assert r["date"] == "2026-09-02" and r["time"] == "20:00"
236+ assert r["lineup"] == ["Deep Purple"]
237+
238+
239+def test_brossard_parses_fiche_banner_and_jsonld():
240+ from sortika.connectors.brossard import BrossardConnector
241+ c = BrossardConnector()
242+ # bannière des fiches ville (structure réelle brossard.ca, 2026-08)
243+ ville = ('<div class="d-flex align-items-center"> Cet événement aura lieu '
244+ 'le 6 décembre 2026, de 15 h 00 à 16 h 00 </div>'
245+ '<meta property="og:image" content="https://brossard.ca/i.jpg">')
246+ info = c._parse_fiche(ville)
247+ assert info["start_date"] == "2026-12-06" and info["time"] == "15:00"
248+ assert info["image"] == "https://brossard.ca/i.jpg"
249+ # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08)
250+ biblio = ('<script type="application/ld+json">[{"@context":"http://schema.org",'
251+ '"@type":"Event","name":"La marche : un chemin de santé",'
252+ '"startDate":"2026-11-27T13:30:00","endDate":"2026-11-27T15:00:00",'
253+ '"image":"https://biblio.brossard.ca/g.jpg",'
254+ '"location":[{"@type":"Place","name":"Salle d\'animation",'
255+ '"address":{"@type":"PostalAddress",'
256+ '"streetAddress":"7855, av San Francisco","postalCode":"J4X 2A4"}}]}]'
257+ '</script>')
258+ info = c._parse_fiche(biblio)
259+ assert info["start_date"] == "2026-11-27" and info["time"] == "13:30"
260+ assert info["end_date"] == "2026-11-27"
261+ assert info["venue"] == "Salle d'animation"
262+ assert info["postal_code"] == "J4X 2A4"
modified tests/test_db.py +27 −0
@@ -52,6 +52,33 @@ def test_drift_suspends_removals(tmp_path):
52 assert n == 20 52 assert n == 20
53 53
54 54
55+def test_start_time_and_artists_persisted(tmp_path):
56+ con = db.connect(tmp_path / "t.db")
57+ ev = Event(source="evenko", external_id="s1", url="https://x/s1",
58+ title="Deep Purple", city="Montréal",
59+ start_date="2027-01-15", start_time="19h30",
60+ artists=["Deep Purple", " deep purple "]).finalize()
61+ assert ev.start_time == "19:30" # normalisée "HH:MM"
62+ assert ev.artists == ["Deep Purple"] # dédupliqués
63+ db.sync_source(con, "evenko", [ev])
64+ row = con.execute("SELECT start_time, artists FROM events").fetchone()
65+ assert row["start_time"] == "19:30"
66+ assert row["artists"] == '["Deep Purple"]'
67+
68+
69+def test_migration_adds_columns(tmp_path):
70+ p = tmp_path / "old.db"
71+ con = db.connect(p)
72+ # simuler une base d'avant la vague 2 (sans les colonnes additives)
73+ con.execute("ALTER TABLE events DROP COLUMN start_time")
74+ con.execute("ALTER TABLE events DROP COLUMN artists")
75+ con.commit()
76+ con.close()
77+ con = db.connect(p) # la connexion migre (colonnes additives)
78+ cols = {r["name"] for r in con.execute("PRAGMA table_info(events)")}
79+ assert {"start_time", "artists"} <= cols
80+
81+
55 def test_dedup_key_cross_source(): 82 def test_dedup_key_cross_source():
56 a = _ev(source="sitq", title="Grand marché de Noël") 83 a = _ev(source="sitq", title="Grand marché de Noël")
57 b = Event(source="montreal", external_id="99", url="https://y", 84 b = Event(source="montreal", external_id="99", url="https://y",
modified tests/test_normalize.py +36 −2
@@ -5,8 +5,9 @@
5 # ============================================================================== 5 # ==============================================================================
6 from __future__ import annotations 6 from __future__ import annotations
7 7
8−from sortika.normalize import (clean_city, clean_text, map_categories, 8+from sortika.normalize import (artists_from_title, clean_city, clean_text,
9− parse_date_iso, parse_latlng, parse_price) 9+ map_categories, parse_date_iso, parse_latlng,
10+ parse_price, parse_time, utc_to_local)
10 from sortika.regions import region_from_city, region_from_tourist 11 from sortika.regions import region_from_city, region_from_tourist
11 from sortika.schema import Event 12 from sortika.schema import Event
12 13
@@ -19,6 +20,39 @@ def test_parse_date_iso():
19 assert parse_date_iso("0001-99-99") is None 20 assert parse_date_iso("0001-99-99") is None
20 21
21 22
23+def test_parse_time():
24+ assert parse_time("17 août 2026, 20h00") == "20:00"
25+ assert parse_time("19h30") == "19:30"
26+ assert parse_time("15 h 00") == "15:00"
27+ assert parse_time("19:30:00") == "19:30"
28+ assert parse_time("2026-09-11T19:30:00") == "19:30"
29+ assert parse_time("17 août 2026") is None
30+ assert parse_time("") is None
31+ assert parse_time(None) is None
32+
33+
34+def test_utc_to_local():
35+ # hiver (UTC-5) et été (UTC-4), vérifiés sur les flux Laval/Sherbrooke
36+ assert utc_to_local("2025-02-13T23:30:00") == ("2025-02-13", "18:30")
37+ assert utc_to_local("2026-08-01T22:30:00Z") == ("2026-08-01", "18:30")
38+ # minuit local = placeholder « toute la journée » → pas d'heure
39+ assert utc_to_local("2026-07-10T04:00:00Z") == ("2026-07-10", None)
40+ assert utc_to_local(None) == (None, None)
41+
42+
43+def test_artists_from_title():
44+ # patron sûr : suffixe = salle connue de l'événement
45+ assert artists_from_title("Les Cowboys Fringants - Club Soda",
46+ "Club Soda") == ["Les Cowboys Fringants"]
47+ # patron sûr : « X en concert / en spectacle »
48+ assert artists_from_title("Marc Dupré en spectacle", "") == ["Marc Dupré"]
49+ # ambigu ou événement → jamais d'artiste inventé
50+ assert artists_from_title("Festival d'été de Québec", "") == []
51+ assert artists_from_title("Soirée humour - Club Soda", "Club Soda") == []
52+ assert artists_from_title("Un artiste - Une autre salle", "Club Soda") == []
53+ assert artists_from_title("", "") == []
54+
55+
22 def test_parse_price(): 56 def test_parse_price():
23 assert parse_price("Gratuit") == (True, None, "Gratuit") 57 assert parse_price("Gratuit") == (True, None, "Gratuit")
24 assert parse_price("Entrée libre") == (True, None, "Entrée libre") 58 assert parse_price("Entrée libre") == (True, None, "Entrée libre")
25 59
Menu

Theme