SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

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>
Simon-Pierre Boucher committed (Aug 19, 2026) parent 653ebdd Browse files

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&#039;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&#233;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&#233;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&#233;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&thinsp;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="{&quot;categoryUrl&quot;: [0, &quot;/quoi-faire/festivals-et-evenements&quot;], &quot;events&quot;: [1, [[0, {&quot;id&quot;: [0, &quot;69379d207e36e5aaa7518767&quot;], &quot;title&quot;: [0, &quot;Premiers royaumes d’Europe&quot;], &quot;slug&quot;: [0, &quot;&quot;], &quot;url&quot;: [0, &quot;https://www.museedelhistoire.ca/premiers-royaumes/?_ga=2.164185151.1465875516.1711477939-1738617796.1711477939&amp;_gl=1*1fmnd74*_ga*MTczODYxNzc5Ni4xNzExNDc3OTM5*_ga_6SV303E8VY*MTcxMTQ3NzkzOS4xLjEuMTcxMTQ3ODAzNi42MC4wLjA.&quot;], &quot;territory&quot;: [0, null], &quot;municipality&quot;: [0, null], &quot;photo&quot;: [0, &quot;/api/photos/file/couronne-field-museum-adam-vago-620x620.webp&quot;], &quot;focalX&quot;: [0, 50], &quot;focalY&quot;: [0, 50], &quot;startDate&quot;: [0, &quot;2024-04-05&quot;], &quot;endDate&quot;: [0, &quot;2025-01-19&quot;]}], [0, {&quot;id&quot;: [0, &quot;69379d1f7e36e5aaa75186b9&quot;], &quot;title&quot;: [0, &quot;SORTIES DES RANGS&quot;], &quot;slug&quot;: [0, &quot;&quot;], &quot;url&quot;: [0, &quot;https://www.museedelaguerre.ca/sorties-des-rangs/&quot;], &quot;territory&quot;: [0, null], &quot;municipality&quot;: [0, null], &quot;photo&quot;: [0, &quot;/api/photos/file/ld-mark-holleron-musee-canadien-de-la-gue-620x620.webp&quot;], &quot;focalX&quot;: [0, 50], &quot;focalY&quot;: [0, 50], &quot;startDate&quot;: [0, &quot;2024-05-28&quot;], &quot;endDate&quot;: [0, &quot;2025-01-05&quot;]}], [0, {&quot;id&quot;: [0, &quot;69379d137e36e5aaa7517f36&quot;], &quot;title&quot;: [0, &quot;Les Olympiques de Gatineau&quot;], &quot;slug&quot;: [0, &quot;&quot;], &quot;url&quot;: [0, &quot;https://centreslushpuppie.com/les-olympiques/&quot;], &quot;territory&quot;: [0, null], &quot;municipality&quot;: [0, null], &quot;photo&quot;: [0, &quot;/api/photos/file/ld-centre-slush-puppie-centre-slush-puppi-1-620x620.webp&quot;], &quot;focalX&quot;: [0, 50], &quot;focalY&quot;: [0, 50], &quot;startDate&quot;: [0, &quot;2024-12-01&quot;], &quot;endDate&quot;: [0, &quot;2025-03-24&quot;]}]]], &quot;initialFilterQuery&quot;: [0, {&quot;sectors&quot;: [0, {&quot;territories&quot;: [1, []], &quot;municipalities&quot;: [1, []]}], &quot;date&quot;: [0, {&quot;startDate&quot;: [0, &quot;&quot;], &quot;endDate&quot;: [0, &quot;&quot;]}]}], &quot;initialFilterDefinitions&quot;: [1, [[0, {&quot;type&quot;: [0, &quot;date&quot;], &quot;label&quot;: [0, &quot;Quand&quot;], &quot;options&quot;: [1, []], &quot;hasMany&quot;: [0, true]}], [0, {&quot;type&quot;: [0, &quot;sectors&quot;], &quot;label&quot;: [0, &quot;Où&quot;], &quot;options&quot;: [1, [[0, {&quot;name&quot;: [0, &quot;gatineau&quot;], &quot;label&quot;: [0, &quot;Gatineau&quot;]}], [0, {&quot;name&quot;: [0, &quot;la-vallee-de-la-gatineau&quot;], &quot;label&quot;: [0, &quot;La Vallée-de-la-Gatineau&quot;]}], [0, {&quot;name&quot;: [0, &quot;les-collines-de-loutaouais&quot;], &quot;label&quot;: [0, &quot;Les Collines-de-l'Outaouais&quot;]}], [0, {&quot;name&quot;: [0, &quot;petite-nation&quot;], &quot;label&quot;: [0, &quot;Petite Nation&quot;]}], [0, {&quot;name&quot;: [0, &quot;pontiac&quot;], &quot;label&quot;: [0, &quot;Pontiac&quot;]}], [0, {&quot;name&quot;: [0, &quot;territoire-exterieur-de-loutaouais&quot;], &quot;label&quot;: [0, &quot;Territoire extérieur de l'Outaouais&quot;]}]]], &quot;hasMany&quot;: [0, true]}]]], &quot;locale&quot;: [0, &quot;fr&quot;]}"></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&nbsp;$ à 97,50&nbsp;$
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&nbsp;$ à 97,50&nbsp;$
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&nbsp;$ à 97,50&nbsp;$
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&nbsp;$ à 97,50&nbsp;$
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&nbsp;$ à 97,50&nbsp;$
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
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/saguenaysorties_detail.html +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/saguenaysorties_liste.html +55 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/sallesjsonld_fiche.html +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/sallesjsonld_liste.html +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified tests/test_connectors.py +9 −1
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_expansion.py +270 −0
Large diff (271 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme