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 addr = _first(rec.get("Adresses"), default={}) or {} 106 addr = _first(rec.get("Adresses"), default={}) or {}
107 geo = _first(rec.get("Geolocalisations"), default={}) or {} 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 struct = rec.get("Structure") or {} 122 struct = rec.get("Structure") or {}
110 123
111 address = ", ".join( 124 address = ", ".join(
@@ -143,25 +156,28 @@ class SitqConnector(BaseConnector):
143 contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c) 156 contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c)
144 description = f"Renseignements : {contacts}" if contacts else "" 157 description = f"Renseignements : {contacts}" if contacts else ""
145 158
146− events.append(Event( 159+ for pi, period in enumerate(periods):
147− source=self.source_id, 160+ events.append(Event(
148− external_id=ext_id, 161+ source=self.source_id,
149− url=website or "https://www.quebecoriginal.com/", 162+ # 1re période = identifiant historique (zéro churn) ;
150− title=title, 163+ # périodes supplémentaires suffixées -p<i>
151− description=description, 164+ external_id=ext_id if pi == 0 else f"{ext_id}-p{pi}",
152− raw_categories=raw_cats, 165+ url=website or "https://www.quebecoriginal.com/",
153− audience=audience, 166+ title=title,
154− venue=addr.get("Nomdupointdeservice", "") or "", 167+ description=description,
155− address=address, 168+ raw_categories=raw_cats,
156− city=addr.get("Municipalite", "") or "", 169+ audience=audience,
157− tourist_region=tourist_region, 170+ venue=addr.get("Nomdupointdeservice", "") or "",
158− postal_code=addr.get("CodePostal", "") or "", 171+ address=address,
159− lat=geo.get("Latitude"), 172+ city=addr.get("Municipalite", "") or "",
160− lng=geo.get("Longitude"), 173+ tourist_region=tourist_region,
161− start_date=period.get("Datedebut"), 174+ postal_code=addr.get("CodePostal", "") or "",
162− end_date=period.get("Datefin"), 175+ lat=geo.get("Latitude"),
163− organizer=struct.get("Name", "") or "", 176+ lng=geo.get("Longitude"),
164− website=website, 177+ start_date=period.get("Datedebut"),
165− )) 178+ end_date=period.get("Datefin"),
179+ organizer=struct.get("Name", "") or "",
180+ website=website,
181+ ))
166 self._enrich_images(events) 182 self._enrich_images(events)
167 return events 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