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>
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 | |