Phase 3 — 9 nouveaux connecteurs + déroulage des périodes SITQ (fiches d évaluation avant connexion)
- lavalweb : calendrier VIVANT laval.ca (SSR paginé, ~470 fiches — remplace le jeu Données Québec mort à la source, consigné Phase 2) - saguenaysorties : portail sorties.saguenay.ca (ajax/plus + ajax/details, 445 séances horodatées — remplace le CSV mort de 2022) - lhjmq : API HockeyTech modulekit (matchs à domicile QC, 12 villes/10 régions) - placedesarts : fiches Craft (eventbannerdata/eventGTM + représentations) — une fiche PAR séance avec heure et PRIX RÉELS - sallesjsonld : moteur générique schema.org/Event configurable (data/salles_jsonld.json — amorce Grand Théâtre/Trident/Capitole ; heures UTC du Grand Théâtre converties en heure locale) - quebecanimee : activités gratuites Ville de Québec (attributs data-*) - lanaudiere : ATR via sitemap (Crawl-delay 2 s et Disallow /*? respectés) - outaouais : calendrier complet dans les props de l îlot Astro (1 GET) - bassaintlaurent : ATR SSR + GPS exact des fiches - sitq : PeriodeOuvertures[] déroulées en occurrences datées (cap 24, 1re période = identifiant historique) — 1 599 → 1 803 fiches - tests : 46 → 59 verts (fixtures réelles pour chaque connecteur + annuaire) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27 changed files +2,124 −22
added
data/salles_jsonld.json
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Sorti-Ka — Salles à JSON-LD Event (connecteur générique sallesjsonld). Ajouter une salle = 1 bloc : listes (pages programmation), motif (regex des liens de fiche), ville/salle par défaut. robots.txt vérifié salle par salle AVANT ajout (2026-08-19) — voir docs/connecteurs/sallesjsonld.md. Salles sondées SANS JSON-LD Event (ne pas ajouter sans re-sonder) : palaismontcalm.ca, lediamant.ca, imperialbell.com, mtelus.com, clubsoda.ca, theatrecorona.ca (evenko, déjà couvert). Auteur : Simon-Pierre Boucher — contact@spboucher.ai", | |
| 3 | + "salles": [ | |
| 4 | + { | |
| 5 | + "id": "grandtheatre", | |
| 6 | + "nom": "Grand Théâtre de Québec", | |
| 7 | + "listes": ["https://grandtheatre.qc.ca/programmation"], | |
| 8 | + "motif": "grandtheatre\\.qc\\.ca/programmation/[a-z0-9-]{3,}", | |
| 9 | + "salle": "Grand Théâtre de Québec", | |
| 10 | + "ville": "Québec", | |
| 11 | + "robots": "Allow: / (vérifié 2026-08-19)" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "id": "trident", | |
| 15 | + "nom": "Théâtre du Trident", | |
| 16 | + "listes": ["https://www.letrident.com/spectacles/"], | |
| 17 | + "motif": "letrident\\.com/piece/[a-z0-9-]{3,}", | |
| 18 | + "salle": "Théâtre du Trident", | |
| 19 | + "ville": "Québec", | |
| 20 | + "robots": "Disallow: (vide — tout permis, vérifié 2026-08-19)" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "id": "capitole", | |
| 24 | + "nom": "Théâtre Capitole (Gestev)", | |
| 25 | + "listes": ["https://theatrecapitole.com/calendrier-evenements/"], | |
| 26 | + "motif": "(theatrecapitole|gestev)\\.com/calendrier-evenements/[a-z0-9-]{3,}", | |
| 27 | + "salle": "Théâtre Capitole", | |
| 28 | + "ville": "Québec", | |
| 29 | + "robots": "Disallow: /cpresources/ /vendor/ seulement (vérifié 2026-08-19)" | |
| 30 | + } | |
| 31 | + ] | |
| 32 | +} | |
added
sortika/connectors/bassaintlaurent.py
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/bassaintlaurent.py : Tourisme Bas-Saint-Laurent — événements (ATR) | |
| 5 | +# Source : https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/ — | |
| 6 | +# WordPress (Bedrock), liste rendue serveur (~27 fiches à venir, | |
| 7 | +# vérifié 2026-08-19). | |
| 8 | +# Extraction: cartes .card--type-event (lien, plage de dates FR, titre, | |
| 9 | +# municipalité, image) + fiches détail incrémentales (cache | |
| 10 | +# data/bassaintlaurent_cache.json, BSL_FICHE_MAX/sync, re-visite | |
| 11 | +# 7 j) : og:description + GPS exact (coordonnées de la carte | |
| 12 | +# embarquées dans la fiche). | |
| 13 | +# Accès : pages publiques de l'ATR, rendu serveur — GET throttlés 0,8 s, | |
| 14 | +# identification honnête, lien vers la fiche originale. | |
| 15 | +# Décision : le Bas-Saint-Laurent n'avait que 168 fiches au baseline — | |
| 16 | +# équilibre régional (04-SOURCES-CANDIDATES §A). | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import os | |
| 21 | +import re | |
| 22 | +import time | |
| 23 | + | |
| 24 | +from ..normalize import clean_text, parse_date_range_fr | |
| 25 | +from ..schema import Event | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://www.bassaintlaurent.ca" | |
| 29 | +LIST_URL = BASE + "/fr/quoi-faire/evenements/" | |
| 30 | +FICHE_MAX = int(os.environ.get("BSL_FICHE_MAX", "15")) | |
| 31 | +REFRESH_AFTER = 7 * 86400 | |
| 32 | + | |
| 33 | +_CARD_RE = re.compile( | |
| 34 | + r'<a href="(https://www\.bassaintlaurent\.ca/fr/quoi-faire/evenements/' | |
| 35 | + r'[^"]+)"[^>]*class="card-location card--type-event">(.*?)</a>', re.S) | |
| 36 | +_DATE_RE = re.compile(r'card-location__dates">([^<]+)<') | |
| 37 | +_TITLE_RE = re.compile(r'card-location__title">([^<]+)<') | |
| 38 | +_CITY_RE = re.compile(r'card-location__city">(?:<[^>]+>)*([^<]+)<') | |
| 39 | +_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"') | |
| 40 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 41 | +_GPS_RE = re.compile( | |
| 42 | + r'"lat(?:itude)?"\s*[:=]\s*"?(-?\d{2}\.\d+)"?\s*,\s*' | |
| 43 | + r'"(?:lng|lon|longitude)"\s*[:=]\s*"?(-?\d{2}\.\d+)"?') | |
| 44 | + | |
| 45 | + | |
| 46 | +class BasSaintLaurentConnector(BaseConnector): | |
| 47 | + source_id = "bassaintlaurent" | |
| 48 | + request_delay = 0.8 | |
| 49 | + | |
| 50 | + def _parse_fiche(self, html: str) -> dict: | |
| 51 | + m = _OG_DESC_RE.search(html) | |
| 52 | + gps = _GPS_RE.search(html) | |
| 53 | + return { | |
| 54 | + "desc": clean_text(m.group(1)) if m else "", | |
| 55 | + "lat": float(gps.group(1)) if gps else None, | |
| 56 | + "lng": float(gps.group(2)) if gps else None, | |
| 57 | + } | |
| 58 | + | |
| 59 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 60 | + cache = self.load_cache() | |
| 61 | + now = time.time() | |
| 62 | + current = {r["url"] for r in rows} | |
| 63 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 64 | + to_fetch = sorted( | |
| 65 | + (u for u in current | |
| 66 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 67 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 68 | + | |
| 69 | + def worker(url, session): | |
| 70 | + return self._parse_fiche(session.get(url, timeout=25).text) | |
| 71 | + | |
| 72 | + for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items(): | |
| 73 | + cache[url] = {"ts": now, **(det or {"desc": "", "lat": None, "lng": None})} | |
| 74 | + self.save_cache(cache) | |
| 75 | + for r in rows: | |
| 76 | + det = cache.get(r["url"]) or {} | |
| 77 | + r["description"] = det.get("desc", "") | |
| 78 | + r["lat"], r["lng"] = det.get("lat"), det.get("lng") | |
| 79 | + | |
| 80 | + def fetch(self) -> list[Event]: | |
| 81 | + html = self.get(LIST_URL).text | |
| 82 | + rows: list[dict] = [] | |
| 83 | + for m in _CARD_RE.finditer(html): | |
| 84 | + url, card = m.group(1), m.group(2) | |
| 85 | + title = _TITLE_RE.search(card) | |
| 86 | + if not title: | |
| 87 | + continue | |
| 88 | + date_raw = _DATE_RE.search(card) | |
| 89 | + start, end = parse_date_range_fr( | |
| 90 | + date_raw.group(1) if date_raw else "") | |
| 91 | + city = _CITY_RE.search(card) | |
| 92 | + img = _IMG_RE.search(card) | |
| 93 | + rows.append({ | |
| 94 | + "external_id": url.rstrip("/").split("/")[-1], | |
| 95 | + "url": url, | |
| 96 | + "title": clean_text(title.group(1)), | |
| 97 | + "city": clean_text(city.group(1)) if city else "", | |
| 98 | + "start_date": start, "end_date": end, | |
| 99 | + "image": img.group(1) if img else "", | |
| 100 | + }) | |
| 101 | + self._enrich_fiches(rows) | |
| 102 | + | |
| 103 | + events: list[Event] = [] | |
| 104 | + seen: set[str] = set() | |
| 105 | + for r in rows: | |
| 106 | + if r["external_id"] in seen: | |
| 107 | + continue | |
| 108 | + seen.add(r["external_id"]) | |
| 109 | + events.append(Event( | |
| 110 | + source=self.source_id, | |
| 111 | + external_id=r["external_id"], | |
| 112 | + url=r["url"], | |
| 113 | + title=r["title"], | |
| 114 | + description=r.get("description", ""), | |
| 115 | + raw_categories=[r["title"]], | |
| 116 | + city=r["city"], | |
| 117 | + tourist_region="Bas-Saint-Laurent", | |
| 118 | + lat=r.get("lat"), lng=r.get("lng"), | |
| 119 | + start_date=r["start_date"], | |
| 120 | + end_date=r["end_date"], | |
| 121 | + image=r["image"], | |
| 122 | + )) | |
| 123 | + return events | |
added
sortika/connectors/lanaudiere.py
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lanaudiere.py : Tourisme Lanaudière — événements (ATR, Phase 3) | |
| 5 | +# Source : https://lanaudiere.ca/fr/evenements-lanaudiere — CMS Umbraco, | |
| 6 | +# médias Tourinsoft (mto.media.tourinsoft.eu — même gisement que | |
| 7 | +# le SIT Québec, recoupement géré par dedup_key). La liste rendue | |
| 8 | +# serveur ne montre que ~10 cartes (le reste charge en JS) → on | |
| 9 | +# passe par le SITEMAP officiel (robots.txt), qui publie TOUTES | |
| 10 | +# les fiches /fr/evenements-lanaudiere/<slug>/ (~44 à venir, | |
| 11 | +# vérifié 2026-08-19). | |
| 12 | +# Extraction: sitemap.xml (1 GET) → fiches détail incrémentales (cache | |
| 13 | +# data/lanaudiere_cache.json, LAN_FICHE_MAX/sync, re-visite 7 j) : | |
| 14 | +# en-tête structuré member-title-1-1 (titre, plage de dates FR | |
| 15 | +# « Du 17 au 26 juillet 2026 », municipalité) + og:image + | |
| 16 | +# og:description. Pas d'heure ni tarif structurés → jamais inventés. | |
| 17 | +# Accès : robots.txt : « Disallow: /*? » (aucune URL à paramètres | |
| 18 | +# requêtée) + « Crawl-delay: 2 » → GET séquentiels 2,1 s. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import os | |
| 23 | +import re | |
| 24 | +import time | |
| 25 | + | |
| 26 | +from ..normalize import clean_text, parse_date_range_fr | |
| 27 | +from ..schema import Event | |
| 28 | +from .base import BaseConnector | |
| 29 | + | |
| 30 | +BASE = "https://lanaudiere.ca" | |
| 31 | +SITEMAP_URL = BASE + "/sitemap.xml" | |
| 32 | +FICHE_MAX = int(os.environ.get("LAN_FICHE_MAX", "10")) | |
| 33 | +REFRESH_AFTER = 7 * 86400 | |
| 34 | + | |
| 35 | +_SITEMAP_RE = re.compile( | |
| 36 | + r"<loc>\s*(https://lanaudiere\.ca/fr/evenements-lanaudiere/" | |
| 37 | + r"[a-z0-9-]+/?)\s*</loc>") | |
| 38 | +_TITLE_RE = re.compile(r'member-title-1-1__title">\s*([^<]+?)\s*<') | |
| 39 | +_DATE_RE = re.compile(r'member-title-1-1__subtitle">\s*([^<]+?)\s*<') | |
| 40 | +_CITY_RE = re.compile(r'member-title-1-1__location"><span>([^<]+)<') | |
| 41 | +_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') | |
| 42 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 43 | + | |
| 44 | + | |
| 45 | +class LanaudiereConnector(BaseConnector): | |
| 46 | + source_id = "lanaudiere" | |
| 47 | + request_delay = 2.1 # Crawl-delay: 2 (robots.txt) | |
| 48 | + | |
| 49 | + def _parse_fiche(self, url: str, html: str) -> dict | None: | |
| 50 | + title = _TITLE_RE.search(html) | |
| 51 | + if not title: | |
| 52 | + return None | |
| 53 | + date_raw = _DATE_RE.search(html) | |
| 54 | + start, end = parse_date_range_fr( | |
| 55 | + date_raw.group(1) if date_raw else "") | |
| 56 | + city = _CITY_RE.search(html) | |
| 57 | + img = _OG_IMG_RE.search(html) | |
| 58 | + desc = _OG_DESC_RE.search(html) | |
| 59 | + return { | |
| 60 | + "external_id": url.rstrip("/").split("/")[-1], | |
| 61 | + "url": url, | |
| 62 | + "title": clean_text(title.group(1)), | |
| 63 | + "description": clean_text(desc.group(1)) if desc else "", | |
| 64 | + "city": clean_text(city.group(1)) if city else "", | |
| 65 | + "start_date": start, "end_date": end, | |
| 66 | + "image": clean_text(img.group(1)) if img else "", | |
| 67 | + } | |
| 68 | + | |
| 69 | + def fetch(self) -> list[Event]: | |
| 70 | + xml = self.get(SITEMAP_URL).text | |
| 71 | + urls = sorted({u.rstrip("/") + "/" for u in _SITEMAP_RE.findall(xml) | |
| 72 | + if not u.rstrip("/").endswith("evenements-lanaudiere")}) | |
| 73 | + cache = self.load_cache() | |
| 74 | + now = time.time() | |
| 75 | + current = set(urls) | |
| 76 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 77 | + to_fetch = sorted( | |
| 78 | + (u for u in urls | |
| 79 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 80 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 81 | + for url in to_fetch: # séquentiel : Crawl-delay respecté | |
| 82 | + try: | |
| 83 | + row = self._parse_fiche(url, self.get(url).text) | |
| 84 | + except Exception: | |
| 85 | + row = None | |
| 86 | + if row is None: | |
| 87 | + print(f"[sorti-ka] lanaudiere : fiche ignorée {url}") | |
| 88 | + cache[url] = {"ts": now, "row": None} | |
| 89 | + continue | |
| 90 | + cache[url] = {"ts": now, "row": row} | |
| 91 | + self.save_cache(cache) | |
| 92 | + | |
| 93 | + events: list[Event] = [] | |
| 94 | + seen: set[str] = set() | |
| 95 | + for entry in cache.values(): | |
| 96 | + r = entry.get("row") | |
| 97 | + if not r or r["external_id"] in seen: | |
| 98 | + continue | |
| 99 | + seen.add(r["external_id"]) | |
| 100 | + events.append(Event( | |
| 101 | + source=self.source_id, | |
| 102 | + external_id=r["external_id"], | |
| 103 | + url=r["url"], | |
| 104 | + title=r["title"], | |
| 105 | + description=r.get("description", ""), | |
| 106 | + raw_categories=[r["title"]], | |
| 107 | + city=r["city"], | |
| 108 | + tourist_region="Lanaudière", | |
| 109 | + start_date=r["start_date"], | |
| 110 | + end_date=r["end_date"], | |
| 111 | + image=r["image"], | |
| 112 | + )) | |
| 113 | + return events | |
added
sortika/connectors/lavalweb.py
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lavalweb.py : Ville de Laval — calendrier VIVANT laval.ca (Phase 3) | |
| 5 | +# Remplace la source « laval » (jeu Données Québec MORT À LA SOURCE depuis | |
| 6 | +# 2025-12, consigné Phase 2). L'ancien connecteur reste branché (réveil | |
| 7 | +# automatique si la Ville republie) ; le recoupement éventuel est géré par | |
| 8 | +# dedup_key. | |
| 9 | +# Source : https://www.laval.ca/calendrier-activites/ — WordPress, listes | |
| 10 | +# rendues serveur, pagination ?findstr[group]=events&findstr[page]=N | |
| 11 | +# (~58 pages × 8 cartes ≈ 460 activités à venir, vérifié 2026-08-19). | |
| 12 | +# Extraction: cartes .event-item (date FR « 18 août 2026 | 18 h », titre, | |
| 13 | +# lieu, lien, image lazy) + fiches détail incrémentales (cache | |
| 14 | +# data/lavalweb_cache.json, LAVALWEB_FICHE_MAX/sync, re-visite | |
| 15 | +# 7 j) : og:description. GPS via annuaire de salles/géocodeur. | |
| 16 | +# Accès : robots.txt laval.ca : « Disallow: /wp-admin/ » seulement — les | |
| 17 | +# pages calendrier sont libres. GET throttlés 0,5 s, UA honnête. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_date_range_fr, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://www.laval.ca" | |
| 30 | +LIST_URL = BASE + "/calendrier-activites/?findstr%5Bgroup%5D=events&findstr%5Bpage%5D={page}" | |
| 31 | +PAGES_MAX = int(os.environ.get("LAVALWEB_PAGES_MAX", "60")) | |
| 32 | +FICHE_MAX = int(os.environ.get("LAVALWEB_FICHE_MAX", "30")) | |
| 33 | +REFRESH_AFTER = 7 * 86400 | |
| 34 | + | |
| 35 | +_CARD_RE = re.compile(r'class="event-item">(.*?)</a>', re.S) | |
| 36 | +_LINK_RE = re.compile(r'href="(/calendrier-activites/[^"?#]+)"') | |
| 37 | +_DATE_RE = re.compile(r'event-item__date">([^<]+)<') | |
| 38 | +_TITLE_RE = re.compile(r'event-item__title"><span>([^<]+)<') | |
| 39 | +_VENUE_RE = re.compile(r'event-item__localisation">\s*<span>([^<]+)<') | |
| 40 | +_IMG_RE = re.compile(r'data-lazy-src="(https://www\.laval\.ca/wp-content/[^"]+)"') | |
| 41 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 42 | + | |
| 43 | + | |
| 44 | +class LavalWebConnector(BaseConnector): | |
| 45 | + source_id = "lavalweb" | |
| 46 | + request_delay = 0.5 | |
| 47 | + | |
| 48 | + def _parse_page(self, html: str) -> list[dict]: | |
| 49 | + rows = [] | |
| 50 | + for m in _CARD_RE.finditer(html): | |
| 51 | + card = m.group(1) | |
| 52 | + link = _LINK_RE.search(card) | |
| 53 | + title = _TITLE_RE.search(card) | |
| 54 | + if not link or not title: | |
| 55 | + continue | |
| 56 | + date_raw = _DATE_RE.search(card) | |
| 57 | + # « 18 août 2026 | 18 h » (séance) ou « Du 4 juin au 22 août 2026 » | |
| 58 | + start = end = start_time = None | |
| 59 | + if date_raw: | |
| 60 | + txt = clean_text(date_raw.group(1)) | |
| 61 | + parts = txt.split("|") | |
| 62 | + start, end = parse_date_range_fr(parts[0]) | |
| 63 | + if len(parts) > 1: | |
| 64 | + start_time = parse_time(parts[1]) | |
| 65 | + venue = _VENUE_RE.search(card) | |
| 66 | + img = _IMG_RE.search(card) | |
| 67 | + slug = link.group(1).strip("/").split("/")[-1] | |
| 68 | + rows.append({ | |
| 69 | + "external_id": slug, | |
| 70 | + "url": BASE + link.group(1), | |
| 71 | + "title": clean_text(title.group(1)), | |
| 72 | + "venue": clean_text(venue.group(1)) if venue else "", | |
| 73 | + "start_date": start, "end_date": end, "start_time": start_time, | |
| 74 | + "image": img.group(1) if img else "", | |
| 75 | + }) | |
| 76 | + return rows | |
| 77 | + | |
| 78 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 79 | + """og:description des fiches — incrémental, plafonné, re-visite 7 j.""" | |
| 80 | + cache = self.load_cache() | |
| 81 | + now = time.time() | |
| 82 | + current = {r["url"] for r in rows} | |
| 83 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 84 | + to_fetch = sorted( | |
| 85 | + (u for u in current | |
| 86 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 87 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 88 | + | |
| 89 | + def worker(url, session): | |
| 90 | + m = _OG_DESC_RE.search(session.get(url, timeout=20).text) | |
| 91 | + return clean_text(m.group(1)) if m else "" | |
| 92 | + | |
| 93 | + for url, desc in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 94 | + cache[url] = {"ts": now, "desc": desc or ""} | |
| 95 | + self.save_cache(cache) | |
| 96 | + for r in rows: | |
| 97 | + r["description"] = (cache.get(r["url"]) or {}).get("desc", "") | |
| 98 | + | |
| 99 | + def fetch(self) -> list[Event]: | |
| 100 | + rows: list[dict] = [] | |
| 101 | + for page in range(1, PAGES_MAX + 1): | |
| 102 | + html = self.get(LIST_URL.format(page=page)).text | |
| 103 | + batch = self._parse_page(html) | |
| 104 | + if not batch: | |
| 105 | + break | |
| 106 | + rows.extend(batch) | |
| 107 | + self._enrich_fiches(rows) | |
| 108 | + | |
| 109 | + events: list[Event] = [] | |
| 110 | + seen: set[str] = set() | |
| 111 | + for r in rows: | |
| 112 | + if r["external_id"] in seen: | |
| 113 | + continue | |
| 114 | + seen.add(r["external_id"]) | |
| 115 | + events.append(Event( | |
| 116 | + source=self.source_id, | |
| 117 | + external_id=r["external_id"], | |
| 118 | + url=r["url"], | |
| 119 | + title=r["title"], | |
| 120 | + description=r.get("description", ""), | |
| 121 | + raw_categories=[r["title"]], | |
| 122 | + venue=r["venue"], | |
| 123 | + city="Laval", | |
| 124 | + region="Laval", | |
| 125 | + start_date=r["start_date"], | |
| 126 | + start_time=r["start_time"], | |
| 127 | + end_date=r["end_date"], | |
| 128 | + image=r["image"], | |
| 129 | + organizer="Ville de Laval", | |
| 130 | + )) | |
| 131 | + return events | |
added
sortika/connectors/lhjmq.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lhjmq.py : LHJMQ — matchs des 12 équipes québécoises (Phase 3) | |
| 5 | +# Source : API HockeyTech « modulekit » (lscluster.hockeytech.com) — le | |
| 6 | +# flux JSON public qui motorise le site officiel theqmjhl.ca | |
| 7 | +# (clé publique embarquée dans les sites de la ligue, largement | |
| 8 | +# documentée). 2 GET/sync : saisons + calendrier de la saison | |
| 9 | +# courante (~580 matchs, dont ~384 à domicile au Québec, | |
| 10 | +# vérifié 2026-08-19 pour 2026-27). | |
| 11 | +# Extraction: date + heure locale exactes, aréna (venue_name), équipes, | |
| 12 | +# statut. Seuls les matchs À DOMICILE au Québec sont publiés | |
| 13 | +# (Chicoutimi, Baie-Comeau, Victoriaville, Blainville-Boisbriand, | |
| 14 | +# Sherbrooke, Gatineau, Québec, Rouyn-Noranda, Rimouski, | |
| 15 | +# Val-d'Or, Shawinigan, Drummondville). Prix non publié par la | |
| 16 | +# ligue → jamais inventé. Lien = fiche de match officielle. | |
| 17 | +# Accès : usage index + lien vers theqmjhl.ca ; GET throttlés, | |
| 18 | +# identification honnête. Couverture régionale forte | |
| 19 | +# (Abitibi, Côte-Nord, Bas-Saint-Laurent, Saguenay…). | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +from datetime import date | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +FEED = ("https://lscluster.hockeytech.com/feed/?feed=modulekit&view={view}" | |
| 30 | + "&key=f322673b6bcae299&fmt=json&client_code=lhjmq&lang=fr{extra}") | |
| 31 | +GAME_URL = "https://theqmjhl.ca/gamecentre/{gid}" | |
| 32 | + | |
| 33 | +# villes des équipes QC → la ville affichée sur la fiche Sorti-Ka | |
| 34 | +QC_TEAM_CITIES = { | |
| 35 | + "Chicoutimi": "Saguenay", "Baie-Comeau": "Baie-Comeau", | |
| 36 | + "Victoriaville": "Victoriaville", "Blainville-Boisbriand": "Boisbriand", | |
| 37 | + "Sherbrooke": "Sherbrooke", "Gatineau": "Gatineau", "Québec": "Québec", | |
| 38 | + "Rouyn-Noranda": "Rouyn-Noranda", "Rimouski": "Rimouski", | |
| 39 | + "Val-d'Or": "Val-d'Or", "Shawinigan": "Shawinigan", | |
| 40 | + "Drummondville": "Drummondville", | |
| 41 | +} | |
| 42 | + | |
| 43 | + | |
| 44 | +def _label(team: dict, side: str) -> str: | |
| 45 | + """« Remparts de Québec » à partir de city/nickname ; repli sur le nom brut.""" | |
| 46 | + nick = clean_text(team.get(f"{side}_team_nickname") or "") | |
| 47 | + city = clean_text(team.get(f"{side}_team_city") or "") | |
| 48 | + if nick and city: | |
| 49 | + return f"{nick} ({city})" | |
| 50 | + return clean_text(team.get(f"{side}_team_name") or "") | |
| 51 | + | |
| 52 | + | |
| 53 | +class LhjmqConnector(BaseConnector): | |
| 54 | + source_id = "lhjmq" | |
| 55 | + request_delay = 1.0 | |
| 56 | + | |
| 57 | + def _current_seasons(self) -> list[str]: | |
| 58 | + """Saisons non terminées (régulière + séries), les plus récentes.""" | |
| 59 | + data = self.get_json(FEED.format(view="seasons", extra="")) | |
| 60 | + seasons = (data.get("SiteKit") or {}).get("Seasons") or [] | |
| 61 | + today = date.today().isoformat() | |
| 62 | + keep = [s["season_id"] for s in seasons | |
| 63 | + if (s.get("end_date") or "") >= today] | |
| 64 | + return sorted(keep, key=int)[-2:] # au plus 2 (régulière + séries) | |
| 65 | + | |
| 66 | + def fetch(self) -> list[Event]: | |
| 67 | + events: list[Event] = [] | |
| 68 | + seen: set[str] = set() | |
| 69 | + for sid in self._current_seasons(): | |
| 70 | + data = self.get_json(FEED.format( | |
| 71 | + view="schedule", extra=f"&season_id={sid}")) | |
| 72 | + for g in (data.get("SiteKit") or {}).get("Schedule") or []: | |
| 73 | + home_city = clean_text(g.get("home_team_city") or "") | |
| 74 | + if home_city not in QC_TEAM_CITIES: | |
| 75 | + continue | |
| 76 | + gid = str(g.get("game_id") or "") | |
| 77 | + d = g.get("date_played") or "" | |
| 78 | + if not gid or not d or gid in seen: | |
| 79 | + continue | |
| 80 | + seen.add(gid) | |
| 81 | + home, vis = _label(g, "home"), _label(g, "visiting") | |
| 82 | + heure = parse_time(g.get("schedule_time") or "") | |
| 83 | + venue = clean_text(g.get("venue_name") or "") | |
| 84 | + status = "" | |
| 85 | + if str(g.get("game_status") or "").lower().startswith( | |
| 86 | + ("cancel", "annul")): | |
| 87 | + status = "cancelled" | |
| 88 | + events.append(Event( | |
| 89 | + source=self.source_id, | |
| 90 | + external_id=gid, | |
| 91 | + url=GAME_URL.format(gid=gid), | |
| 92 | + title=f"LHJMQ : {vis} c. {home}", | |
| 93 | + description=(f"Match de la Ligue de hockey junior " | |
| 94 | + f"Maritimes Québec (LHJMQ) — {vis} en " | |
| 95 | + f"visite chez {home}, {venue}."), | |
| 96 | + raw_categories=["hockey", "sport"], | |
| 97 | + venue=venue, | |
| 98 | + city=QC_TEAM_CITIES[home_city], | |
| 99 | + start_date=d, | |
| 100 | + start_time=heure, | |
| 101 | + status=status, | |
| 102 | + organizer="LHJMQ", | |
| 103 | + )) | |
| 104 | + return events | |
added
sortika/connectors/outaouais.py
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/outaouais.py : Tourisme Outaouais — calendrier des événements (ATR) | |
| 5 | +# Source : https://tourismeoutaouais.com/calendrier-des-evenements — site | |
| 6 | +# Astro : la page embarque TOUT le calendrier dans les props | |
| 7 | +# sérialisées de l'îlot <astro-island CalendrierDesEvenements> | |
| 8 | +# (162 événements dont 25 à venir, vérifié 2026-08-19) → | |
| 9 | +# UNE SEULE requête par sync. | |
| 10 | +# Extraction: props JSON (format Svelte [type, valeur] déballé) : id, titre, | |
| 11 | +# slug/url (interne /attraits/<slug> ou site officiel externe), | |
| 12 | +# municipalité, territoire, photo (CDN admin.tourismeoutaouais.com), | |
| 13 | +# startDate/endDate ISO. Pas d'heure ni de tarif publiés → jamais | |
| 14 | +# inventés. GPS via annuaire de salles/géocodeur central. | |
| 15 | +# Accès : robots.txt « Allow: / » — GET direct, identification honnête. | |
| 16 | +# Décision : comble l'échec Gatineau (site 100 % JS, consigné 2026-08-18) — | |
| 17 | +# l'Outaouais n'avait que 337 fiches au baseline Phase 3. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import html as _html | |
| 22 | +import json | |
| 23 | +import re | |
| 24 | + | |
| 25 | +from ..normalize import clean_text | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://tourismeoutaouais.com" | |
| 30 | +ADMIN = "https://admin.tourismeoutaouais.com" | |
| 31 | +LIST_URL = BASE + "/calendrier-des-evenements" | |
| 32 | + | |
| 33 | +def _unwrap(v): | |
| 34 | + """Déballe le format de sérialisation Astro/Svelte : [0, valeur] scalaire, | |
| 35 | + [1, [...]] liste — récursif, prudent.""" | |
| 36 | + if isinstance(v, list) and len(v) == 2 and v[0] in (0, 1): | |
| 37 | + return _unwrap(v[1]) | |
| 38 | + if isinstance(v, list): | |
| 39 | + return [_unwrap(x) for x in v] | |
| 40 | + if isinstance(v, dict): | |
| 41 | + return {k: _unwrap(x) for k, x in v.items()} | |
| 42 | + return v | |
| 43 | + | |
| 44 | + | |
| 45 | +def _extract_events(page_html: str) -> list[dict]: | |
| 46 | + """Événements des props de l'îlot calendrier ([] si structure changée).""" | |
| 47 | + idx = page_html.find("CalendrierDesEvenements") | |
| 48 | + if idx < 0: | |
| 49 | + return [] | |
| 50 | + m = re.search(r'props="([^"]*)"', page_html[max(0, idx - 2000):]) | |
| 51 | + if not m: | |
| 52 | + return [] | |
| 53 | + try: | |
| 54 | + data = _unwrap(json.loads(_html.unescape(m.group(1)))) | |
| 55 | + except Exception: | |
| 56 | + return [] | |
| 57 | + events = data.get("events") if isinstance(data, dict) else None | |
| 58 | + return [e for e in (events or []) if isinstance(e, dict)] | |
| 59 | + | |
| 60 | + | |
| 61 | +class OutaouaisConnector(BaseConnector): | |
| 62 | + source_id = "outaouais" | |
| 63 | + request_delay = 0.8 | |
| 64 | + | |
| 65 | + def fetch(self) -> list[Event]: | |
| 66 | + html = self.get(LIST_URL).text | |
| 67 | + events: list[Event] = [] | |
| 68 | + seen: set[str] = set() | |
| 69 | + for e in _extract_events(html): | |
| 70 | + ext_id = str(e.get("id") or "") | |
| 71 | + title = clean_text(e.get("title") or "") | |
| 72 | + if not ext_id or not title or ext_id in seen: | |
| 73 | + continue | |
| 74 | + seen.add(ext_id) | |
| 75 | + url = e.get("url") or "" | |
| 76 | + if url.startswith("/"): | |
| 77 | + url = BASE + url | |
| 78 | + if not url.startswith("http"): | |
| 79 | + url = LIST_URL | |
| 80 | + photo = e.get("photo") or "" | |
| 81 | + if photo.startswith("/"): | |
| 82 | + photo = ADMIN + photo | |
| 83 | + muni_slug = e.get("municipality") or "" | |
| 84 | + if muni_slug.startswith("municipalite-exterieure"): | |
| 85 | + muni_slug = "" # « extérieur de la région » ≠ ville | |
| 86 | + muni = clean_text(muni_slug.replace("-", " ")).title() | |
| 87 | + events.append(Event( | |
| 88 | + source=self.source_id, | |
| 89 | + external_id=ext_id, | |
| 90 | + url=url, | |
| 91 | + title=title, | |
| 92 | + raw_categories=[title, clean_text(e.get("territory") or "")], | |
| 93 | + city=muni, | |
| 94 | + tourist_region="Outaouais", | |
| 95 | + start_date=e.get("startDate"), | |
| 96 | + end_date=e.get("endDate") or e.get("startDate"), | |
| 97 | + image=photo, | |
| 98 | + )) | |
| 99 | + return events | |
added
sortika/connectors/placedesarts.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/placedesarts.py : Place des Arts — calendrier multi-salles (Phase 3) | |
| 5 | +# Source : https://www.placedesarts.com/programmation (Craft CMS) — | |
| 6 | +# ~97 spectacles à l'affiche sur les 6 salles (Wilfrid-Pelletier, | |
| 7 | +# Maison symphonique, Théâtre Maisonneuve, Jean-Duceppe, | |
| 8 | +# Cinquième Salle, Claude-Léveillée), vérifié 2026-08-19. | |
| 9 | +# Extraction: liste /programmation (1 GET) → fiches /evenement/<slug> | |
| 10 | +# incrémentales (cache data/placedesarts_cache.json, | |
| 11 | +# PDA_FICHE_MAX/sync, re-visite 7 j). Chaque fiche embarque : | |
| 12 | +# · #eventbannerdata (JSON) : titre, url, image, plage de dates ; | |
| 13 | +# · #eventGTM (JSON) : salle(s), producteur, discipline ; | |
| 14 | +# · barre latérale #sb-performances : CHAQUE représentation avec | |
| 15 | +# heure (« 19h30 »), date FR complète et PRIX RÉELS | |
| 16 | +# (« 33 $ à 97,50 $ ») → une fiche Sorti-Ka PAR séance | |
| 17 | +# (dedup_key par heure, patron Phase 2). | |
| 18 | +# Accès : robots.txt n'interdit que /cpresources/ /vendor/ /cache/ — | |
| 19 | +# GET throttlés 0,8 s, identification honnête, lien billetterie | |
| 20 | +# officiel. Recoupement lavitrine/evenko géré par dedup_key. | |
| 21 | +# ----------------------------------------------------------------------------- | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import json | |
| 25 | +import os | |
| 26 | +import re | |
| 27 | +import time | |
| 28 | + | |
| 29 | +from ..normalize import clean_text, parse_date_fr, parse_date_range_fr, parse_time | |
| 30 | +from ..schema import Event | |
| 31 | +from .base import BaseConnector | |
| 32 | + | |
| 33 | +BASE = "https://www.placedesarts.com" | |
| 34 | +LIST_URL = BASE + "/programmation" | |
| 35 | +FICHE_MAX = int(os.environ.get("PDA_FICHE_MAX", "25")) | |
| 36 | +REFRESH_AFTER = 7 * 86400 | |
| 37 | + | |
| 38 | +_LINK_RE = re.compile(r'href="(?:https://www\.placedesarts\.com)?(/evenement/[a-z0-9-]+)"') | |
| 39 | +_BANNER_RE = re.compile(r'id="eventbannerdata">\s*(\{.*?\})\s*</script>', re.S) | |
| 40 | +_GTM_RE = re.compile(r'id="eventGTM">\s*(\{.*?\})\s*</script>', re.S) | |
| 41 | + | |
| 42 | + | |
| 43 | +class PlaceDesArtsConnector(BaseConnector): | |
| 44 | + source_id = "placedesarts" | |
| 45 | + request_delay = 0.8 | |
| 46 | + | |
| 47 | + def _parse_fiche(self, html: str) -> dict | None: | |
| 48 | + m = _BANNER_RE.search(html) | |
| 49 | + if not m: | |
| 50 | + return None | |
| 51 | + try: | |
| 52 | + banner = json.loads(m.group(1)) | |
| 53 | + except Exception: | |
| 54 | + return None | |
| 55 | + gtm = {} | |
| 56 | + mg = _GTM_RE.search(html) | |
| 57 | + if mg: | |
| 58 | + try: | |
| 59 | + gtm = json.loads(mg.group(1)) | |
| 60 | + except Exception: | |
| 61 | + gtm = {} | |
| 62 | + # représentations de la barre latérale (texte : heure, date, prix) | |
| 63 | + i = html.find('id="sb-performances"') | |
| 64 | + perfs = [] | |
| 65 | + if i >= 0: | |
| 66 | + seg = re.sub(r"\s+", " ", html[i:i + 60000]) | |
| 67 | + txt = re.sub(r"<[^>]+>", "|", seg) | |
| 68 | + for pm in re.finditer( | |
| 69 | + r"(\d{1,2}h\d{2})[|\s]+" | |
| 70 | + r"([A-Za-zÀ-ÿ]+ \d{1,2}(?:er)? [a-zà-ÿ]+ \d{4})" | |
| 71 | + r"(?:[|\s]+([^|]*\$[^|]*))?", txt): | |
| 72 | + heure = parse_time(pm.group(1)) | |
| 73 | + date_iso = parse_date_fr(pm.group(2)) | |
| 74 | + if date_iso: | |
| 75 | + perfs.append({"date": date_iso, "time": heure, | |
| 76 | + "prix": clean_text(pm.group(3) or "")}) | |
| 77 | + start, end = parse_date_range_fr(banner.get("dates") or "") | |
| 78 | + return { | |
| 79 | + "title": clean_text(banner.get("title") or ""), | |
| 80 | + "url": banner.get("url") or "", | |
| 81 | + "image": banner.get("mainImage") or "", | |
| 82 | + "start": start, "end": end, | |
| 83 | + "passed": bool(banner.get("hasPassed")), | |
| 84 | + "venue": clean_text((gtm.get("eventPlace") or [""])[0]), | |
| 85 | + "organizer": clean_text(gtm.get("eventProducer") or ""), | |
| 86 | + "cats": [clean_text(c) for c in | |
| 87 | + (gtm.get("eventDiscipline") or []) + | |
| 88 | + (gtm.get("eventSubCategory") or [])], | |
| 89 | + "perfs": perfs, | |
| 90 | + } | |
| 91 | + | |
| 92 | + def fetch(self) -> list[Event]: | |
| 93 | + html = self.get(LIST_URL).text | |
| 94 | + paths = sorted(set(_LINK_RE.findall(html))) | |
| 95 | + cache = self.load_cache() | |
| 96 | + now = time.time() | |
| 97 | + current = set(paths) | |
| 98 | + cache = {p: c for p, c in cache.items() if p in current} | |
| 99 | + to_fetch = sorted( | |
| 100 | + (p for p in paths | |
| 101 | + if p not in cache or now - cache[p].get("ts", 0) > REFRESH_AFTER), | |
| 102 | + key=lambda p: cache.get(p, {}).get("ts", 0))[:FICHE_MAX] | |
| 103 | + | |
| 104 | + def worker(path, session): | |
| 105 | + return self._parse_fiche( | |
| 106 | + session.get(BASE + path, timeout=25).text) | |
| 107 | + | |
| 108 | + for path, row in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 109 | + if row is None: | |
| 110 | + print(f"[sorti-ka] placedesarts : fiche ignorée {path}") | |
| 111 | + continue | |
| 112 | + cache[path] = {"ts": now, "row": row} | |
| 113 | + self.save_cache(cache) | |
| 114 | + | |
| 115 | + events: list[Event] = [] | |
| 116 | + seen: set[str] = set() | |
| 117 | + for path, entry in cache.items(): | |
| 118 | + row = entry.get("row") | |
| 119 | + if not row or not row.get("title") or row.get("passed"): | |
| 120 | + continue | |
| 121 | + slug = path.strip("/").split("/")[-1] | |
| 122 | + base_kwargs = dict( | |
| 123 | + source=self.source_id, | |
| 124 | + url=row["url"] or BASE + path, | |
| 125 | + title=row["title"], | |
| 126 | + raw_categories=row["cats"] or [row["title"]], | |
| 127 | + venue=row["venue"], | |
| 128 | + city="Montréal", | |
| 129 | + region="Montréal", | |
| 130 | + organizer=row["organizer"], | |
| 131 | + image=row["image"], | |
| 132 | + ) | |
| 133 | + if row["perfs"]: | |
| 134 | + for p in row["perfs"]: | |
| 135 | + ext = f"{slug}-{p['date']}-{p['time'] or 'nd'}" | |
| 136 | + if ext in seen: | |
| 137 | + continue | |
| 138 | + seen.add(ext) | |
| 139 | + events.append(Event( | |
| 140 | + external_id=ext, | |
| 141 | + start_date=p["date"], start_time=p["time"], | |
| 142 | + end_date=p["date"], | |
| 143 | + price_label=p["prix"], | |
| 144 | + **base_kwargs)) | |
| 145 | + else: | |
| 146 | + if slug in seen: | |
| 147 | + continue | |
| 148 | + seen.add(slug) | |
| 149 | + events.append(Event( | |
| 150 | + external_id=slug, | |
| 151 | + start_date=row["start"], end_date=row["end"], | |
| 152 | + **base_kwargs)) | |
| 153 | + return events | |
added
sortika/connectors/quebecanimee.py
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/quebecanimee.py : Québec animée — Ville de Québec (Phase 3) | |
| 5 | +# Source : https://animee.ville.quebec.qc.ca/activites/ — portail officiel | |
| 6 | +# des activités gratuites/à faible coût dont la Ville est | |
| 7 | +# promotrice ou partenaire (73 fiches à venir, vérifié 2026-08-19). | |
| 8 | +# Comble l'absence de source municipale/gratuite pour la 2ᵉ ville | |
| 9 | +# du Québec (l'inventaire Capitale-Nationale était ~100 % billetteries). | |
| 10 | +# Extraction: la LISTE porte tout en attributs data- (1 requête) : | |
| 11 | +# data-date-debut / data-date-fin ISO, data-arrondissement, | |
| 12 | +# data-categorie, data-prix (« Gratuit ») + titre/image de la | |
| 13 | +# carte ; fiches détail incrémentales (cache | |
| 14 | +# data/quebecanimee_cache.json, QA_FICHE_MAX/sync, re-visite 7 j) : | |
| 15 | +# description, lieu (nom de salle/parc), heures si publiées. | |
| 16 | +# Accès : site municipal public rendu serveur — GET throttlés 0,8 s, | |
| 17 | +# identification honnête, lien vers la fiche officielle. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://animee.ville.quebec.qc.ca" | |
| 30 | +LIST_URL = BASE + "/activites/" | |
| 31 | +FICHE_MAX = int(os.environ.get("QA_FICHE_MAX", "20")) | |
| 32 | +REFRESH_AFTER = 7 * 86400 | |
| 33 | + | |
| 34 | +_CARD_RE = re.compile( | |
| 35 | + r'<a[^>]+href="(/activites/[a-z0-9-]+/)"[^>]*' | |
| 36 | + r'data-date-debut="([^"]*)"[^>]*data-date-fin="([^"]*)"[^>]*' | |
| 37 | + r'data-arrondissement="([^"]*)"[^>]*data-categorie="([^"]*)"[^>]*' | |
| 38 | + r'data-prix="([^"]*)"[^>]*>(.*?)</a>', re.S) | |
| 39 | +_TITLE_RE = re.compile(r'<h3[^>]*>\s*([^<]+?)\s*</h3>|<h2[^>]*>\s*([^<]+?)\s*</h2>') | |
| 40 | +_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"') | |
| 41 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 42 | +_LIEU_RE = re.compile(r'class="[^"]*lieu[^"]*"[^>]*>\s*(?:<[^>]+>\s*)*([^<]{3,120})') | |
| 43 | +_HEURE_RE = re.compile(r'(\d{1,2}\s*h(?:\s*\d{2})?)') | |
| 44 | + | |
| 45 | + | |
| 46 | +class QuebecAnimeeConnector(BaseConnector): | |
| 47 | + source_id = "quebecanimee" | |
| 48 | + request_delay = 0.8 | |
| 49 | + | |
| 50 | + def _parse_fiche(self, html: str) -> dict: | |
| 51 | + m = _OG_DESC_RE.search(html) | |
| 52 | + lieu = _LIEU_RE.search(html) | |
| 53 | + return { | |
| 54 | + "desc": clean_text(m.group(1)) if m else "", | |
| 55 | + "venue": clean_text(lieu.group(1)) if lieu else "", | |
| 56 | + } | |
| 57 | + | |
| 58 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 59 | + cache = self.load_cache() | |
| 60 | + now = time.time() | |
| 61 | + current = {r["url"] for r in rows} | |
| 62 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 63 | + to_fetch = sorted( | |
| 64 | + (u for u in current | |
| 65 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 66 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 67 | + | |
| 68 | + def worker(url, session): | |
| 69 | + return self._parse_fiche(session.get(url, timeout=20).text) | |
| 70 | + | |
| 71 | + for url, det in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 72 | + cache[url] = {"ts": now, **(det or {"desc": "", "venue": ""})} | |
| 73 | + self.save_cache(cache) | |
| 74 | + for r in rows: | |
| 75 | + det = cache.get(r["url"]) or {} | |
| 76 | + r["description"] = det.get("desc", "") | |
| 77 | + r["venue"] = det.get("venue", "") | |
| 78 | + | |
| 79 | + def fetch(self) -> list[Event]: | |
| 80 | + html = self.get(LIST_URL).text | |
| 81 | + rows: list[dict] = [] | |
| 82 | + for m in _CARD_RE.finditer(html): | |
| 83 | + path, debut, fin, arrond, cats, prix, inner = m.groups() | |
| 84 | + t = _TITLE_RE.search(inner) | |
| 85 | + title = clean_text((t.group(1) or t.group(2)) if t else "") | |
| 86 | + if not title: | |
| 87 | + # repli : dernier texte non vide de la carte | |
| 88 | + texts = [clean_text(x) for x in re.findall(r">([^<>]{4,120})<", inner)] | |
| 89 | + title = texts[-1] if texts else "" | |
| 90 | + if not title: | |
| 91 | + continue | |
| 92 | + img = _IMG_RE.search(inner) | |
| 93 | + slug = path.strip("/").split("/")[-1] | |
| 94 | + # la liste répète une carte PAR occurrence datée d'une même | |
| 95 | + # activité → l'identifiant inclut la date (séances distinctes) | |
| 96 | + rows.append({ | |
| 97 | + "external_id": f"{slug}-{debut or 'nd'}", | |
| 98 | + "url": BASE + path, | |
| 99 | + "title": title, | |
| 100 | + "start_date": debut or None, | |
| 101 | + "end_date": fin or debut or None, | |
| 102 | + "arrondissement": clean_text(arrond), | |
| 103 | + "cats": [c.strip() for c in clean_text(cats).split(",") if c.strip()], | |
| 104 | + "prix": clean_text(prix), | |
| 105 | + "image": img.group(1) if img else "", | |
| 106 | + }) | |
| 107 | + self._enrich_fiches(rows) | |
| 108 | + | |
| 109 | + events: list[Event] = [] | |
| 110 | + seen: set[str] = set() | |
| 111 | + for r in rows: | |
| 112 | + if r["external_id"] in seen: | |
| 113 | + continue | |
| 114 | + seen.add(r["external_id"]) | |
| 115 | + img = r["image"] | |
| 116 | + if img.startswith("/"): | |
| 117 | + img = BASE + img | |
| 118 | + events.append(Event( | |
| 119 | + source=self.source_id, | |
| 120 | + external_id=r["external_id"], | |
| 121 | + url=r["url"], | |
| 122 | + title=r["title"], | |
| 123 | + description=r.get("description", ""), | |
| 124 | + raw_categories=r["cats"] or [r["title"]], | |
| 125 | + venue=r.get("venue", ""), | |
| 126 | + city="Québec", | |
| 127 | + region="Capitale-Nationale", | |
| 128 | + start_date=r["start_date"], | |
| 129 | + end_date=r["end_date"], | |
| 130 | + price_label=r["prix"], | |
| 131 | + image=img, | |
| 132 | + organizer="Ville de Québec", | |
| 133 | + )) | |
| 134 | + return events | |
added
sortika/connectors/saguenaysorties.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/saguenaysorties.py : Sorties Saguenay — portail officiel de la | |
| 5 | +# Ville de Saguenay (Phase 3). Remplace le CSV Données Québec mort (2022). | |
| 6 | +# Source : https://sorties.saguenay.ca — portail événementiel municipal | |
| 7 | +# (motorisé par les données La Vitrine culturelle, médias | |
| 8 | +# lvc-medias.lavitrine.com ; recoupement géré par dedup_key). | |
| 9 | +# Extraction: POST /ajax/plus (offset par pas de 36, ~300-400 séances | |
| 10 | +# horodatées, vérifié 2026-08-19) — chaque <article> = UNE séance : | |
| 11 | +# discipline, date FR, heure « 08 h 30 », titre, « Ville, Salle », | |
| 12 | +# image, data-detail-id. Détails incrémentaux POST /ajax/details | |
| 13 | +# (cache data/saguenaysorties_cache.json, SAG_DETAIL_MAX/sync, | |
| 14 | +# re-visite 7 j) : description + adresse civique. | |
| 15 | +# Accès : portail public municipal, pas de robots.txt publié (page de | |
| 16 | +# maintenance) — volumes modestes, POST throttlés 0,8 s, UA | |
| 17 | +# honnête, lien vers la fiche officielle /festivals?id=<id>. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_date_fr, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://sorties.saguenay.ca" | |
| 30 | +AJAX_LIST = BASE + "/ajax/plus" | |
| 31 | +AJAX_DETAIL = BASE + "/ajax/details" | |
| 32 | +BATCH = 36 | |
| 33 | +BATCH_MAX = int(os.environ.get("SAG_BATCH_MAX", "15")) # ≤ 540 séances | |
| 34 | +DETAIL_MAX = int(os.environ.get("SAG_DETAIL_MAX", "40")) | |
| 35 | +REFRESH_AFTER = 7 * 86400 | |
| 36 | + | |
| 37 | +_DISC_RE = re.compile(r'discipline_name">\s*([^<]+?)\s*</div>', re.S) | |
| 38 | +_IMG_RE = re.compile(r'<img src="([^"]+)"') | |
| 39 | +_DATE_RE = re.compile(r'article-date[^>]*>\s*([^<]+?)\s*</p>', re.S) | |
| 40 | +_TITLE_RE = re.compile(r'data-detail-id="(\d+)"[^>]*>\s*([^<]+?)\s*</a>', re.S) | |
| 41 | +_PLACE_RE = re.compile(r'</h3>\s*<p>\s*(.*?)\s*</p>', re.S) | |
| 42 | +_ADDR_RE = re.compile(r'<br\s*/?>\s*([^<]+?)\s*</p>', re.S) | |
| 43 | + | |
| 44 | + | |
| 45 | +class SaguenaySortiesConnector(BaseConnector): | |
| 46 | + source_id = "saguenaysorties" | |
| 47 | + request_delay = 0.8 | |
| 48 | + | |
| 49 | + def _post(self, url: str, data: dict) -> str: | |
| 50 | + self._throttle() | |
| 51 | + resp = self.session.post(url, data=data, timeout=self.timeout) | |
| 52 | + self._last_request = time.time() | |
| 53 | + resp.raise_for_status() | |
| 54 | + return resp.text | |
| 55 | + | |
| 56 | + def _parse_batch(self, html: str) -> list[dict]: | |
| 57 | + rows = [] | |
| 58 | + for art in html.split("<article")[1:]: | |
| 59 | + m_title = _TITLE_RE.search(art) | |
| 60 | + if not m_title: | |
| 61 | + continue | |
| 62 | + dates = _DATE_RE.findall(art) | |
| 63 | + date_iso = parse_date_fr(dates[0]) if dates else None | |
| 64 | + heure = parse_time(dates[1]) if len(dates) > 1 else None | |
| 65 | + m_place = _PLACE_RE.search(art) | |
| 66 | + city = venue = "" | |
| 67 | + if m_place: | |
| 68 | + place = clean_text(m_place.group(1)) | |
| 69 | + if "," in place: | |
| 70 | + city, venue = (p.strip() for p in place.split(",", 1)) | |
| 71 | + else: | |
| 72 | + city = place | |
| 73 | + # événements multi-lieux : la carte liste TOUS les sites séparés | |
| 74 | + # par des virgules — un « lieu » de 100+ caractères n'est pas une | |
| 75 | + # salle, on ne garde que la ville (jamais un faux GPS de salle) | |
| 76 | + if len(venue) > 100: | |
| 77 | + venue = "" | |
| 78 | + if len(city) > 60: | |
| 79 | + city = "" | |
| 80 | + disc = _DISC_RE.search(art) | |
| 81 | + img = _IMG_RE.search(art) | |
| 82 | + rows.append({ | |
| 83 | + "detail_id": m_title.group(1), | |
| 84 | + "title": clean_text(m_title.group(2)), | |
| 85 | + "date": date_iso, "time": heure, | |
| 86 | + "city": city, "venue": venue, | |
| 87 | + "discipline": clean_text(disc.group(1)) if disc else "", | |
| 88 | + "image": img.group(1) if img else "", | |
| 89 | + }) | |
| 90 | + return rows | |
| 91 | + | |
| 92 | + def _details(self, ids: list[str]) -> dict: | |
| 93 | + """description + adresse par detail_id — incrémental, plafonné.""" | |
| 94 | + cache = self.load_cache() | |
| 95 | + now = time.time() | |
| 96 | + to_fetch = sorted( | |
| 97 | + (i for i in ids | |
| 98 | + if i not in cache or now - cache[i].get("ts", 0) > REFRESH_AFTER), | |
| 99 | + key=lambda i: cache.get(i, {}).get("ts", 0))[:DETAIL_MAX] | |
| 100 | + for did in to_fetch: | |
| 101 | + try: | |
| 102 | + html = self._post(AJAX_DETAIL, {"id": did}) | |
| 103 | + m = re.search(r"</h3>\s*(.*?)\s*<h4", html, re.S) | |
| 104 | + desc = clean_text(m.group(1)) if m else "" | |
| 105 | + m_addr = _ADDR_RE.search(html) | |
| 106 | + addr = clean_text(m_addr.group(1)) if m_addr else "" | |
| 107 | + cache[did] = {"ts": now, "desc": desc, "addr": addr} | |
| 108 | + except Exception: | |
| 109 | + cache[did] = {"ts": now, "desc": "", "addr": ""} | |
| 110 | + self.save_cache(cache) | |
| 111 | + return cache | |
| 112 | + | |
| 113 | + def fetch(self) -> list[Event]: | |
| 114 | + rows: list[dict] = [] | |
| 115 | + for i in range(BATCH_MAX): | |
| 116 | + html = self._post(AJAX_LIST, {"offset": str(i * BATCH), "page": "1"}) | |
| 117 | + batch = self._parse_batch(html) | |
| 118 | + rows.extend(batch) | |
| 119 | + if len(batch) < BATCH // 2: # dernière page (lots partiels vus) | |
| 120 | + break | |
| 121 | + details = self._details(sorted({r["detail_id"] for r in rows})) | |
| 122 | + | |
| 123 | + events: list[Event] = [] | |
| 124 | + seen: set[str] = set() | |
| 125 | + for r in rows: | |
| 126 | + ext = f"{r['detail_id']}-{r['date'] or 'nd'}-{r['time'] or 'nd'}" | |
| 127 | + if ext in seen or not r["title"]: | |
| 128 | + continue | |
| 129 | + seen.add(ext) | |
| 130 | + det = details.get(r["detail_id"]) or {} | |
| 131 | + events.append(Event( | |
| 132 | + source=self.source_id, | |
| 133 | + external_id=ext, | |
| 134 | + url=f"{BASE}/festivals?id={r['detail_id']}", | |
| 135 | + title=r["title"], | |
| 136 | + description=det.get("desc", ""), | |
| 137 | + raw_categories=[r["discipline"]] if r["discipline"] else [], | |
| 138 | + venue=r["venue"], | |
| 139 | + address=det.get("addr", ""), | |
| 140 | + city=r["city"], | |
| 141 | + tourist_region="Saguenay–Lac-Saint-Jean", | |
| 142 | + start_date=r["date"], | |
| 143 | + start_time=r["time"], | |
| 144 | + image=r["image"], | |
| 145 | + )) | |
| 146 | + return events | |
added
sortika/connectors/sallesjsonld.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/sallesjsonld.py : moteur générique « schema.org/Event » (Phase 3) | |
| 5 | +# La LONGUE TRAÎNE des salles de spectacle : beaucoup de sites de salles | |
| 6 | +# embarquent un bloc JSON-LD Event complet (dates horodatées, salle, offres, | |
| 7 | +# statut) sur chaque fiche. Ce connecteur prend une LISTE DE SALLES | |
| 8 | +# configurable (data/salles_jsonld.json, patron stores.json de fabri-ka) : | |
| 9 | +# chaque entrée = page(s) de programmation + motif des liens de fiche + | |
| 10 | +# ville/salle par défaut. Ajouter une salle = 1 bloc de config, 0 code. | |
| 11 | +# Extraction: page liste (1 GET/salle) → fiches incrémentales (cache | |
| 12 | +# data/sallesjsonld_cache.json, SALLES_FICHE_MAX/sync partagé, | |
| 13 | +# re-visite 7 j) → TOUS les blocs JSON-LD @type Event de la | |
| 14 | +# fiche (une fiche multi-dates = plusieurs séances). | |
| 15 | +# Salles d'amorce validées 2026-08-19 (Event JSON-LD réel constaté) : | |
| 16 | +# Grand Théâtre de Québec, Théâtre du Trident, Théâtre Capitole (Gestev). | |
| 17 | +# Accès : robots.txt vérifié PAR SALLE avant l'ajout à la config | |
| 18 | +# (consigné dans docs/connecteurs/sallesjsonld.md) ; GET | |
| 19 | +# throttlés 0,8 s, identification honnête, lien fiche originale. | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import json | |
| 24 | +import os | |
| 25 | +import re | |
| 26 | +import time | |
| 27 | +from pathlib import Path | |
| 28 | +from urllib.parse import urljoin | |
| 29 | + | |
| 30 | +from ..normalize import clean_text | |
| 31 | +from ..schema import Event, normalize_status | |
| 32 | +from .base import BaseConnector | |
| 33 | + | |
| 34 | +CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "salles_jsonld.json" | |
| 35 | +FICHE_MAX = int(os.environ.get("SALLES_FICHE_MAX", "40")) # total, toutes salles | |
| 36 | +REFRESH_AFTER = 7 * 86400 | |
| 37 | + | |
| 38 | +_LD_RE = re.compile(r'<script[^>]*type="application/ld\+json"[^>]*>') | |
| 39 | +_HREF_RE = re.compile(r'href="([^"#]+)"') | |
| 40 | + | |
| 41 | + | |
| 42 | +def ld_events(html: str) -> list[dict]: | |
| 43 | + """Tous les blocs JSON-LD de @type Event d'une page (listes et @graph | |
| 44 | + déballés), tolérant aux JSON malformés voisins.""" | |
| 45 | + out: list[dict] = [] | |
| 46 | + for m in _LD_RE.finditer(html): | |
| 47 | + try: | |
| 48 | + d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip()) | |
| 49 | + except Exception: | |
| 50 | + continue | |
| 51 | + docs = d if isinstance(d, list) else ( | |
| 52 | + d.get("@graph") if isinstance(d, dict) and "@graph" in d else [d]) | |
| 53 | + for doc in docs or []: | |
| 54 | + if isinstance(doc, dict) and "Event" in str(doc.get("@type", "")): | |
| 55 | + out.append(doc) | |
| 56 | + return out | |
| 57 | + | |
| 58 | + | |
| 59 | +def _first(v): | |
| 60 | + if isinstance(v, list): | |
| 61 | + return v[0] if v else None | |
| 62 | + return v | |
| 63 | + | |
| 64 | + | |
| 65 | +def _local_dt(iso: str) -> tuple[str | None, str | None]: | |
| 66 | + """Horodatage JSON-LD → (date, heure) locales Québec, sans inventer. | |
| 67 | + | |
| 68 | + Un décalage EXPLICITE (« +00:00 », « Z », « -05:00 ») est converti en | |
| 69 | + heure de Montréal (le Grand Théâtre publie ses heures en UTC — vérifié | |
| 70 | + 2026-08-19 : « 2026-08-19T23:30:00+00:00 » = 19 h 30 locales). Sans | |
| 71 | + décalage, l'heure est prise littérale. Minuit = placeholder « toute la | |
| 72 | + journée » (constaté sur les fiches Trident) → heure inconnue. | |
| 73 | + """ | |
| 74 | + from ..normalize import parse_date_iso, parse_time, utc_to_local | |
| 75 | + if not iso: | |
| 76 | + return None, None | |
| 77 | + if re.search(r"(Z|[+-]\d{2}:\d{2})$", iso) and "T" in iso: | |
| 78 | + return utc_to_local(iso) | |
| 79 | + t = parse_time(iso) if "T" in iso else None | |
| 80 | + return parse_date_iso(iso), (None if t == "00:00" else t) | |
| 81 | + | |
| 82 | + | |
| 83 | +class SallesJsonLdConnector(BaseConnector): | |
| 84 | + source_id = "sallesjsonld" | |
| 85 | + request_delay = 0.8 | |
| 86 | + | |
| 87 | + def _load_config(self) -> list[dict]: | |
| 88 | + try: | |
| 89 | + return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["salles"] | |
| 90 | + except Exception: | |
| 91 | + return [] | |
| 92 | + | |
| 93 | + def _fiche_events(self, salle: dict, url: str, html: str) -> list[dict]: | |
| 94 | + rows = [] | |
| 95 | + for ld in ld_events(html): | |
| 96 | + title = clean_text(str(ld.get("name") or "")) | |
| 97 | + start = str(ld.get("startDate") or "") | |
| 98 | + if not title or not start: | |
| 99 | + continue | |
| 100 | + loc = ld.get("location") | |
| 101 | + if isinstance(loc, list): | |
| 102 | + loc = loc[0] if loc else {} | |
| 103 | + loc = loc if isinstance(loc, dict) else {} | |
| 104 | + addr = loc.get("address") | |
| 105 | + addr = addr if isinstance(addr, dict) else {} | |
| 106 | + offers = ld.get("offers") | |
| 107 | + if isinstance(offers, list): | |
| 108 | + offers = offers[0] if offers else {} | |
| 109 | + offers = offers if isinstance(offers, dict) else {} | |
| 110 | + price_label = "" | |
| 111 | + low = offers.get("lowPrice") or offers.get("price") | |
| 112 | + if low not in (None, "", 0, "0"): | |
| 113 | + high = offers.get("highPrice") | |
| 114 | + price_label = (f"{low} $ à {high} $" if high and high != low | |
| 115 | + else f"{low} $") | |
| 116 | + img = _first(ld.get("image")) or "" | |
| 117 | + slug = url.rstrip("/").split("/")[-1] | |
| 118 | + date_part = start[:16].replace(":", "").replace("T", "-") | |
| 119 | + rows.append({ | |
| 120 | + "external_id": f"{salle['id']}-{slug}-{date_part}", | |
| 121 | + "url": url, | |
| 122 | + "title": title, | |
| 123 | + "description": clean_text(str(ld.get("description") or "")), | |
| 124 | + "venue": clean_text(str(loc.get("name") or "")) or salle.get("salle", ""), | |
| 125 | + "address": clean_text(str(addr.get("streetAddress") or "")), | |
| 126 | + "city": clean_text(str(addr.get("addressLocality") or "")) or salle.get("ville", ""), | |
| 127 | + "postal_code": str(addr.get("postalCode") or "").strip(), | |
| 128 | + "start": start, | |
| 129 | + "end": str(ld.get("endDate") or "") or start, | |
| 130 | + "status": normalize_status(str(ld.get("eventStatus") or "")), | |
| 131 | + "price_label": price_label, | |
| 132 | + "image": img if isinstance(img, str) else "", | |
| 133 | + "website": str(offers.get("url") or "") or url, | |
| 134 | + }) | |
| 135 | + return rows | |
| 136 | + | |
| 137 | + def fetch(self) -> list[Event]: | |
| 138 | + cache = self.load_cache() | |
| 139 | + now = time.time() | |
| 140 | + budget = FICHE_MAX | |
| 141 | + all_urls: set[str] = set() | |
| 142 | + for salle in self._load_config(): | |
| 143 | + motif = re.compile(salle["motif"]) | |
| 144 | + urls: set[str] = set() | |
| 145 | + for liste in salle.get("listes", []): | |
| 146 | + try: | |
| 147 | + html = self.get(liste).text | |
| 148 | + except Exception: | |
| 149 | + print(f"[sorti-ka] sallesjsonld : liste inaccessible {liste}") | |
| 150 | + continue | |
| 151 | + for href in _HREF_RE.findall(html): | |
| 152 | + full = urljoin(liste, href) | |
| 153 | + if motif.search(full): | |
| 154 | + urls.add(full.split("?")[0]) | |
| 155 | + all_urls |= urls | |
| 156 | + to_fetch = sorted( | |
| 157 | + (u for u in urls | |
| 158 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 159 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:max(0, budget)] | |
| 160 | + budget -= len(to_fetch) | |
| 161 | + | |
| 162 | + def worker(url, session, _salle=salle): | |
| 163 | + return self._fiche_events( | |
| 164 | + _salle, url, session.get(url, timeout=25).text) | |
| 165 | + | |
| 166 | + for url, rows in self.fetch_many(to_fetch, worker, | |
| 167 | + max_workers=3).items(): | |
| 168 | + cache[url] = {"ts": now, "rows": rows or []} | |
| 169 | + # on ne garde en cache que les fiches encore listées quelque part | |
| 170 | + cache = {u: c for u, c in cache.items() if u in all_urls} | |
| 171 | + self.save_cache(cache) | |
| 172 | + | |
| 173 | + events: list[Event] = [] | |
| 174 | + seen: set[str] = set() | |
| 175 | + for entry in cache.values(): | |
| 176 | + for r in entry.get("rows") or []: | |
| 177 | + if r["external_id"] in seen: | |
| 178 | + continue | |
| 179 | + seen.add(r["external_id"]) | |
| 180 | + sd, st = _local_dt(r["start"]) | |
| 181 | + ed, et = (_local_dt(r["end"]) | |
| 182 | + if r["end"] != r["start"] else (sd, None)) | |
| 183 | + events.append(Event( | |
| 184 | + source=self.source_id, | |
| 185 | + external_id=r["external_id"], | |
| 186 | + url=r["url"], | |
| 187 | + title=r["title"], | |
| 188 | + description=r["description"], | |
| 189 | + raw_categories=[r["title"]], | |
| 190 | + venue=r["venue"], | |
| 191 | + address=r["address"], | |
| 192 | + city=r["city"], | |
| 193 | + postal_code=r["postal_code"], | |
| 194 | + start_date=sd, | |
| 195 | + start_time=st, | |
| 196 | + end_date=ed, | |
| 197 | + end_time=et, | |
| 198 | + status=r["status"], | |
| 199 | + price_label=r["price_label"], | |
| 200 | + image=r["image"], | |
| 201 | + website=r["website"], | |
| 202 | + )) | |
| 203 | + return events | |
modified
sortika/connectors/sitq.py
+37 −21
@@ -105,7 +105,20 @@ class SitqConnector(BaseConnector): | ||
| 105 | 105 | |
| 106 | 106 | addr = _first(rec.get("Adresses"), default={}) or {} |
| 107 | 107 | geo = _first(rec.get("Geolocalisations"), default={}) or {} |
| 108 | − period = _first(rec.get("PeriodeOuvertures"), default={}) or {} | |
| 108 | + # Phase 3 : DÉROULAGE des périodes multiples (reporté de la | |
| 109 | + # Phase 2, audit 02 §4) — une occurrence datée PAR période | |
| 110 | + # publiée. Cap 24 : au-delà, c'est un calendrier d'ouverture | |
| 111 | + # hebdomadaire, on garde l'enveloppe min-max (jamais 40 fiches | |
| 112 | + # pour un même marché public). | |
| 113 | + periods = sorted( | |
| 114 | + (pp for pp in (rec.get("PeriodeOuvertures") or []) | |
| 115 | + if isinstance(pp, dict) and pp.get("Datedebut")), | |
| 116 | + key=lambda pp: str(pp.get("Datedebut"))) | |
| 117 | + if len(periods) > 24: | |
| 118 | + periods = [{"Datedebut": periods[0].get("Datedebut"), | |
| 119 | + "Datefin": periods[-1].get("Datefin")}] | |
| 120 | + if not periods: | |
| 121 | + periods = [{}] | |
| 109 | 122 | struct = rec.get("Structure") or {} |
| 110 | 123 | |
| 111 | 124 | address = ", ".join( |
@@ -143,25 +156,28 @@ class SitqConnector(BaseConnector): | ||
| 143 | 156 | contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c) |
| 144 | 157 | description = f"Renseignements : {contacts}" if contacts else "" |
| 145 | 158 | |
| 146 | − events.append(Event( | |
| 147 | − source=self.source_id, | |
| 148 | − external_id=ext_id, | |
| 149 | − url=website or "https://www.quebecoriginal.com/", | |
| 150 | − title=title, | |
| 151 | − description=description, | |
| 152 | − raw_categories=raw_cats, | |
| 153 | − audience=audience, | |
| 154 | − venue=addr.get("Nomdupointdeservice", "") or "", | |
| 155 | − address=address, | |
| 156 | − city=addr.get("Municipalite", "") or "", | |
| 157 | − tourist_region=tourist_region, | |
| 158 | − postal_code=addr.get("CodePostal", "") or "", | |
| 159 | − lat=geo.get("Latitude"), | |
| 160 | − lng=geo.get("Longitude"), | |
| 161 | − start_date=period.get("Datedebut"), | |
| 162 | − end_date=period.get("Datefin"), | |
| 163 | − organizer=struct.get("Name", "") or "", | |
| 164 | − website=website, | |
| 165 | − )) | |
| 159 | + for pi, period in enumerate(periods): | |
| 160 | + events.append(Event( | |
| 161 | + source=self.source_id, | |
| 162 | + # 1re période = identifiant historique (zéro churn) ; | |
| 163 | + # périodes supplémentaires suffixées -p<i> | |
| 164 | + external_id=ext_id if pi == 0 else f"{ext_id}-p{pi}", | |
| 165 | + url=website or "https://www.quebecoriginal.com/", | |
| 166 | + title=title, | |
| 167 | + description=description, | |
| 168 | + raw_categories=raw_cats, | |
| 169 | + audience=audience, | |
| 170 | + venue=addr.get("Nomdupointdeservice", "") or "", | |
| 171 | + address=address, | |
| 172 | + city=addr.get("Municipalite", "") or "", | |
| 173 | + tourist_region=tourist_region, | |
| 174 | + postal_code=addr.get("CodePostal", "") or "", | |
| 175 | + lat=geo.get("Latitude"), | |
| 176 | + lng=geo.get("Longitude"), | |
| 177 | + start_date=period.get("Datedebut"), | |
| 178 | + end_date=period.get("Datefin"), | |
| 179 | + organizer=struct.get("Name", "") or "", | |
| 180 | + website=website, | |
| 181 | + )) | |
| 166 | 182 | self._enrich_images(events) |
| 167 | 183 | return events |
added
tests/fixtures/bassaintlaurent_fiche.html
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +<html><head><meta property="og:description" content="Grand rendez-vous festif marquant la fin de la saison estivale. | |
| 2 | + | |
| 3 | +Dégustez les excellentes bières de microbrasseries de partout au Québec et cassez la croûte avec d'excellents produits du terroir. | |
| 4 | + | |
| 5 | +Animation sur place et site accessible gratuitement au coeur du Vieux Rivière-du-Loup." /></head><body><script>js"></script><script id="mlmi-locator-data" type="text/javascript"> | |
| 6 | +window.locations = [{"id":6461,"title":"Bi\u00e8re Fest Rivi\u00e8re-du-Loup","display_name":"Bi\u00e8re Fest Rivi\u00e8re-du-Loup","lat":47.8306127999999972644218360073864459991455078125,"lng":-69.534986900000006926347850821912288665771484375,"coords":{"address":"Parc Blais, rue Lafontaine, Rivi\u00e8re-du-Loup, Qu\u00e9bec, Canada","l</script></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/bassaintlaurent_liste.html
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +<html><body><a href="https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/degelis-ta-riviere-tappelle/" target="_self" aria-label="Dégelis, ta rivière t’appelle" class="card-location card--type-event"> | |
| 2 | + <div class="featured card-location__featured"><i class="icon-star"></i><span>En vedette</span></div><div class="favorite"> | |
| 3 | + <button class="favorite__toggle" data-content-uid="ahekl-pera-fkz1ef-ffxt5of-jn63kh"></button> | |
| 4 | +</div> | |
| 5 | + <div class="card-location__image"> | |
| 6 | + <picture class="picture grid-picture-33851"> | |
| 7 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-666x518.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb.png.webp 2x" type="image/webp"> | |
| 8 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-666x518.png 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb.png 2x"> | |
| 9 | + <source srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-868x675.png.webp 2x" type="image/webp"> | |
| 10 | + <img onload="picture(this)" src="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-868x675.png 2x" alt=""> | |
| 11 | +</picture> | |
| 12 | + </div> | |
| 13 | + <div class="card-location__content"> <div class="card-location__dates">Du 27 au 30 août 2026</div> <p class="card-location__title">Dégelis, ta rivière t’appelle</p> | |
| 14 | + <div class="card-location__city"><i class="icon-pin-2"></i>Dégelis</div> </div> </a> | |
| 15 | +<a href="https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/riviere-du-loup-en-spectacles/" target="_self" aria-label="Rivière-du-Loup en spectacles" class="card-location card--type-event"> | |
| 16 | + <div class="favorite"> | |
| 17 | + <button class="favorite__toggle" data-content-uid="kyttwxchbxegedh8-grj4afyykhq-88y"></button> | |
| 18 | +</div> | |
| 19 | + <div class="card-location__image"> | |
| 20 | + <picture class="picture grid-picture-15187"> | |
| 21 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-666x455.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248.png.webp 2x" type="image/webp"> | |
| 22 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-666x455.png 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248.png 2x"> | |
| 23 | + <source srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-868x593.png.webp 2x" type="image/webp"> | |
| 24 | + <img onload="picture(this)" src="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-868x593.png 2x" alt=""> | |
| 25 | +</picture> | |
| 26 | + </div> | |
| 27 | + <div class="card-location__content"> <div class="card-location__dates">Du 1er janvier au 31 décembre 2026</div> <p class="card-location__title">Rivière-du-Loup en spectacles</p> | |
| 28 | + <div class="card-location__city"><i class="icon-pin-2"></i>Rivière-du-Loup</div> </div> </a></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lanaudiere_fiche.html
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +<html><head><meta property="og:image" content="https://mto.media.tourinsoft.eu/upload/moring-220730-01498-HD.jpg" /><meta property="og:description" content="Le grand rendez-vous trad de Lanaudière." /></head><body><div class="member-title-1-1"> | |
| 2 | + <div class="member-title-1-1__container"> | |
| 3 | + <h1 class="member-title-1-1__title">Festival Mémoire et Racines</h1> | |
| 4 | + | |
| 5 | + <h2 class="member-title-1-1__subtitle"> | |
| 6 | +Du 17 au 26 juillet 2026<br /> | |
| 7 | + </h2> | |
| 8 | + | |
| 9 | + <div class="member-title-1-1__details"> | |
| 10 | + <div class="member-title-1-1__location"><span>Saint-Charles-Borromée</span></div> | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + </div> | |
| 15 | + </div> | |
| 16 | +</div> | |
| 17 | + | |
| 18 | + <div class="member-1-1__container"> | |
| 19 | + <div class="member-1-1__content"> | |
| 20 | + <div class="member-1-1__content-container"> | |
| 21 | + <div class="member-1-1__content--gutter"></div> | |
| 22 | + <div class="pictograms-1-1"> | |
| 23 | + <div class="pictograms-1-1__items"> | |
| 24 | + <div class="pictograms-1-1__item"> | |
| 25 | + <img class="pictograms-1-1__item-icon" src="/dist/catalog/pictograms-1/view-1/img/payant.svg" alt="" /> | |
| 26 | + <div class="pictograms-1-1__item-label">Entrée payante</div> | |
| 27 | + </div> | |
| 28 | + <div class="pictograms-1-1__item"> | |
| 29 | + <img class="pictograms-1-1__item-icon" src="/dist/catalog/pictograms-1/view-1/img/restauration.svg" alt="" /> | |
| 30 | + <div class="pictograms-1-1__item-label">Restauration sur place</div> | |
| 31 | + </div> | |
| 32 | + <div class="pictograms-1-1__item"> | |
| 33 | + <img class="pictograms-1-1__item-icon" src=</body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lanaudiere_sitemap.xml
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<urlset><url><loc>https://lanaudiere.ca/fr/evenements-lanaudiere/festival-memoire-et-racines/</loc></url> | |
| 3 | +<url><loc>https://lanaudiere.ca/fr/evenements-lanaudiere/apero-musical/</loc></url> | |
| 4 | +<url><loc>https://lanaudiere.ca/en/whats-going-on/some-event/</loc></url></urlset> | |
| \ No newline at end of file | ||
added
tests/fixtures/lavalweb_page.html
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +<html><body><div class="event-item"> | |
| 2 | + <a class="event-item__inner" href="/calendrier-activites/hockey-cosom-6-12-ans-dehors-champfleury-2026-08-18/" aria-label="Hockey cosom pour les 6-12 ans. Aller sur la page de cet événement."> | |
| 3 | + <div class="event-item__img-wrapper"> | |
| 4 | + <div class="event-item__tag-container"> | |
| 5 | + <span class="event-occurrence">Les mardis du 30 juin au 18 août</span> | |
| 6 | + </div> | |
| 7 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 8 | +<source type="image/webp" data-lazy-srcset="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 9 | +<img width="420" height="235" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20420%20235'%3E%3C/svg%3E" alt="Hockey cosom : une balle et deux bâtons rouges" data-lazy-sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async" data-lazy-src="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png"/><noscript><img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png" alt="Hockey cosom : une balle et deux bâtons rouges" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/></noscript> | |
| 10 | +</picture> | |
| 11 | + | |
| 12 | + </div> | |
| 13 | + <div class="event-item__content__info"> | |
| 14 | + <p class="event-item__date">18 août 2026 | 17 h</p> | |
| 15 | + <h3 class="event-item__title"><span>Hockey cosom pour les 6-12 ans</span></h3> | |
| 16 | + <p class="event-item__localisation"> | |
| 17 | + <span>Parc Champfleury</span> | |
| 18 | + </p> | |
| 19 | + </div> | |
| 20 | + </a> | |
| 21 | +</div> | |
| 22 | +<div class="event-item"> | |
| 23 | + <a class="event-item__inner" href="/calendrier-activites/oeuvre-art-public-temporaire-khadija-baker-appartenances-emap/" aria-label="Œuvre d’art public temporaire | Khadija Baker – Appartenances. Aller sur la page de cet événement."> | |
| 24 | + <div class="event-item__img-wrapper"> | |
| 25 | + <div class="event-item__tag-container"> | |
| 26 | + </div> | |
| 27 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 28 | +<source type="image/webp" srcset="https://www.laval.ca/wp-content/uploads/2026/05/appartenances-1216x516-1-420x235.jpg.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 29 | +<img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/appartenances-1216x516-1-420x235.jpg" alt="Maison avec en avant plan une oeuvre" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/> | |
| 30 | +</picture> | |
| 31 | + | |
| 32 | + </div> | |
| 33 | + <div class="event-item__content__info"> | |
| 34 | + <p class="event-item__date">Du 27 mai 2026 au 28 août 2026</p> | |
| 35 | + <h3 class="event-item__title"><span>Œuvre d’art public temporaire | Khadija Baker – Appartenances</span></h3> | |
| 36 | + <p class="event-item__localisation"> | |
| 37 | + <span>Résidence de création Marcel-Saint-Pierre</span> | |
| 38 | + </p> | |
| 39 | + </div> | |
| 40 | + </a> | |
| 41 | +</div> | |
| 42 | +<div class="event-item"> | |
| 43 | + <a class="event-item__inner" href="/calendrier-activites/oeuvre-art-public-temporaire-khadija-baker-relations-emap/" aria-label="Œuvre d’art public temporaire | xecê Khadija Baker – Nos relations. Aller sur la page de cet événement."> | |
| 44 | + <div class="event-item__img-wrapper"> | |
| 45 | + <div class="event-item__tag-container"> | |
| 46 | + </div> | |
| 47 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 48 | +<source type="image/webp" srcset="https://www.laval.ca/wp-content/uploads/2026/05/oeuvre-art-khadija-baker-1216x516-1-420x235.jpg.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 49 | +<img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/oeuvre-art-khadija-baker-1216x516-1-420x235.jpg" alt="" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/> | |
| 50 | +</picture> | |
| 51 | + | |
| 52 | + </div> | |
| 53 | + <div class="event-item__content__info"> | |
| 54 | + <p class="event-item__date">Du 27 mai 2026 au 25 août 2026</p> | |
| 55 | + <h3 class="event-item__title"><span>Œuvre d’art public temporaire | xecê Khadija Baker – Nos relations</span></h3> | |
| 56 | + <p class="event-item__localisation"> | |
| 57 | + <span>Parc Bernard-Landry</span> | |
| 58 | + </p> | |
| 59 | + </div> | |
| 60 | + </a> | |
| 61 | +</div></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lhjmq_schedule.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"SiteKit": {"Parameters": {"feed": "modulekit", "view": "schedule", "key": "f322673b6bcae299", "fmt": "json", "client_code": "lhjmq", "lang": "fr", "season_id": 214, "lang_id": 2, "league_id": "1"}, "Schedule": [{"id": "32940", "game_id": "32940", "season_id": "214", "quick_score": "0", "date_played": "2026-09-18", "date_tbd": "0", "date": "Sep. 18", "date_with_day": "Fri, Sep 18", "date_time_played": "2026-09-18T19:00:00Z", "GameDateISO8601": "2026-09-18T19:00:00-02:30", "home_team": "2", "visiting_team": "8", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "1", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Saint John, Sea Dogs", "visiting_team_code": "SNB", "visiting_team_nickname": "Sea Dogs", "visiting_team_city": "Saint John", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16026080", "flo_live_event_id": "253475", "htv_game_id": "1152686", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32940"}, {"id": "32779", "game_id": "32779", "season_id": "214", "quick_score": "0", "date_played": "2026-09-18", "date_tbd": "0", "date": "Sep. 18", "date_with_day": "Fri, Sep 18", "date_time_played": "2026-09-18T19:00:00Z", "GameDateISO8601": "2026-09-18T19:00:00-04:00", "home_team": "10", "visiting_team": "13", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "6", "shootout": "0", "attendance": "", "status": "1", "location": "6", "game_status": "19:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Chicoutimi, Saguenéens", "home_team_code": "Chi", "home_team_nickname": "Saguenéens", "home_team_city": "Chicoutimi", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Shawinigan, Cataractes", "visiting_team_code": "Sha", "visiting_team_nickname": "Cataractes", "visiting_team_city": "Shawinigan", "visiting_team_division_long": "Association Ouest", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Georges-Vézina", "venue_url": "", "venue_location": "Chicoutimi QC", "last_modified": "2026-06-26 17:15:04", "flo_core_event_id": "0", "flo_live_event_id": "253281", "htv_game_id": "1152519", "client_code": "lhjmq", "scheduled_time": "19:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32779"}, {"id": "32941", "game_id": "32941", "season_id": "214", "quick_score": "0", "date_played": "2026-09-19", "date_tbd": "0", "date": "Sep. 19", "date_with_day": "Sat, Sep 19", "date_time_played": "2026-09-19T19:00:00Z", "GameDateISO8601": "2026-09-19T19:00:00-02:30", "home_team": "2", "visiting_team": "8", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "13", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Saint John, Sea Dogs", "visiting_team_code": "SNB", "visiting_team_nickname": "Sea Dogs", "visiting_team_city": "Saint John", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16025883", "flo_live_event_id": "253375", "htv_game_id": "1152607", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32941"}, {"id": "32780", "game_id": "32780", "season_id": "214", "quick_score": "0", "date_played": "2026-09-20", "date_tbd": "0", "date": "Sep. 20", "date_with_day": "Sun, Sep 20", "date_time_played": "2026-09-20T16:00:00Z", "GameDateISO8601": "2026-09-20T16:00:00-04:00", "home_team": "10", "visiting_team": "9", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "16:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "17", "shootout": "0", "attendance": "", "status": "1", "location": "6", "game_status": "16:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Chicoutimi, Saguenéens", "home_team_code": "Chi", "home_team_nickname": "Saguenéens", "home_team_city": "Chicoutimi", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Québec, Remparts", "visiting_team_code": "Que", "visiting_team_nickname": "Remparts", "visiting_team_city": "Québec", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Georges-Vézina", "venue_url": "", "venue_location": "Chicoutimi QC", "last_modified": "2026-06-26 17:15:04", "flo_core_event_id": "0", "flo_live_event_id": "252996", "htv_game_id": "1152231", "client_code": "lhjmq", "scheduled_time": "16:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32780"}, {"id": "32942", "game_id": "32942", "season_id": "214", "quick_score": "0", "date_played": "2026-09-25", "date_tbd": "0", "date": "Sep. 25", "date_with_day": "Fri, Sep 25", "date_time_played": "2026-09-25T19:00:00Z", "GameDateISO8601": "2026-09-25T19:00:00-02:30", "home_team": "2", "visiting_team": "5", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "19", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Halifax, Mooseheads", "visiting_team_code": "Hal", "visiting_team_nickname": "Mooseheads", "visiting_team_city": "Halifax", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16025436", "flo_live_event_id": "253156", "htv_game_id": "1152393", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32942"}, {"id": "32974", "game_id": "32974", "season_id": "214", "quick_score": "0", "date_played": "2026-09-25", "date_tbd": "0", "date": "Sep. 25", "date_with_day": "Fri, Sep 25", "date_time_played": "2026-09-25T19:00:00Z", "GameDateISO8601": "2026-09-25T19:00:00-04:00", "home_team": "9", "visiting_team": "13", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "24", "shootout": "0", "attendance": "", "status": "1", "location": "182", "game_status": "19:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Québec, Remparts", "home_team_code": "Que", "home_team_nickname": "Remparts", "home_team_city": "Québec", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Shawinigan, Cataractes", "visiting_team_code": "Sha", "visiting_team_nickname": "Cataractes", "visiting_team_city": "Shawinigan", "visiting_team_division_long": "Association Ouest", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Vidéotron", "venue_url": "", "venue_location": "Québec (QC)", "last_modified": "2026-06-26 17:00:05", "flo_core_event_id": "0", "flo_live_event_id": "252143", "htv_game_id": "1151389", "client_code": "lhjmq", "scheduled_time": "19:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32974"}], "Copyright": {"required_copyright": "Official statistics provided by LHJMQ", "required_link": "http://leaguestat.com", "powered_by": "Powered by HockeyTech.com", "powered_by_url": "http://hockeytech.com"}}} | |
| \ No newline at end of file | ||
added
tests/fixtures/lhjmq_seasons.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"SiteKit": {"Seasons": [{"season_id": "214", "season_name": "2026-27 | Saison régulière", "career": "1", "playoff": "0", "start_date": "2026-09-15", "end_date": "2027-03-22"}, {"season_id": "213", "season_name": "Pré-saison", "career": "0", "playoff": "0", "start_date": "2026-08-01", "end_date": "2026-09-10"}]}} | |
| \ No newline at end of file | ||
added
tests/fixtures/outaouais_page.html
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<html><body><astro-island component-url="/_astro/CalendrierDesEvenements.X.js" props="{"categoryUrl": [0, "/quoi-faire/festivals-et-evenements"], "events": [1, [[0, {"id": [0, "69379d207e36e5aaa7518767"], "title": [0, "Premiers royaumes d’Europe"], "slug": [0, ""], "url": [0, "https://www.museedelhistoire.ca/premiers-royaumes/?_ga=2.164185151.1465875516.1711477939-1738617796.1711477939&_gl=1*1fmnd74*_ga*MTczODYxNzc5Ni4xNzExNDc3OTM5*_ga_6SV303E8VY*MTcxMTQ3NzkzOS4xLjEuMTcxMTQ3ODAzNi42MC4wLjA."], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/couronne-field-museum-adam-vago-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-04-05"], "endDate": [0, "2025-01-19"]}], [0, {"id": [0, "69379d1f7e36e5aaa75186b9"], "title": [0, "SORTIES DES RANGS"], "slug": [0, ""], "url": [0, "https://www.museedelaguerre.ca/sorties-des-rangs/"], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/ld-mark-holleron-musee-canadien-de-la-gue-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-05-28"], "endDate": [0, "2025-01-05"]}], [0, {"id": [0, "69379d137e36e5aaa7517f36"], "title": [0, "Les Olympiques de Gatineau"], "slug": [0, ""], "url": [0, "https://centreslushpuppie.com/les-olympiques/"], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/ld-centre-slush-puppie-centre-slush-puppi-1-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-12-01"], "endDate": [0, "2025-03-24"]}]]], "initialFilterQuery": [0, {"sectors": [0, {"territories": [1, []], "municipalities": [1, []]}], "date": [0, {"startDate": [0, ""], "endDate": [0, ""]}]}], "initialFilterDefinitions": [1, [[0, {"type": [0, "date"], "label": [0, "Quand"], "options": [1, []], "hasMany": [0, true]}], [0, {"type": [0, "sectors"], "label": [0, "Où"], "options": [1, [[0, {"name": [0, "gatineau"], "label": [0, "Gatineau"]}], [0, {"name": [0, "la-vallee-de-la-gatineau"], "label": [0, "La Vallée-de-la-Gatineau"]}], [0, {"name": [0, "les-collines-de-loutaouais"], "label": [0, "Les Collines-de-l'Outaouais"]}], [0, {"name": [0, "petite-nation"], "label": [0, "Petite Nation"]}], [0, {"name": [0, "pontiac"], "label": [0, "Pontiac"]}], [0, {"name": [0, "territoire-exterieur-de-loutaouais"], "label": [0, "Territoire extérieur de l'Outaouais"]}]]], "hasMany": [0, true]}]]], "locale": [0, "fr"]}"></astro-island></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/placedesarts_fiche.html
+257 −0
@@ -0,0 +1,257 @@ | ||
| 1 | +<html><body><script type="application/json" id="eventbannerdata"> | |
| 2 | + {"mainImage":"https:\/\/cdn.placedesarts.com\/tranforms\/_300x140_crop_center-center_none_ns\/1657294\/24POSES_DU2526_2400x1035.webp","title":"24 poses (portraits)","url":"https:\/\/www.placedesarts.com\/evenement\/24-poses-portraits","hasPassed":false,"dates":"2 septembre au 3 octobre 2026"} | |
| 3 | + </script><script type="application/json" id="eventGTM"> | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + {"eventName":"24 poses (portraits)","eventPlace":["Th\u00e9\u00e2tre Jean-Duceppe"],"eventProducer":"Duceppe","eventDiscipline":["Th\u00e9\u00e2tre"],"eventSubCategory":["Th\u00e9\u00e2tre \u2022 Tragi-com\u00e9die"],"eventDateRange":"2 septembre au 3 octobre 2026","eventID":1657289} | |
| 9 | + </script><section rs__wrapper"> | |
| 10 | + | |
| 11 | +<section class="sidebar sb-performances" id="sb-performances" data-lenis-prevent> | |
| 12 | + <div class="sidebar__bg"></div> | |
| 13 | + <div class="sidebar__inner"> | |
| 14 | + <div class="sidebar__header"> | |
| 15 | + <p class="sidebar__title">Sélectionnez la date</p> | |
| 16 | + <span class="btn--close"></span> | |
| 17 | + </div> | |
| 18 | + | |
| 19 | + <template id="sb-performances-promotion-template"> | |
| 20 | + <div class="promotion__wrapper"> | |
| 21 | + <div class="promotion__content"> | |
| 22 | + <div class="promotion__sticker"> | |
| 23 | + <span class="promotion__sticker-text"></span> | |
| 24 | + </div> | |
| 25 | + <div class="promotion__infos"> | |
| 26 | + <div class="promotion__txt"> | |
| 27 | + <p class="promotion__txt-title"></p> | |
| 28 | + <div class="promotion__txt-description"></div> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + </template> | |
| 34 | + | |
| 35 | + <div class="sidebar__body"> | |
| 36 | + <div class="sidebar__promotion"> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <p class="sb-performances__subtitle"> | |
| 40 | + Vous serez redirigé·e vers notre système de billetterie. | |
| 41 | + </p> | |
| 42 | + <p class="sb-performances__subtitle"> | |
| 43 | + Tous les prix affichés incluent les taxes et frais associés. | |
| 44 | + </p> | |
| 45 | + | |
| 46 | + <div class="sb-performances__grid"> | |
| 47 | + <div class="sb-performance" data-performance-id="1671189"> | |
| 48 | + <div class="sb-performance__infos"> | |
| 49 | + | |
| 50 | + <p class="sb-performance__hour"> | |
| 51 | + <span class="hour"> | |
| 52 | + 19h30 | |
| 53 | + </span> | |
| 54 | + </p> | |
| 55 | + | |
| 56 | + <h6 class="sb-performance__date"> | |
| 57 | + Mercredi 2 septembre 2026 | |
| 58 | + </h6> | |
| 59 | + <div class="sb-performance__extrainfos"> | |
| 60 | + <template class="sb-performance-promotion-template"> | |
| 61 | + <div class="sb-performance__promo"></span> | |
| 62 | + <span class="font-primary"></span> | |
| 63 | + </div> | |
| 64 | + </template> | |
| 65 | + | |
| 66 | + | |
| 67 | + <p class="sb-performance__price"> | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + 33 $ à 97,50 $ | |
| 73 | + | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + | |
| 79 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwMg==" target="_blank" aria-label="Obtenir des billets"> | |
| 80 | + | |
| 81 | + <div class="btn__icon--right"> | |
| 82 | + <span class="icon btn--icon bg-black text-white"> | |
| 83 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 84 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 85 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 86 | + </svg> | |
| 87 | +</span> | |
| 88 | + </div> | |
| 89 | + </a> | |
| 90 | + </div> | |
| 91 | + <div class="sb-performance" data-performance-id="1671190"> | |
| 92 | + <div class="sb-performance__infos"> | |
| 93 | + | |
| 94 | + <p class="sb-performance__hour"> | |
| 95 | + <span class="hour"> | |
| 96 | + 20h00 | |
| 97 | + </span> | |
| 98 | + </p> | |
| 99 | + | |
| 100 | + <h6 class="sb-performance__date"> | |
| 101 | + Vendredi 4 septembre 2026 | |
| 102 | + </h6> | |
| 103 | + <div class="sb-performance__extrainfos"> | |
| 104 | + <template class="sb-performance-promotion-template"> | |
| 105 | + <div class="sb-performance__promo"></span> | |
| 106 | + <span class="font-primary"></span> | |
| 107 | + </div> | |
| 108 | + </template> | |
| 109 | + | |
| 110 | + | |
| 111 | + <p class="sb-performance__price"> | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + 33 $ à 97,50 $ | |
| 117 | + | |
| 118 | + </p> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + | |
| 122 | + | |
| 123 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwNA==" target="_blank" aria-label="Obtenir des billets"> | |
| 124 | + | |
| 125 | + <div class="btn__icon--right"> | |
| 126 | + <span class="icon btn--icon bg-black text-white"> | |
| 127 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 128 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 129 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 130 | + </svg> | |
| 131 | +</span> | |
| 132 | + </div> | |
| 133 | + </a> | |
| 134 | + </div> | |
| 135 | + <div class="sb-performance" data-performance-id="1671191"> | |
| 136 | + <div class="sb-performance__infos"> | |
| 137 | + | |
| 138 | + <p class="sb-performance__hour"> | |
| 139 | + <span class="hour"> | |
| 140 | + 15h00 | |
| 141 | + </span> | |
| 142 | + </p> | |
| 143 | + | |
| 144 | + <h6 class="sb-performance__date"> | |
| 145 | + Samedi 5 septembre 2026 | |
| 146 | + </h6> | |
| 147 | + <div class="sb-performance__extrainfos"> | |
| 148 | + <template class="sb-performance-promotion-template"> | |
| 149 | + <div class="sb-performance__promo"></span> | |
| 150 | + <span class="font-primary"></span> | |
| 151 | + </div> | |
| 152 | + </template> | |
| 153 | + | |
| 154 | + | |
| 155 | + <p class="sb-performance__price"> | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + 33 $ à 97,50 $ | |
| 161 | + | |
| 162 | + </p> | |
| 163 | + </div> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + | |
| 167 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwNVA=" target="_blank" aria-label="Obtenir des billets"> | |
| 168 | + | |
| 169 | + <div class="btn__icon--right"> | |
| 170 | + <span class="icon btn--icon bg-black text-white"> | |
| 171 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 172 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 173 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 174 | + </svg> | |
| 175 | +</span> | |
| 176 | + </div> | |
| 177 | + </a> | |
| 178 | + </div> | |
| 179 | + <div class="sb-performance" data-performance-id="1671192"> | |
| 180 | + <div class="sb-performance__infos"> | |
| 181 | + | |
| 182 | + <p class="sb-performance__hour"> | |
| 183 | + <span class="hour"> | |
| 184 | + 19h30 | |
| 185 | + </span> | |
| 186 | + </p> | |
| 187 | + | |
| 188 | + <h6 class="sb-performance__date"> | |
| 189 | + Mercredi 9 septembre 2026 | |
| 190 | + </h6> | |
| 191 | + <div class="sb-performance__extrainfos"> | |
| 192 | + <template class="sb-performance-promotion-template"> | |
| 193 | + <div class="sb-performance__promo"></span> | |
| 194 | + <span class="font-primary"></span> | |
| 195 | + </div> | |
| 196 | + </template> | |
| 197 | + | |
| 198 | + | |
| 199 | + <p class="sb-performance__price"> | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + 33 $ à 97,50 $ | |
| 205 | + | |
| 206 | + </p> | |
| 207 | + </div> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + | |
| 211 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwOQ==" target="_blank" aria-label="Obtenir des billets"> | |
| 212 | + | |
| 213 | + <div class="btn__icon--right"> | |
| 214 | + <span class="icon btn--icon bg-black text-white"> | |
| 215 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 216 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 217 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 218 | + </svg> | |
| 219 | +</span> | |
| 220 | + </div> | |
| 221 | + </a> | |
| 222 | + </div> | |
| 223 | + <div class="sb-performance" data-performance-id="1671193"> | |
| 224 | + <div class="sb-performance__infos"> | |
| 225 | + | |
| 226 | + <p class="sb-performance__hour"> | |
| 227 | + <span class="hour"> | |
| 228 | + 20h00 | |
| 229 | + </span> | |
| 230 | + </p> | |
| 231 | + | |
| 232 | + <h6 class="sb-performance__date"> | |
| 233 | + Jeudi 10 septembre 2026 | |
| 234 | + </h6> | |
| 235 | + <div class="sb-performance__extrainfos"> | |
| 236 | + <template class="sb-performance-promotion-template"> | |
| 237 | + <div class="sb-performance__promo"></span> | |
| 238 | + <span class="font-primary"></span> | |
| 239 | + </div> | |
| 240 | + </template> | |
| 241 | + | |
| 242 | + | |
| 243 | + <p class="sb-performance__price"> | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + 33 $ à 97,50 $ | |
| 249 | + | |
| 250 | + </p> | |
| 251 | + </div> | |
| 252 | + </div> | |
| 253 | + | |
| 254 | + | |
| 255 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkxMA==" target="_blank" aria-label="Obtenir des billets"> | |
| 256 | + | |
| 257 | + <div class="btn</body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/quebecanimee_liste.html
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +<html><body><a class="item" | |
| 2 | + href="/activites/confrontation-dimpro-musicale/" | |
| 3 | + target="_self" | |
| 4 | + data-lieu-id="5653" | |
| 5 | + data-date-debut="2026-08-19" | |
| 6 | + data-date-fin="2026-08-19" | |
| 7 | + data-arrondissement="La Cité-Limoilou" | |
| 8 | + data-categorie="Art,Musique" | |
| 9 | + data-prix="Gratuit" | |
| 10 | + > | |
| 11 | + <div class="item-img"> | |
| 12 | + <img src="/media/s0clvkrg/limq_voletb_dsc9698-eburriel_emmanuel_burriel_confrontation-impro-musi.png?width=320&height=180&v=1dceeaee449bc30" alt="" loading="lazy" width="320" height="180"> | |
| 13 | + | |
| 14 | + </div> | |
| 15 | + <button class="my-activities-shortcut" data-id="8779__20260819__1800__parc-de-la-pointe-aux-lièvres"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button> | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + <div class="item-lieu">Parc de la Pointe-aux-Lièvres</div> | |
| 20 | + <div class="item-titre"> | |
| 21 | + <h3>Confrontation d'improvisation musicale</h3> | |
| 22 | + <p class="item-soustitre">Pique-nique animé : spectacle grand public</p> | |
| 23 | + </div> | |
| 24 | + | |
| 25 | + <p class="item-info"> | |
| 26 | + | |
| 27 | +19 août 2026 <span class="item-info-heuredebut"> — 18 h </span> | |
| 28 | + <span class="item-info-heurefin">à 19 h </span> | |
| 29 | + | |
| 30 | + </p> | |
| 31 | + </a> | |
| 32 | +<a class="item" | |
| 33 | + href="/activites/cinema-en-plein-air-de-radio-canada/" | |
| 34 | + target="_self" | |
| 35 | + data-lieu-id="5653" | |
| 36 | + data-date-debut="2026-08-19" | |
| 37 | + data-date-fin="2026-08-19" | |
| 38 | + data-arrondissement="La Cité-Limoilou" | |
| 39 | + data-categorie="Cinéma,Événement,Famille et jeunesse" | |
| 40 | + data-prix="Gratuit" | |
| 41 | + > | |
| 42 | + <div class="item-img"> | |
| 43 | + <img src="/media/jvoijf3z/rc_photo_cine-dans-lparc.jpg?width=320&height=180&v=1dd0572e73b0b80" alt="" loading="lazy" width="320" height="180"> | |
| 44 | + | |
| 45 | + </div> | |
| 46 | + <button class="my-activities-shortcut" data-id="9069"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button> | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + <div class="item-lieu">Parc de la Pointe-aux-Lièvres</div> | |
| 51 | + <div class="item-titre"> | |
| 52 | + <h3>Cinéma en plein air de Radio-Canada</h3> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <p class="item-info"> | |
| 56 | +19 août 2026 <span class="item-info-heuredebut"> — 18 h 30</span> | |
| 57 | + | |
| 58 | + </p> | |
| 59 | + </a> | |
| 60 | +<a class="item" | |
| 61 | + href="/activites/garcons-a-marier/" | |
| 62 | + target="_self" | |
| 63 | + data-lieu-id="5622" | |
| 64 | + data-date-debut="2026-08-19" | |
| 65 | + data-date-fin="2026-08-19" | |
| 66 | + data-arrondissement="Beauport" | |
| 67 | + data-categorie="Événement,Musique" | |
| 68 | + data-prix="Gratuit" | |
| 69 | + > | |
| 70 | + <div class="item-img"> | |
| 71 | + <img src="/media/v3hbpfzs/garconsamarier.jpg?width=320&height=180&v=1dceeacf8ba8070" alt="" loading="lazy" width="320" height="180"> | |
| 72 | + | |
| 73 | + </div> | |
| 74 | + <button class="my-activities-shortcut" data-id="8767"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button> | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + <div class="item-lieu">Maison Tessier-Dit-Laplante</div> | |
| 79 | + <div class="item-titre"> | |
| 80 | + <h3>Garçons à marier</h3> | |
| 81 | + <p class="item-soustitre">Concert en plein air</p> | |
| 82 | + </div> | |
| 83 | + | |
| 84 | + <p class="item-info"> | |
| 85 | +19 août 2026 <span class="item-info-heuredebut"> — 19 h </span> | |
| 86 | + <span class="item-info-heurefin">à 20 h 30</span> | |
| 87 | + | |
| 88 | + </p> | |
| 89 | + </a></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/saguenaysorties_detail.html
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | + | |
| 2 | + | |
| 3 | + <h3>Rencontre avec les archéologues sur le terrain</h3> | |
| 4 | + Le public pourra rencontrer les archéologues qui réaliseront la dernière campagne de fouilles archéologiques de l'histoire du poste de traite de Chicoutimi. Après plus de cinquante ans de recherches menées sur le site, cette intervention marquera la fin d'un important chapitre de l'archéologie régionale. Les archéologues seront présents principalement les jeudis, vendredis, samedis et dimanches, entre 8 h 30 et 15 h 30. Leur priorité sera la réalisation des fouilles archéologiques. Les visiteurs seront toutefois invités à observer le chantier, à suivre l'évolution des recherches et à échanger avec les membres de l'équipe lorsqu'ils seront disponibles. | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + <h4>Représentation(s)</h4> | |
| 10 | + | |
| 11 | + <div class="row small-collapse small-up-1 medium-up-2 representation quoi-faire-recherche"> | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + <div class="column column-block "> | |
| 16 | + <p class="article-date float-left"> | |
| 17 | + 20 août 2026 | |
| 18 | + </p> | |
| 19 | + <p class="article-date float-left"> | |
| 20 | + 08 h 30 | |
| 21 | + </p> | |
| 22 | + <div class="clearfix"></div> | |
| 23 | + <p> | |
| 24 | + Chicoutimi, | |
| 25 | + Poste-de-traite-de-Chicoutimi <br/> Bd du Saguenay O | |
| 26 | + </p> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
added
tests/fixtures/saguenaysorties_liste.html
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +<article class="column column-block "> | |
| 2 | + <div class="discipline_name"> | |
| 3 | + Histoire et Patrimoine | |
| 4 | + </div> | |
| 5 | + <div class="photo-container"> | |
| 6 | + <div class="photo"> | |
| 7 | + <img src="https://lvc-medias.lavitrine.com/77b5b9cf-95f5-517a-be63-82ed1e8e4806.jpg" alt="" /> | |
| 8 | + </div> | |
| 9 | + </div> | |
| 10 | + | |
| 11 | + <p class="article-date float-left"> | |
| 12 | + 20 août 2026 | |
| 13 | + </p> | |
| 14 | + <p class="article-date float-left"> | |
| 15 | + 08 h 30 | |
| 16 | + </p> | |
| 17 | + <div class="clearfix"></div> | |
| 18 | + | |
| 19 | + <div class="text-container "> | |
| 20 | + <h3><a data-open="modal-details" data-detail-id="497146" class="detail-open"> Rencontre avec les archéologues sur le terrain</a></h3> | |
| 21 | + <p> | |
| 22 | + Chicoutimi, | |
| 23 | + Poste-de-traite-de-Chicoutimi | |
| 24 | + </p> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + </article> | |
| 28 | + <article class="column column-block "> | |
| 29 | + <div class="discipline_name"> | |
| 30 | + Histoire et Patrimoine | |
| 31 | + </div> | |
| 32 | + <div class="photo-container"> | |
| 33 | + <div class="photo"> | |
| 34 | + <img src="https://lvc-medias.lavitrine.com/a95bebe0-90f9-585c-9986-1c34abdc5811.jpg" alt="" /> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + | |
| 38 | + <p class="article-date float-left"> | |
| 39 | + 20 août 2026 | |
| 40 | + </p> | |
| 41 | + <p class="article-date float-left"> | |
| 42 | + 09 h 00 | |
| 43 | + </p> | |
| 44 | + <div class="clearfix"></div> | |
| 45 | + | |
| 46 | + <div class="text-container "> | |
| 47 | + <h3><a data-open="modal-details" data-detail-id="447876" class="detail-open"> Visite guidée du site patrimonial du Poste-de-Traite-de-Chicoutimi</a></h3> | |
| 48 | + <p> | |
| 49 | + Saguenay, | |
| 50 | + Site patrimonial du Poste de Traite de Chicoutimi | |
| 51 | + </p> | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + </article> | |
| 55 | + | |
| \ No newline at end of file | ||
added
tests/fixtures/sallesjsonld_fiche.html
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<html><body><script type="application/ld+json">{"@context": "http://schema.org", "@id": "albanie-2027-04-18-18-00", "@type": "Event", "description": "Rencontre avec Patrick BureauAprès des décennies d’isolement marquées par la dictature communiste d’Enver Hoxha (1944-1985), l’Albanie s’est ouverte sur le monde et connaît un essor touristique rapide depuis les années 1990. Ce petit pays des Balkans est un paradis secret situé entre la Grèce et le Monténégro. Sa capitale, Tirana, est aujourd’hui une ville dynamique et en pleine transformation, symbole du renouveau du pays. Découvrir l’Albanie, c’est explorer un pays à la nature spectaculaire, à l’histoire complexe et à l’identité forte. Elle attire aujourd’hui de plus en plus de visiteurs curieux de découvrir une destination encore préservée et authentique.Grâce à de nombreux périples à travers le pays, Patrick Bureau nous dévoile une terre de contrastes. Explorez ses montagnes sauvages au nord, ses plages turquoise le long de la mer Ionienne et ses villes et villages traditionnels classés au patrimoine mondial de l’UNESCO. Vous ne manquerez pas non plus de faire de captivantes rencontres avec la population locale.Ce voyage vous invite à découvrir l’un des pays les plus fascinants et méconnus des Balkans situé au carrefour de l’Orient et de l’Occident.Biographie de Patrick BureauSavoyard d’origine, Patrick Bureau a toujours été attiré par les voyages et l’aventure. Depuis plus de 20 ans, il sillonne le monde à la recherche d’une nature préservée et de peuples vivant encore selon des modes de vie et des traditions ancestrales. En tant que cinéaste indépendant, il a réalisé de nombreux documentaires qui lui ont permis de se joindre aux plus grandes organisations de ciné-conférences francophones en France, au Québec, en Belgique et en Suisse, où il a présenté ses derniers documentaires, dont celui sur l’Auvergne, une région de la France. Il a été diffusé aux Grands Explorateurs lors de la saison 2023-2024. Aujourd’hui, il a choisi de nous faire partager sa passion du voyage en nous faisant découvrir l’Albanie. Sa mission reste la même : créer des liens étroits avec la population locale pour vous proposer une vision authentique et au-delà des clichés de cet étonnant pays des Balkans.", "endDate": "2027-04-18T22:00:00+00:00", "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode", "eventStatus": "https://schema.org/EventScheduled", "image": {"@context": "http://schema.org", "@id": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "@type": "ImageObject", "contentSize": "137186", "contentUrl": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "height": "599 px", "name": "LGE_2627_Albanie_428x599", "thumbnail": "/uploads/images/LGE_2627_Albanie_428x599.max-200x200.jpg", "url": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "width": "428 px"}, "location": {"@context": "http://schema.org", "@type": "Place", "address": {"@type": "PostalAddress", "addressCountry": "CA", "addressLocality": "Québec", "addressRegion": "QC", "postalCode": "G1R2B3", "streetAddress": "269 Boulevard René-Lévesque E"}, "description": "Le Grand Théâtre de Québec est un complexe artistique et culturel national situé dans la ville de Québec et comprenant deux salles de spectacle, de théâtre et d'opéra ainsi que plusieurs foyers.", "geo": {"latitude": "46.806228", "longitude": "-71.221983", "type": "http://schema.org/GeoCoordinates"}, "logo": {"@context": "http://schema.org", "@type": "ImageObject", "url": "https://www.grandtheatre.qc.ca/static/build/img/logo.png"}, "name": "Grand Théatre de Québec", "sameAs": ["https://www.facebook.com/grandtheatre", "https://twitter.com/GrandTheatreQc", "https://www.instagram.com/grandtheatreqc/", "https://www.youtube.com/channel/UCcrgPnIkEmTMe7tvJRiUMow", "https://fr.wikipedia.org/wiki/Grand_Th%C3%A9%C3%A2tre_de_Qu%C3%A9bec", "https://www.wikidata.org/wiki/Q3114610"], "url": "https://www.grandtheatre.qc.ca/"}, "name": "Les Grands Explorateurs", "offers": [{"@type": "Offer", "availability": "InStock", "price": "33.92", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}, {"@type": "Offer", "availability": "InStock", "price": "21.85", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}, {"@type": "Offer", "availability": "InStock", "price": "31.04", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}], "organizer": {"@context": "http://schema.org", "@type": "Organization", "contactPoint": [{"@type": "ContactPoint", "areaServed": "CA", "availableLanguage": ["FR", "EN"], "contactType": "customer support", "email": "gtq@grandtheatre.qc.ca", "telephone": "+14186438131"}], "description": "Le Grand Théâtre de Québec est un complexe artistique et culturel national situé dans la ville de Québec et comprenant deux salles de spectacle, de théâtre et d'opéra ainsi que plusieurs foyers.", "logo": {"@context": "http://schema.org", "@type": "ImageObject", "url": "https://www.grandtheatre.qc.ca/static/build/img/logo.png"}, "name": "Grand Théâtre de Québec", "sameAs": ["https://www.facebook.com/grandtheatre", "https://twitter.com/GrandTheatreQc", "https://www.instagram.com/grandtheatreqc/", "https://www.youtube.com/channel/UCcrgPnIkEmTMe7tvJRiUMow", "https://fr.wikipedia.org/wiki/Grand_Th%C3%A9%C3%A2tre_de_Qu%C3%A9bec", "https://www.wikidata.org/wiki/Q3114610"], "url": "https://grandtheatre.qc.ca/"}, "performer": {"@context": "http://schema.org", "@id": "albanie-paradis-secret-deurope", "@type": "Person", "identifier": null, "name": "Albanie, paradis secret d’Europe", "sameAs": [], "url": null}, "startDate": "2027-04-18T18:00:00+00:00", "url": "https://www.grandtheatre.qc.ca/programmation/albanie/"}</script></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/sallesjsonld_liste.html
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<html><body><a href="/programmation/albanie/">Albanie</a></body></html> | |
| \ No newline at end of file | ||
modified
tests/test_connectors.py
+9 −1
@@ -142,7 +142,15 @@ def test_sitq_parses_fixture(monkeypatch): | ||
| 142 | 142 | _patch_json(monkeypatch, c, [json.loads( |
| 143 | 143 | (FIXTURES / "sitq_sample.json").read_text(encoding="utf-8"))]) |
| 144 | 144 | events = [e.finalize() for e in c.fetch()] |
| 145 | − assert len(events) == 4 | |
| 145 | + # Phase 3 : les périodes multiples (PeriodeOuvertures[]) sont déroulées en | |
| 146 | + # occurrences datées — la fixture porte 4 fiches dont une à 2 périodes | |
| 147 | + assert len(events) == 5 | |
| 148 | + suffixed = [e for e in events if e.external_id.endswith("-p1")] | |
| 149 | + assert len(suffixed) == 1 | |
| 150 | + base = next(e for e in events | |
| 151 | + if e.external_id == suffixed[0].external_id[:-3]) | |
| 152 | + assert base.title == suffixed[0].title | |
| 153 | + assert base.start_date != suffixed[0].start_date | |
| 146 | 154 | jack = next(e for e in events if e.title == "JACKALOPE") |
| 147 | 155 | assert jack.uid == "sitq:EVENMTOV500NIO" |
| 148 | 156 | assert jack.city == "Montréal" |
added
tests/test_expansion.py
+270 −0
@@ -0,0 +1,270 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_expansion.py | |
| 4 | +# Desc: Phase 3 (expansion des sources) — tests des nouveaux connecteurs sur | |
| 5 | +# fixtures réelles + annuaire de salles + plages de dates françaises. | |
| 6 | +# Aucun appel réseau (CLAUDE.md §18). | |
| 7 | +# ============================================================================== | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import json | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +import pytest | |
| 14 | + | |
| 15 | +from sortika.connectors import CONNECTORS | |
| 16 | +from sortika.normalize import parse_date_range_fr | |
| 17 | + | |
| 18 | +FIXTURES = Path(__file__).parent / "fixtures" | |
| 19 | + | |
| 20 | + | |
| 21 | +def _no_cache(monkeypatch, connector): | |
| 22 | + """Cache disque neutralisé : ni lecture ni écriture pendant les tests.""" | |
| 23 | + monkeypatch.setattr(connector, "load_cache", lambda: {}) | |
| 24 | + monkeypatch.setattr(connector, "save_cache", lambda cache: None) | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_expansion_registry_autodiscovers(): | |
| 28 | + for sid in ("lavalweb", "outaouais", "lanaudiere", "saguenaysorties", | |
| 29 | + "bassaintlaurent", "quebecanimee", "placedesarts", | |
| 30 | + "lhjmq", "sallesjsonld"): | |
| 31 | + assert sid in CONNECTORS, sid | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_parse_date_range_fr(): | |
| 35 | + assert parse_date_range_fr("Du 11 au 13 septembre 2026") == \ | |
| 36 | + ("2026-09-11", "2026-09-13") | |
| 37 | + assert parse_date_range_fr("Du 4 juin au 22 août 2026") == \ | |
| 38 | + ("2026-06-04", "2026-08-22") | |
| 39 | + assert parse_date_range_fr("Du 27 août 2026 au 3 janvier 2027") == \ | |
| 40 | + ("2026-08-27", "2027-01-03") | |
| 41 | + assert parse_date_range_fr("17 août 2026") == ("2026-08-17", "2026-08-17") | |
| 42 | + assert parse_date_range_fr("n'importe quoi") == (None, None) | |
| 43 | + assert parse_date_range_fr(None) == (None, None) | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_lavalweb_parses_fixture(monkeypatch): | |
| 47 | + from sortika.connectors.lavalweb import LavalWebConnector | |
| 48 | + c = LavalWebConnector() | |
| 49 | + _no_cache(monkeypatch, c) | |
| 50 | + html = (FIXTURES / "lavalweb_page.html").read_text(encoding="utf-8") | |
| 51 | + pages = [html, "<html></html>"] # page 2 vide → arrêt pagination | |
| 52 | + monkeypatch.setattr(c, "get", | |
| 53 | + lambda url: type("R", (), {"text": pages.pop(0)})()) | |
| 54 | + monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None) | |
| 55 | + events = [e.finalize() for e in c.fetch()] | |
| 56 | + assert len(events) == 3 | |
| 57 | + ev = events[0] | |
| 58 | + assert ev.source == "lavalweb" | |
| 59 | + assert ev.city == "Laval" and ev.region == "Laval" | |
| 60 | + assert ev.start_date and ev.start_date.startswith("20") | |
| 61 | + assert ev.url.startswith("https://www.laval.ca/calendrier-activites/") | |
| 62 | + # une carte-séance porte la date ET l'heure (« 18 août 2026 | 18 h ») | |
| 63 | + seance = [e for e in events if e.start_time] | |
| 64 | + assert seance and seance[0].venue | |
| 65 | + | |
| 66 | + | |
| 67 | +def test_outaouais_parses_fixture(monkeypatch): | |
| 68 | + from sortika.connectors.outaouais import OutaouaisConnector | |
| 69 | + c = OutaouaisConnector() | |
| 70 | + html = (FIXTURES / "outaouais_page.html").read_text(encoding="utf-8") | |
| 71 | + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})()) | |
| 72 | + events = [e.finalize() for e in c.fetch()] | |
| 73 | + assert len(events) == 3 | |
| 74 | + ev = events[0] | |
| 75 | + assert ev.source == "outaouais" | |
| 76 | + assert ev.region == "Outaouais" # via tourist_region | |
| 77 | + assert ev.start_date and ev.end_date | |
| 78 | + assert ev.image.startswith("https://admin.tourismeoutaouais.com/") | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_lanaudiere_parses_fixture(monkeypatch): | |
| 82 | + from sortika.connectors.lanaudiere import LanaudiereConnector | |
| 83 | + c = LanaudiereConnector() | |
| 84 | + _no_cache(monkeypatch, c) | |
| 85 | + sitemap = (FIXTURES / "lanaudiere_sitemap.xml").read_text(encoding="utf-8") | |
| 86 | + fiche = (FIXTURES / "lanaudiere_fiche.html").read_text(encoding="utf-8") | |
| 87 | + responses = {True: sitemap} | |
| 88 | + monkeypatch.setattr( | |
| 89 | + c, "get", lambda url: type("R", (), { | |
| 90 | + "text": sitemap if url.endswith(".xml") else fiche})()) | |
| 91 | + events = [e.finalize() for e in c.fetch()] | |
| 92 | + # 2 URLs françaises dans le sitemap (l'anglaise est exclue), même fiche | |
| 93 | + # → dédupliquées par external_id distinct des slugs | |
| 94 | + assert 1 <= len(events) <= 2 | |
| 95 | + ev = events[0] | |
| 96 | + assert ev.source == "lanaudiere" | |
| 97 | + assert ev.title == "Festival Mémoire et Racines" | |
| 98 | + assert ev.city == "Saint-Charles-Borromée" | |
| 99 | + assert ev.region == "Lanaudière" | |
| 100 | + assert (ev.start_date, ev.end_date) == ("2026-07-17", "2026-07-26") | |
| 101 | + assert "tourinsoft" in ev.image | |
| 102 | + | |
| 103 | + | |
| 104 | +def test_saguenaysorties_parses_fixture(monkeypatch): | |
| 105 | + from sortika.connectors.saguenaysorties import SaguenaySortiesConnector | |
| 106 | + c = SaguenaySortiesConnector() | |
| 107 | + _no_cache(monkeypatch, c) | |
| 108 | + liste = (FIXTURES / "saguenaysorties_liste.html").read_text(encoding="utf-8") | |
| 109 | + detail = (FIXTURES / "saguenaysorties_detail.html").read_text(encoding="utf-8") | |
| 110 | + monkeypatch.setattr( | |
| 111 | + c, "_post", lambda url, data: detail if url.endswith("details") | |
| 112 | + else (liste if data["offset"] == "0" else "")) | |
| 113 | + events = [e.finalize() for e in c.fetch()] | |
| 114 | + assert len(events) == 2 | |
| 115 | + ev = events[0] | |
| 116 | + assert ev.source == "saguenaysorties" | |
| 117 | + assert ev.city and ev.venue | |
| 118 | + assert ev.region == "Saguenay–Lac-Saint-Jean" | |
| 119 | + assert ev.start_date == "2026-08-20" and ev.start_time == "08:30" | |
| 120 | + assert ev.url.startswith("https://sorties.saguenay.ca/festivals?id=") | |
| 121 | + assert "archéologues" in ev.description # détail ajax branché | |
| 122 | + | |
| 123 | + | |
| 124 | +def test_bassaintlaurent_parses_fixture(monkeypatch): | |
| 125 | + from sortika.connectors.bassaintlaurent import BasSaintLaurentConnector | |
| 126 | + c = BasSaintLaurentConnector() | |
| 127 | + _no_cache(monkeypatch, c) | |
| 128 | + liste = (FIXTURES / "bassaintlaurent_liste.html").read_text(encoding="utf-8") | |
| 129 | + fiche = (FIXTURES / "bassaintlaurent_fiche.html").read_text(encoding="utf-8") | |
| 130 | + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})()) | |
| 131 | + monkeypatch.setattr(c, "fetch_many", | |
| 132 | + lambda urls, worker, max_workers=3: | |
| 133 | + {u: c._parse_fiche(fiche) for u in urls}) | |
| 134 | + events = [e.finalize() for e in c.fetch()] | |
| 135 | + assert len(events) == 2 | |
| 136 | + ev = events[0] | |
| 137 | + assert ev.source == "bassaintlaurent" | |
| 138 | + assert ev.region == "Bas-Saint-Laurent" | |
| 139 | + assert ev.start_date and ev.end_date and ev.city | |
| 140 | + assert ev.lat and 44.0 < ev.lat < 63.0 # GPS exact de la fiche | |
| 141 | + | |
| 142 | + | |
| 143 | +def test_quebecanimee_parses_fixture(monkeypatch): | |
| 144 | + from sortika.connectors.quebecanimee import QuebecAnimeeConnector | |
| 145 | + c = QuebecAnimeeConnector() | |
| 146 | + _no_cache(monkeypatch, c) | |
| 147 | + html = (FIXTURES / "quebecanimee_liste.html").read_text(encoding="utf-8") | |
| 148 | + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})()) | |
| 149 | + monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None) | |
| 150 | + events = [e.finalize() for e in c.fetch()] | |
| 151 | + assert len(events) == 3 | |
| 152 | + ev = events[0] | |
| 153 | + assert ev.source == "quebecanimee" | |
| 154 | + assert ev.city == "Québec" and ev.region == "Capitale-Nationale" | |
| 155 | + assert ev.start_date and ev.start_date.startswith("2026") | |
| 156 | + # data-prix « Gratuit » → gratuité réelle, jamais inventée | |
| 157 | + assert any(e.is_free for e in events) | |
| 158 | + | |
| 159 | + | |
| 160 | +def test_placedesarts_parses_fixture(monkeypatch): | |
| 161 | + from sortika.connectors.placedesarts import PlaceDesArtsConnector | |
| 162 | + c = PlaceDesArtsConnector() | |
| 163 | + _no_cache(monkeypatch, c) | |
| 164 | + fiche = (FIXTURES / "placedesarts_fiche.html").read_text(encoding="utf-8") | |
| 165 | + liste = '<a href="/evenement/24-poses-portraits">x</a>' | |
| 166 | + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})()) | |
| 167 | + monkeypatch.setattr(c, "fetch_many", | |
| 168 | + lambda paths, worker, max_workers=4: | |
| 169 | + {p: c._parse_fiche(fiche) for p in paths}) | |
| 170 | + events = [e.finalize() for e in c.fetch()] | |
| 171 | + assert len(events) >= 3 # une fiche = N séances | |
| 172 | + ev = events[0] | |
| 173 | + assert ev.source == "placedesarts" | |
| 174 | + assert ev.venue == "Théâtre Jean-Duceppe" | |
| 175 | + assert ev.city == "Montréal" and ev.organizer == "Duceppe" | |
| 176 | + assert ev.start_time and len(ev.start_time) == 5 | |
| 177 | + # prix réels de la barre latérale (« 33 $ à 97,50 $ ») | |
| 178 | + assert ev.price_min == 33.0 and ev.is_free is False | |
| 179 | + # deux séances distinctes = deux dedup_key distincts (heure incluse) | |
| 180 | + assert events[0].dedup_key() != events[1].dedup_key() | |
| 181 | + | |
| 182 | + | |
| 183 | +def test_lhjmq_parses_fixture(monkeypatch): | |
| 184 | + from sortika.connectors.lhjmq import LhjmqConnector | |
| 185 | + c = LhjmqConnector() | |
| 186 | + seasons = json.loads((FIXTURES / "lhjmq_seasons.json").read_text(encoding="utf-8")) | |
| 187 | + sched = json.loads((FIXTURES / "lhjmq_schedule.json").read_text(encoding="utf-8")) | |
| 188 | + monkeypatch.setattr(c, "get_json", | |
| 189 | + lambda url: seasons if "view=seasons" in url else sched) | |
| 190 | + monkeypatch.setattr(c, "_current_seasons", lambda: ["214"]) | |
| 191 | + events = [e.finalize() for e in c.fetch()] | |
| 192 | + # seuls les matchs à domicile AU QUÉBEC sont publiés (Terre-Neuve exclue) | |
| 193 | + assert events and all(e.city != "Terre-Neuve" for e in events) | |
| 194 | + ev = events[0] | |
| 195 | + assert ev.source == "lhjmq" | |
| 196 | + assert ev.title.startswith("LHJMQ : ") | |
| 197 | + assert ev.venue and ev.start_date and ev.start_time | |
| 198 | + assert "sport" in ev.categories | |
| 199 | + assert ev.url.startswith("https://theqmjhl.ca/gamecentre/") | |
| 200 | + | |
| 201 | + | |
| 202 | +def test_sallesjsonld_parses_fixture(monkeypatch): | |
| 203 | + from sortika.connectors.sallesjsonld import SallesJsonLdConnector | |
| 204 | + c = SallesJsonLdConnector() | |
| 205 | + _no_cache(monkeypatch, c) | |
| 206 | + liste = (FIXTURES / "sallesjsonld_liste.html").read_text(encoding="utf-8") | |
| 207 | + fiche = (FIXTURES / "sallesjsonld_fiche.html").read_text(encoding="utf-8") | |
| 208 | + salle = {"id": "grandtheatre", "nom": "Grand Théâtre de Québec", | |
| 209 | + "listes": ["https://grandtheatre.qc.ca/programmation"], | |
| 210 | + "motif": r"grandtheatre\.qc\.ca/programmation/[a-z0-9-]{3,}", | |
| 211 | + "salle": "Grand Théâtre de Québec", "ville": "Québec"} | |
| 212 | + monkeypatch.setattr(c, "_load_config", lambda: [salle]) | |
| 213 | + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})()) | |
| 214 | + monkeypatch.setattr(c, "fetch_many", | |
| 215 | + lambda urls, worker, max_workers=3: | |
| 216 | + {u: c._fiche_events(salle, u, fiche) for u in urls}) | |
| 217 | + events = [e.finalize() for e in c.fetch()] | |
| 218 | + assert len(events) == 1 | |
| 219 | + ev = events[0] | |
| 220 | + assert ev.source == "sallesjsonld" | |
| 221 | + assert ev.venue and ev.city == "Québec" | |
| 222 | + # le Grand Théâtre publie ses horodatages en UTC (+00:00) → convertis | |
| 223 | + # en heure locale de Montréal (18:00 UTC = 14:00 EDT) | |
| 224 | + assert ev.start_date == "2027-04-18" | |
| 225 | + assert ev.start_time == "14:00" | |
| 226 | + | |
| 227 | + | |
| 228 | +def test_venues_directory(tmp_path): | |
| 229 | + """Annuaire de salles : apprentissage, matching prudent, application.""" | |
| 230 | + from sortika import db, venues | |
| 231 | + con = db.connect(tmp_path / "t.db") | |
| 232 | + venues.ensure_schema(con) | |
| 233 | + now = 0 | |
| 234 | + # 2 événements géocodés par la source (même salle) + 2 sans GPS | |
| 235 | + rows = [ | |
| 236 | + ("a:1", "a", "1", "MTelus", "Montréal", 45.51, -73.56), | |
| 237 | + ("a:2", "a", "2", "MTelus", "Montréal", 45.51, -73.56), | |
| 238 | + ("b:1", "b", "1", "MTelus", "Montréal", None, None), | |
| 239 | + # nom composé : le segment le plus long doit matcher l'annuaire | |
| 240 | + ("b:2", "b", "2", "Théâtre Desjardins | MTelus", "Montréal", None, None), | |
| 241 | + # ville en conflit : ne doit JAMAIS matcher | |
| 242 | + ("b:3", "b", "3", "MTelus", "Québec", None, None), | |
| 243 | + ] | |
| 244 | + for uid, src, ext, venue, city, lat, lng in rows: | |
| 245 | + con.execute( | |
| 246 | + "INSERT INTO events (uid, source, external_id, title, venue, city," | |
| 247 | + " lat, lng, active) VALUES (?,?,?,?,?,?,?,?,1)", | |
| 248 | + (uid, src, ext, "T", venue, city, lat, lng)) | |
| 249 | + con.commit() | |
| 250 | + assert venues.learn(con) >= 1 | |
| 251 | + stats = venues.apply(con) | |
| 252 | + assert stats["events_filled"] == 2 | |
| 253 | + got = con.execute("SELECT lat FROM events WHERE uid='b:1'").fetchone() | |
| 254 | + assert got["lat"] == 45.51 | |
| 255 | + conflict = con.execute("SELECT lat FROM events WHERE uid='b:3'").fetchone() | |
| 256 | + assert conflict["lat"] is None # jamais de GPS inventé | |
| 257 | + # alias : nom seul matché uniquement si UNIQUE dans l'annuaire | |
| 258 | + con.execute("INSERT INTO venues (name, name_key, city, city_key, lat, lng," | |
| 259 | + " origin) VALUES ('MTelus','mtelus','Trois-Rivières'," | |
| 260 | + "'trois rivieres', 46.34, -72.54, 'osm')") | |
| 261 | + con.commit() | |
| 262 | + assert venues._match_row(con, "MTelus", "Laval") is None # ambigu → rien | |
| 263 | + | |
| 264 | + | |
| 265 | +def test_venues_aliases(): | |
| 266 | + from sortika.venues import name_aliases | |
| 267 | + assert name_aliases("Salle Wilfrid-Pelletier, Place des Arts") == [ | |
| 268 | + "salle wilfrid pelletier place des arts", | |
| 269 | + "salle wilfrid pelletier", "place des arts"] | |
| 270 | + assert name_aliases("") == [] | |
| 271 | ||