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%

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 1 mo ago (Aug 18, 2026) parent 7d9d1b0

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 15 data/sortika.db-shm
16 16 data/sortika.db-wal
17 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 36 | **evenko** | promoteur — robots `Allow: /api/search*` | proxy Algolia (~460 grands concerts : Centre Bell, MTELUS…) |
37 37 | **La Vitrine** | Content-Signal `search=yes` | sitemap + fiches HTML, cache incrémental (~5 100 représentations) |
38 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) |
40 | **Ville de Laval** | données ouvertes CC 4.0 | JSON officiel (~290 activités) |
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, 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 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 46 - **Répertoire des municipalités MAMH****1 250 villes** + arrondissements + alias, homonymes résolus par population
44 47 - **Persistance robuste** — upsert par hash de contenu, **délai de grâce 2 syncs**, alerte de dérive, historique de syncs
45 48 - **Page /stats** — tableau de bord analytique + **rapport PDF Groupe-KA**
46 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 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 72 | Couche | Rôle | Fichiers |
69 73 |---|---|---|
70 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 76 | **Normalisation** | dates FR/ISO, prix, **14 catégories**, villes, entités HTML | `sortika/normalize.py` |
73 77 | **Régions** | Répertoire MAMH : 1 250 villes + arrondissements + alias | `sortika/regions.py` + `data/villes_regions.json` |
74 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 97 ```bash
94 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)
96 .venv/bin/python run.py sync # ingestion des 8 sources
99 +.venv/bin/python -m pytest tests/ -q # 32 tests (fixtures réelles, zéro réseau)
100 +.venv/bin/python run.py sync # ingestion des 10 sources actives
97 101 .venv/bin/python run.py web --port 8120 # API + frontend + SSR
98 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 107 ## Déploiement
104 108
@@ -107,7 +111,7 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
107 111 - **Domaine** : **[www.sorti-ka.com](https://www.sorti-ka.com)** (tunnel ngrok)
108 112 - **PM2** (3 processus) :
109 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 115 - **`sorti-ka-ngrok`** — tunnel vers **www.sorti-ka.com**
112 116 - **Registre** : `~/Desktop/cluster-skill/cluster-deployments.json`
113 117
modified data/sources.json +88 −6
@@ -17,7 +17,7 @@
17 17 "name": "Ville de Montréal — Événements publics",
18 18 "url": "https://donnees.montreal.ca/dataset/evenements-publics",
19 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 21 "acces_legal": "Licence CC BY 4.0 (donnees.montreal.ca) — attribution : Ville de Montréal",
22 22 "couverture": "Événements municipaux, île de Montréal",
23 23 "cadence": "horaire (watcher PM2)",
@@ -28,7 +28,7 @@
28 28 "name": "Le point de vente — billetterie québécoise",
29 29 "url": "https://lepointdevente.com/billets",
30 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 32 "acces_legal": "robots.txt : « User-Agent: * / Allow: * » — GET throttlés, identification honnête, lien direct vers chaque billetterie",
33 33 "couverture": "Concerts, humour, théâtre, festivals — partout au Québec (~2 500 événements à venir)",
34 34 "cadence": "horaire (watcher PM2)",
@@ -39,7 +39,7 @@
39 39 "name": "Ville de Laval — Calendrier des activités",
40 40 "url": "https://www.donneesquebec.ca/recherche/dataset/calendrier-des-activites",
41 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 43 "acces_legal": "Licence CC 4.0 (Données Québec) — Ville de Laval",
44 44 "couverture": "Activités municipales, Laval",
45 45 "cadence": "horaire (watcher PM2)",
@@ -50,7 +50,7 @@
50 50 "name": "Ville de Sherbrooke — Calendrier des événements",
51 51 "url": "https://www.sherbrooke.ca/fr/culture-sports-et-loisirs/calendrier-des-activites",
52 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 54 "acces_legal": "page municipale publique (référencée sur Données Québec) — GET direct, identification honnête",
55 55 "couverture": "Événements municipaux et culturels, Sherbrooke (Estrie)",
56 56 "cadence": "horaire (watcher PM2)",
@@ -97,7 +97,7 @@
97 97 "name": "evenko — grands concerts et festivals",
98 98 "url": "https://evenko.ca/fr/evenements",
99 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 101 "acces_legal": "robots.txt : Allow /api/search* (explicite) — lien billets = URL Ticketmaster officielle",
102 102 "couverture": "Grands concerts (Centre Bell, Place Bell, MTELUS…), festivals (Osheaga, ÎleSoniq…)",
103 103 "cadence": "horaire (watcher PM2)",
@@ -108,7 +108,7 @@
108 108 "name": "Atuvu.ca — calendrier culturel",
109 109 "url": "https://atuvu.ca/",
110 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 112 "acces_legal": "robots.txt : Allow / + sitemap public",
113 113 "couverture": "Sorties culturelles, Grand Montréal",
114 114 "cadence": "horaire (watcher PM2, incrémental)",
@@ -124,6 +124,88 @@
124 124 "couverture": "Spectacles et sorties culturelles, Grand Montréal",
125 125 "cadence": "horaire (watcher PM2, incrémental)",
126 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 572 const SRC_LABELS = {
573 573 "sitq":"SIT Québec — Ministère du Tourisme","montreal":"Ville de Montréal",
574 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 578 const MONTHS = ["jan","fév","mar","avr","mai","juin","juil","août","sep","oct","nov","déc"];
578 579 const MONTHS_FULL = ["janvier","février","mars","avril","mai","juin","juillet",
@@ -583,11 +584,17 @@ function fmtDate(iso, full=false){
583 584 const [y,m,d] = iso.split("-").map(Number);
584 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 592 function dateBadge(ev, full=false){
587 593 if(!ev.start_date) return "Date à confirmer";
588 594 if(ev.end_date && ev.end_date !== ev.start_date)
589 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 599 function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
593 600 function priceBadge(ev){
@@ -677,6 +684,8 @@ async function showDetail(uid){
677 684
678 685 const facts = [];
679 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 689 if(ev.venue) facts.push(["Lieu", esc(ev.venue)]);
681 690 if(ev.address) facts.push(["Adresse", esc(ev.address)]);
682 691 if(ev.city) facts.push(["Ville", esc(ev.city)]);
modified sortika/connectors/atuvu.py +3 −1
@@ -19,7 +19,7 @@ import re
19 19 import time
20 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 23 from ..schema import Event
24 24 from .base import BaseConnector
25 25
@@ -81,6 +81,7 @@ class AtuvuConnector(BaseConnector):
81 81 continue
82 82 rows.append({
83 83 "external_id": f"{slug}-{day}",
84 + "start_time": parse_time(date_txt), # « … - 20h00 » de la fiche
84 85 "url": url, "title": title, "image": image,
85 86 "raw_categories": cats, "venue": clean_text(venue),
86 87 "city": clean_text(city or ""), "start_date": day,
@@ -118,6 +119,7 @@ class AtuvuConnector(BaseConnector):
118 119 raw_categories=row.get("raw_categories") or [],
119 120 venue=row.get("venue", ""), city=row.get("city", ""),
120 121 start_date=row.get("start_date"),
122 + start_time=row.get("start_time"),
121 123 end_date=row.get("end_date"),
122 124 price_min=row.get("price_min"),
123 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 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 59 def _ticket_url(hit: dict) -> str:
50 60 """URL billets officielle (fr_CA) depuis additional_information."""
51 61 reg = (hit.get("additional_information") or {}).get("regular_ticket") or {}
@@ -96,6 +106,12 @@ class EvenkoConnector(BaseConnector):
96 106 if thumb.startswith("//"):
97 107 thumb = "https:" + thumb
98 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 115 # gratuité : champ `free` (aujourd'hui toujours null côté
100 116 # source, gardé au cas où il serait re-rempli) + étiquettes
101 117 # explicites « gratuit » (event_tag / promotional_tag).
@@ -116,7 +132,9 @@ class EvenkoConnector(BaseConnector):
116 132 venue=venue.get("name") or "",
117 133 city=venue.get("city") or "",
118 134 start_date=day,
135 + start_time=start_time,
119 136 end_date=day,
137 + artists=artists,
120 138 is_free=is_free,
121 139 organizer="evenko",
122 140 image=thumb,
modified sortika/connectors/laval.py +9 −2
@@ -10,6 +10,7 @@
10 10 # -----------------------------------------------------------------------------
11 11 from __future__ import annotations
12 12
13 +from ..normalize import utc_to_local
13 14 from ..schema import Event
14 15 from .base import BaseConnector
15 16
@@ -38,6 +39,11 @@ class LavalConnector(BaseConnector):
38 39 # → l'id externe inclut la date pour ne pas écraser les occurrences
39 40 slug = page.rsplit("/", 1)[-1].removesuffix(".aspx")
40 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 47 events.append(Event(
42 48 source=self.source_id,
43 49 external_id=f"{slug}-{day}" if day else slug,
@@ -51,8 +57,9 @@ class LavalConnector(BaseConnector):
51 57 venue=", ".join(_labels(rec.get("Locations"))),
52 58 city="Laval",
53 59 region="Laval",
54 start_date=rec.get("EventDate"),
55 end_date=rec.get("EndDate"),
60 + start_date=start_local or rec.get("EventDate"),
61 + start_time=time_local,
62 + end_date=end_local or rec.get("EndDate"),
56 63 organizer="Ville de Laval",
57 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 25 import re
26 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 29 from ..schema import Event
30 30 from .base import BaseConnector
31 31
@@ -84,6 +84,10 @@ class LePointDeVenteConnector(BaseConnector):
84 84 venue=venue,
85 85 city=city,
86 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 91 image=image or "",
88 92 ))
89 93 return events
@@ -165,6 +169,8 @@ class LePointDeVenteConnector(BaseConnector):
165 169 info = cache.get(ev.url) or {}
166 170 if info.get("description") and not ev.description:
167 171 ev.description = info["description"]
172 + if info.get("time"): # heure précise de la fiche ("19h30")
173 + ev.start_time = info["time"]
168 174 if "is_free" in info: # tarifs réels de la billetterie
169 175 ev.is_free = info["is_free"]
170 176 ev.price_min = info.get("price_min")
modified sortika/connectors/montreal.py +33 −13
@@ -26,6 +26,9 @@ PAGE = 1000
26 26 IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300"))
27 27
28 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 33 # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom
31 34 # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières
@@ -45,33 +48,50 @@ class MontrealConnector(BaseConnector):
45 48 source_id = "montreal"
46 49 request_delay = 0.4
47 50
48 def _enrich_images(self, events: list[Event]) -> None:
49 """og:image des fiches montreal.ca — incrémental, cache disque,
50 pool de threads (6). Le cache mémorise aussi les fiches SANS image
51 ("") pour ne pas les re-visiter, et seuls les événements à venir
52 sont visités (pas les archives).
51 + def _enrich_fiches(self, events: list[Event]) -> None:
52 + """og:image + heure précise des fiches montreal.ca — incrémental,
53 + cache disque, pool de threads (6). Le jeu de données ouvert ne publie
54 + pas l'heure, mais chaque fiche montreal.ca l'expose en JSON-LD
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 61 from datetime import date
55 62 today = date.today().isoformat()
56 63 cache = self.load_cache()
57 64 to_fetch = []
58 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 68 continue
61 69 last_day = (ev.end_date or ev.start_date or "")[:10]
62 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 75 def worker(url, session):
66 m = _OG_IMG_RE.search(session.get(url, timeout=15).text)
67 return m.group(1) if m else ""
76 + html = session.get(url, timeout=15).text
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():
70 cache[url] = img or ""
82 + for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items():
83 + cache[url] = info or {"image": "", "time": None}
71 84 self.save_cache(cache)
72 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 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 96 def fetch(self) -> list[Event]:
77 97 events: list[Event] = []
@@ -126,5 +146,5 @@ class MontrealConnector(BaseConnector):
126 146 offset += PAGE
127 147 if offset >= int(result.get("total", 0)):
128 148 break
129 self._enrich_images(events)
149 + self._enrich_fiches(events)
130 150 return events
modified sortika/connectors/sherbrooke.py +6 −2
@@ -15,6 +15,7 @@ from __future__ import annotations
15 15
16 16 import json
17 17
18 +from ..normalize import utc_to_local
18 19 from ..schema import Event
19 20 from .base import BaseConnector
20 21
@@ -63,8 +64,10 @@ class SherbrookeConnector(BaseConnector):
63 64 periods = rec.get("periods") or []
64 65 if not title or not full_url or not periods:
65 66 continue
66 start = (periods[0].get("dateDebut") or "")[:10]
67 end = (periods[-1].get("dateFin") or "")[:10]
67 + # les périodes sont publiées en UTC (« …T04:00:00Z » = minuit
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 72 cats, audience = [], []
70 73 c = rec.get("categories") or {}
@@ -95,6 +98,7 @@ class SherbrookeConnector(BaseConnector):
95 98 city="Sherbrooke",
96 99 region="Estrie",
97 100 start_date=start or None,
101 + start_time=start_time,
98 102 end_date=end or start or None,
99 103 is_free=(not payant) if isinstance(payant, bool) else None,
100 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 47 lat REAL,
48 48 lng REAL,
49 49 start_date TEXT,
50 + start_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
50 51 end_date TEXT,
52 + artists TEXT, -- JSON (artistes à l'affiche)
51 53 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu
52 54 price_min REAL,
53 55 price_label TEXT,
@@ -105,9 +107,23 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
105 107 except sqlite3.OperationalError:
106 108 pass
107 109 con.executescript(_SCHEMA)
110 + _migrate(con)
108 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 127 def _event_row(ev: Event, now: float) -> dict:
112 128 return {
113 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 133 "audience": ev.audience, "venue": ev.venue, "address": ev.address,
118 134 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region,
119 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 139 "is_free": (None if ev.is_free is None else int(ev.is_free)),
122 140 "price_min": ev.price_min, "price_label": ev.price_label,
123 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 172 """INSERT INTO events (uid, source, external_id, url, title,
155 173 description, categories, raw_categories, audience, venue,
156 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 177 organizer, website, image, dedup_key, content_hash,
159 178 first_seen, last_seen, updated_at, miss_count, active)
160 179 VALUES (:uid,:source,:external_id,:url,:title,:description,
161 180 :categories,:raw_categories,:audience,:venue,:address,:city,
162 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 184 :website,:image,:dedup_key,:content_hash,
165 185 :now,:now,:now,0,1)""", row)
166 186 stats["added"] += 1
@@ -172,7 +192,8 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
172 192 venue=:venue, address=:address, city=:city, region=:region,
173 193 tourist_region=:tourist_region, postal_code=:postal_code,
174 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 197 price_label=:price_label, organizer=:organizer,
177 198 website=:website, image=:image, dedup_key=:dedup_key,
178 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 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 212 # Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :
96 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 25 parse_date_iso,
26 26 parse_latlng,
27 27 parse_price,
28 + parse_time,
28 29 )
29 30 from .regions import is_valid_region, region_from_city, region_from_tourist
30 31
@@ -52,7 +53,9 @@ class Event:
52 53 lat: float | None = None
53 54 lng: float | None = None
54 55 start_date: str | None = None # ISO "2026-09-11"
56 + start_time: str | None = None # "HH:MM" locale QC (None = inconnue)
55 57 end_date: str | None = None # ISO "2026-09-13"
58 + artists: list[str] = field(default_factory=list) # artistes à l'affiche
56 59 is_free: bool | None = None # gratuit (None = inconnu)
57 60 price_min: float | None = None # plus bas prix ($ CAD) si payant
58 61 price_label: str = "" # texte original du coût (traçabilité)
@@ -96,8 +99,19 @@ class Event:
96 99 self.organizer = clean_text(self.organizer)
97 100
98 101 self.start_date = parse_date_iso(self.start_date)
102 + self.start_time = parse_time(self.start_time)
99 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 115 if not self.categories:
102 116 self.categories = map_categories(self.raw_categories)
103 117
modified sortika/seo.py +25 −1
@@ -42,10 +42,25 @@ _SRC_LABELS = {
42 42 "sitq": "SIT Québec — Ministère du Tourisme", "montreal": "Ville de Montréal",
43 43 "laval": "Ville de Laval", "lepointdevente": "Le point de vente",
44 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 64 def _e(s) -> str:
50 65 return H.escape(str(s or ""), quote=True)
51 66
@@ -85,8 +100,15 @@ def _jsonld(ev: dict) -> dict:
85 100 }
86 101 if ev.get("start_date"):
87 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 105 if ev.get("end_date"):
89 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 112 if ev.get("description"):
91 113 ld["description"] = ev["description"][:400]
92 114 if ev.get("image"):
@@ -122,6 +144,8 @@ def event_page(uid: str) -> HTMLResponse:
122 144 when = ev.get("start_date") or ""
123 145 if ev.get("end_date") and ev["end_date"] != ev.get("start_date"):
124 146 when += f" au {ev['end_date']}"
147 + elif ev.get("start_time"):
148 + when += f" à {ev['start_time'].replace(':', ' h ')}"
125 149 title = f"{ev['title']}{ev.get('city') or 'Québec'} | {SITE_NAME}"
126 150 desc = (ev.get("description") or
127 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 35 def _row_to_dict(row) -> dict:
36 36 d = dict(row)
37 for k in ("categories", "raw_categories"):
37 + for k in ("categories", "raw_categories", "artists"):
38 38 d[k] = json.loads(d.get(k) or "[]")
39 39 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])
40 40 d.pop("content_hash", None)
@@ -103,10 +103,12 @@ def list_events(
103 103
104 104 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié
105 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 108 ranked = (
108 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 112 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "
111 113 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "
112 114 f"FROM events WHERE {sql_where}")
@@ -163,7 +165,16 @@ def stats() -> dict:
163 165 "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"
164 166 ).fetchone()[0]
165 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 176 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,
177 + "sources": n_sources,
167 178 "cities": cities, "by_region": by_region,
168 179 "by_category": dict(sorted(by_category.items(),
169 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 26 def test_registry_autodiscovers():
27 27 for sid in ("sitq", "montreal", "lepointdevente", "laval",
28 "evenko", "atuvu", "lavitrine"):
28 + "evenko", "atuvu", "lavitrine", "sherbrooke",
29 + "brossard", "ticketmaster", "bandsintown"):
29 30 assert sid in CONNECTORS, sid
30 31
31 32
@@ -43,6 +44,10 @@ def test_evenko_parses_fixture(monkeypatch):
43 44 assert ev.start_date and ev.start_date.startswith("20")
44 45 assert ev.url.startswith("http") # billets Ticketmaster / salle
45 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 53 def test_atuvu_parses_fixture():
@@ -92,6 +97,11 @@ def test_lepointdevente_parses_fixture():
92 97 assert ev.city == "Montréal" and ev.region == "Montréal"
93 98 assert ev.venue == "Club Soda"
94 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 105 assert ev.url.startswith("https://lepointdevente.com/billets/")
96 106 # dates FR parsées et régions rattachées hors Montréal aussi
97 107 stq = next(e for e in events if e.city == "Saint-Agapit")
@@ -146,3 +156,107 @@ def test_montreal_parses_fixture(monkeypatch):
146 156 # « Gratuit » de la fixture → is_free True, jamais un prix inventé
147 157 frees = [e for e in events if e.is_free]
148 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 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 82 def test_dedup_key_cross_source():
56 83 a = _ev(source="sitq", title="Grand marché de Noël")
57 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 6 from __future__ import annotations
7 7
8 from sortika.normalize import (clean_city, clean_text, map_categories,
9 parse_date_iso, parse_latlng, parse_price)
8 +from sortika.normalize import (artists_from_title, clean_city, clean_text,
9 + map_categories, parse_date_iso, parse_latlng,
10 + parse_price, parse_time, utc_to_local)
10 11 from sortika.regions import region_from_city, region_from_tourist
11 12 from sortika.schema import Event
12 13
@@ -19,6 +20,39 @@ def test_parse_date_iso():
19 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 56 def test_parse_price():
23 57 assert parse_price("Gratuit") == (True, None, "Gratuit")
24 58 assert parse_price("Entrée libre") == (True, None, "Entrée libre")
25 59