SPB Git forge

spb/sorti-ka

Public

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

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

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 1 mo ago (Aug 19, 2026) parent 653ebdd

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
@@ -0,0 +1,89 @@
1 +<html><body><a class="item"
2 + href="/activites/confrontation-dimpro-musicale/"
3 + target="_self"
4 + data-lieu-id="5653"
5 + data-date-debut="2026-08-19"
6 + data-date-fin="2026-08-19"
7 + data-arrondissement="La Cit&#xE9;-Limoilou"
8 + data-categorie="Art,Musique"
9 + data-prix="Gratuit"
10 + >
11 + <div class="item-img">
12 + <img src="/media/s0clvkrg/limq_voletb_dsc9698-eburriel_emmanuel_burriel_confrontation-impro-musi.png?width=320&amp;height=180&amp;v=1dceeaee449bc30" alt="" loading="lazy" width="320" height="180">
13 +
14 + </div>
15 + <button class="my-activities-shortcut" data-id="8779__20260819__1800__parc-de-la-pointe-aux-li&#xE8;vres"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button>
16 +
17 +
18 +
19 + <div class="item-lieu">Parc de la Pointe-aux-Lièvres</div>
20 + <div class="item-titre">
21 + <h3>Confrontation d'improvisation musicale</h3>
22 + <p class="item-soustitre">Pique-nique animé : spectacle grand public</p>
23 + </div>
24 +
25 + <p class="item-info">
26 +
27 +19 août 2026 <span class="item-info-heuredebut"> &mdash; 18 h </span>
28 + <span class="item-info-heurefin">à 19 h </span>
29 +
30 + </p>
31 + </a>
32 +<a class="item"
33 + href="/activites/cinema-en-plein-air-de-radio-canada/"
34 + target="_self"
35 + data-lieu-id="5653"
36 + data-date-debut="2026-08-19"
37 + data-date-fin="2026-08-19"
38 + data-arrondissement="La Cit&#xE9;-Limoilou"
39 + data-categorie="Cin&#xE9;ma,&#xC9;v&#xE9;nement,Famille et jeunesse"
40 + data-prix="Gratuit"
41 + >
42 + <div class="item-img">
43 + <img src="/media/jvoijf3z/rc_photo_cine-dans-lparc.jpg?width=320&amp;height=180&amp;v=1dd0572e73b0b80" alt="" loading="lazy" width="320" height="180">
44 +
45 + </div>
46 + <button class="my-activities-shortcut" data-id="9069"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button>
47 +
48 +
49 +
50 + <div class="item-lieu">Parc de la Pointe-aux-Lièvres</div>
51 + <div class="item-titre">
52 + <h3>Cinéma en&nbsp;plein air de&nbsp;Radio-Canada</h3>
53 + </div>
54 +
55 + <p class="item-info">
56 +19 août 2026 <span class="item-info-heuredebut"> &mdash; 18 h 30</span>
57 +
58 + </p>
59 + </a>
60 +<a class="item"
61 + href="/activites/garcons-a-marier/"
62 + target="_self"
63 + data-lieu-id="5622"
64 + data-date-debut="2026-08-19"
65 + data-date-fin="2026-08-19"
66 + data-arrondissement="Beauport"
67 + data-categorie="&#xC9;v&#xE9;nement,Musique"
68 + data-prix="Gratuit"
69 + >
70 + <div class="item-img">
71 + <img src="/media/v3hbpfzs/garconsamarier.jpg?width=320&amp;height=180&amp;v=1dceeacf8ba8070" alt="" loading="lazy" width="320" height="180">
72 +
73 + </div>
74 + <button class="my-activities-shortcut" data-id="8767"><span class="my-activities-shortcut-label"><span>Ajouter à mon horaire</span></span></button>
75 +
76 +
77 +
78 + <div class="item-lieu">Maison Tessier-Dit-Laplante</div>
79 + <div class="item-titre">
80 + <h3>Garçons à&nbsp;marier</h3>
81 + <p class="item-soustitre">Concert en plein air</p>
82 + </div>
83 +
84 + <p class="item-info">
85 +19 août 2026 <span class="item-info-heuredebut"> &mdash; 19 h </span>
86 + <span class="item-info-heurefin">à 20 h 30</span>
87 +
88 + </p>
89 + </a></body></html>
\ No newline at end of file
added tests/fixtures/saguenaysorties_detail.html +32 −0
@@ -0,0 +1,32 @@
1 +
2 +
3 + <h3>Rencontre avec les archéologues sur le terrain</h3>
4 + Le public pourra rencontrer les archéologues qui réaliseront la dernière campagne de fouilles archéologiques de l&#039;histoire du poste de traite de Chicoutimi. Après plus de cinquante ans de recherches menées sur le site, cette intervention marquera la fin d&#039;un important chapitre de l&#039;archéologie régionale. Les archéologues seront présents principalement les jeudis, vendredis, samedis et dimanches, entre 8 h 30 et 15 h 30. Leur priorité sera la réalisation des fouilles archéologiques. Les visiteurs seront toutefois invités à observer le chantier, à suivre l&#039;évolution des recherches et à échanger avec les membres de l&#039;équipe lorsqu&#039;ils seront disponibles.
5 +
6 +
7 +
8 +
9 + <h4>Représentation(s)</h4>
10 +
11 + <div class="row small-collapse small-up-1 medium-up-2 representation quoi-faire-recherche">
12 +
13 +
14 +
15 + <div class="column column-block ">
16 + <p class="article-date float-left">
17 + 20 août 2026
18 + </p>
19 + <p class="article-date float-left">
20 + 08 h 30
21 + </p>
22 + <div class="clearfix"></div>
23 + <p>
24 + Chicoutimi,
25 + Poste-de-traite-de-Chicoutimi <br/> Bd du Saguenay O
26 + </p>
27 + </div>
28 +
29 + </div>
30 +
31 +
32 +
added tests/fixtures/saguenaysorties_liste.html +55 −0
@@ -0,0 +1,55 @@
1 +<article class="column column-block ">
2 + <div class="discipline_name">
3 + Histoire et Patrimoine
4 + </div>
5 + <div class="photo-container">
6 + <div class="photo">
7 + <img src="https://lvc-medias.lavitrine.com/77b5b9cf-95f5-517a-be63-82ed1e8e4806.jpg" alt="" />
8 + </div>
9 + </div>
10 +
11 + <p class="article-date float-left">
12 + 20 août 2026
13 + </p>
14 + <p class="article-date float-left">
15 + 08 h 30
16 + </p>
17 + <div class="clearfix"></div>
18 +
19 + <div class="text-container ">
20 + <h3><a data-open="modal-details" data-detail-id="497146" class="detail-open"> Rencontre avec les archéologues sur le terrain</a></h3>
21 + <p>
22 + Chicoutimi,
23 + Poste-de-traite-de-Chicoutimi
24 + </p>
25 + </div>
26 +
27 + </article>
28 + <article class="column column-block ">
29 + <div class="discipline_name">
30 + Histoire et Patrimoine
31 + </div>
32 + <div class="photo-container">
33 + <div class="photo">
34 + <img src="https://lvc-medias.lavitrine.com/a95bebe0-90f9-585c-9986-1c34abdc5811.jpg" alt="" />
35 + </div>
36 + </div>
37 +
38 + <p class="article-date float-left">
39 + 20 août 2026
40 + </p>
41 + <p class="article-date float-left">
42 + 09 h 00
43 + </p>
44 + <div class="clearfix"></div>
45 +
46 + <div class="text-container ">
47 + <h3><a data-open="modal-details" data-detail-id="447876" class="detail-open"> Visite guidée du site patrimonial du Poste-de-Traite-de-Chicoutimi</a></h3>
48 + <p>
49 + Saguenay,
50 + Site patrimonial du Poste de Traite de Chicoutimi
51 + </p>
52 + </div>
53 +
54 + </article>
55 +
\ No newline at end of file
added tests/fixtures/sallesjsonld_fiche.html +1 −0
@@ -0,0 +1 @@
1 +<html><body><script type="application/ld+json">{"@context": "http://schema.org", "@id": "albanie-2027-04-18-18-00", "@type": "Event", "description": "Rencontre avec Patrick BureauAprès des décennies d’isolement marquées par la dictature communiste d’Enver Hoxha (1944-1985), l’Albanie s’est ouverte sur le monde et connaît un essor touristique rapide depuis les années 1990. Ce petit pays des Balkans est un paradis secret situé entre la Grèce et le Monténégro. Sa capitale, Tirana, est aujourd’hui une ville dynamique et en pleine transformation, symbole du renouveau du pays. Découvrir l’Albanie, c’est explorer un pays à la nature spectaculaire, à l’histoire complexe et à l’identité forte. Elle attire aujourd’hui de plus en plus de visiteurs curieux de découvrir une destination encore préservée et authentique.Grâce à de nombreux périples à travers le pays, Patrick Bureau nous dévoile une terre de contrastes. Explorez ses montagnes sauvages au nord, ses plages turquoise le long de la mer Ionienne et ses villes et villages traditionnels classés au patrimoine mondial de l’UNESCO. Vous ne manquerez pas non plus de faire de captivantes rencontres avec la population locale.Ce voyage vous invite à découvrir l’un des pays les plus fascinants et méconnus des Balkans situé au carrefour de l’Orient et de l’Occident.Biographie de Patrick BureauSavoyard d’origine, Patrick Bureau a toujours été attiré par les voyages et l’aventure. Depuis plus de 20 ans, il sillonne le monde à la recherche d’une nature préservée et de peuples vivant encore selon des modes de vie et des traditions ancestrales. En tant que cinéaste indépendant, il a réalisé de nombreux documentaires qui lui ont permis de se joindre aux plus grandes organisations de ciné-conférences francophones en France, au Québec, en Belgique et en Suisse, où il a présenté ses derniers documentaires, dont celui sur l’Auvergne, une région de la France. Il a été diffusé aux Grands Explorateurs lors de la saison 2023-2024. Aujourd’hui, il a choisi de nous faire partager sa passion du voyage en nous faisant découvrir l’Albanie. Sa mission reste la même : créer des liens étroits avec la population locale pour vous proposer une vision authentique et au-delà des clichés de cet étonnant pays des Balkans.", "endDate": "2027-04-18T22:00:00+00:00", "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode", "eventStatus": "https://schema.org/EventScheduled", "image": {"@context": "http://schema.org", "@id": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "@type": "ImageObject", "contentSize": "137186", "contentUrl": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "height": "599 px", "name": "LGE_2627_Albanie_428x599", "thumbnail": "/uploads/images/LGE_2627_Albanie_428x599.max-200x200.jpg", "url": "/uploads/original_images/LGE_2627_Albanie_428x599.jpg", "width": "428 px"}, "location": {"@context": "http://schema.org", "@type": "Place", "address": {"@type": "PostalAddress", "addressCountry": "CA", "addressLocality": "Québec", "addressRegion": "QC", "postalCode": "G1R2B3", "streetAddress": "269 Boulevard René-Lévesque E"}, "description": "Le Grand Théâtre de Québec est un complexe artistique et culturel national situé dans la ville de Québec et comprenant deux salles de spectacle, de théâtre et d'opéra ainsi que plusieurs foyers.", "geo": {"latitude": "46.806228", "longitude": "-71.221983", "type": "http://schema.org/GeoCoordinates"}, "logo": {"@context": "http://schema.org", "@type": "ImageObject", "url": "https://www.grandtheatre.qc.ca/static/build/img/logo.png"}, "name": "Grand Théatre de Québec", "sameAs": ["https://www.facebook.com/grandtheatre", "https://twitter.com/GrandTheatreQc", "https://www.instagram.com/grandtheatreqc/", "https://www.youtube.com/channel/UCcrgPnIkEmTMe7tvJRiUMow", "https://fr.wikipedia.org/wiki/Grand_Th%C3%A9%C3%A2tre_de_Qu%C3%A9bec", "https://www.wikidata.org/wiki/Q3114610"], "url": "https://www.grandtheatre.qc.ca/"}, "name": "Les Grands Explorateurs", "offers": [{"@type": "Offer", "availability": "InStock", "price": "33.92", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}, {"@type": "Offer", "availability": "InStock", "price": "21.85", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}, {"@type": "Offer", "availability": "InStock", "price": "31.04", "priceCurrency": "CAD", "url": "https://am.ticketmaster.com/grandtheatre/fr-ca/buy/virtual-venue/TDIyNzQ1MDE%3D", "validFrom": null}], "organizer": {"@context": "http://schema.org", "@type": "Organization", "contactPoint": [{"@type": "ContactPoint", "areaServed": "CA", "availableLanguage": ["FR", "EN"], "contactType": "customer support", "email": "gtq@grandtheatre.qc.ca", "telephone": "+14186438131"}], "description": "Le Grand Théâtre de Québec est un complexe artistique et culturel national situé dans la ville de Québec et comprenant deux salles de spectacle, de théâtre et d'opéra ainsi que plusieurs foyers.", "logo": {"@context": "http://schema.org", "@type": "ImageObject", "url": "https://www.grandtheatre.qc.ca/static/build/img/logo.png"}, "name": "Grand Théâtre de Québec", "sameAs": ["https://www.facebook.com/grandtheatre", "https://twitter.com/GrandTheatreQc", "https://www.instagram.com/grandtheatreqc/", "https://www.youtube.com/channel/UCcrgPnIkEmTMe7tvJRiUMow", "https://fr.wikipedia.org/wiki/Grand_Th%C3%A9%C3%A2tre_de_Qu%C3%A9bec", "https://www.wikidata.org/wiki/Q3114610"], "url": "https://grandtheatre.qc.ca/"}, "performer": {"@context": "http://schema.org", "@id": "albanie-paradis-secret-deurope", "@type": "Person", "identifier": null, "name": "Albanie, paradis secret d’Europe", "sameAs": [], "url": null}, "startDate": "2027-04-18T18:00:00+00:00", "url": "https://www.grandtheatre.qc.ca/programmation/albanie/"}</script></body></html>
\ No newline at end of file
added tests/fixtures/sallesjsonld_liste.html +1 −0
@@ -0,0 +1 @@
1 +<html><body><a href="/programmation/albanie/">Albanie</a></body></html>
\ No newline at end of file
modified tests/test_connectors.py +9 −1
@@ -142,7 +142,15 @@ def test_sitq_parses_fixture(monkeypatch):
142 142 _patch_json(monkeypatch, c, [json.loads(
143 143 (FIXTURES / "sitq_sample.json").read_text(encoding="utf-8"))])
144 144 events = [e.finalize() for e in c.fetch()]
145 − assert len(events) == 4
145 + # Phase 3 : les périodes multiples (PeriodeOuvertures[]) sont déroulées en
146 + # occurrences datées — la fixture porte 4 fiches dont une à 2 périodes
147 + assert len(events) == 5
148 + suffixed = [e for e in events if e.external_id.endswith("-p1")]
149 + assert len(suffixed) == 1
150 + base = next(e for e in events
151 + if e.external_id == suffixed[0].external_id[:-3])
152 + assert base.title == suffixed[0].title
153 + assert base.start_date != suffixed[0].start_date
146 154 jack = next(e for e in events if e.title == "JACKALOPE")
147 155 assert jack.uid == "sitq:EVENMTOV500NIO"
148 156 assert jack.city == "Montréal"
added tests/test_expansion.py +270 −0
@@ -0,0 +1,270 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_expansion.py
4 +# Desc: Phase 3 (expansion des sources) — tests des nouveaux connecteurs sur
5 +# fixtures réelles + annuaire de salles + plages de dates françaises.
6 +# Aucun appel réseau (CLAUDE.md §18).
7 +# ==============================================================================
8 +from __future__ import annotations
9 +
10 +import json
11 +from pathlib import Path
12 +
13 +import pytest
14 +
15 +from sortika.connectors import CONNECTORS
16 +from sortika.normalize import parse_date_range_fr
17 +
18 +FIXTURES = Path(__file__).parent / "fixtures"
19 +
20 +
21 +def _no_cache(monkeypatch, connector):
22 + """Cache disque neutralisé : ni lecture ni écriture pendant les tests."""
23 + monkeypatch.setattr(connector, "load_cache", lambda: {})
24 + monkeypatch.setattr(connector, "save_cache", lambda cache: None)
25 +
26 +
27 +def test_expansion_registry_autodiscovers():
28 + for sid in ("lavalweb", "outaouais", "lanaudiere", "saguenaysorties",
29 + "bassaintlaurent", "quebecanimee", "placedesarts",
30 + "lhjmq", "sallesjsonld"):
31 + assert sid in CONNECTORS, sid
32 +
33 +
34 +def test_parse_date_range_fr():
35 + assert parse_date_range_fr("Du 11 au 13 septembre 2026") == \
36 + ("2026-09-11", "2026-09-13")
37 + assert parse_date_range_fr("Du 4 juin au 22 août 2026") == \
38 + ("2026-06-04", "2026-08-22")
39 + assert parse_date_range_fr("Du 27 août 2026 au 3 janvier 2027") == \
40 + ("2026-08-27", "2027-01-03")
41 + assert parse_date_range_fr("17 août 2026") == ("2026-08-17", "2026-08-17")
42 + assert parse_date_range_fr("n'importe quoi") == (None, None)
43 + assert parse_date_range_fr(None) == (None, None)
44 +
45 +
46 +def test_lavalweb_parses_fixture(monkeypatch):
47 + from sortika.connectors.lavalweb import LavalWebConnector
48 + c = LavalWebConnector()
49 + _no_cache(monkeypatch, c)
50 + html = (FIXTURES / "lavalweb_page.html").read_text(encoding="utf-8")
51 + pages = [html, "<html></html>"] # page 2 vide → arrêt pagination
52 + monkeypatch.setattr(c, "get",
53 + lambda url: type("R", (), {"text": pages.pop(0)})())
54 + monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None)
55 + events = [e.finalize() for e in c.fetch()]
56 + assert len(events) == 3
57 + ev = events[0]
58 + assert ev.source == "lavalweb"
59 + assert ev.city == "Laval" and ev.region == "Laval"
60 + assert ev.start_date and ev.start_date.startswith("20")
61 + assert ev.url.startswith("https://www.laval.ca/calendrier-activites/")
62 + # une carte-séance porte la date ET l'heure (« 18 août 2026 | 18 h »)
63 + seance = [e for e in events if e.start_time]
64 + assert seance and seance[0].venue
65 +
66 +
67 +def test_outaouais_parses_fixture(monkeypatch):
68 + from sortika.connectors.outaouais import OutaouaisConnector
69 + c = OutaouaisConnector()
70 + html = (FIXTURES / "outaouais_page.html").read_text(encoding="utf-8")
71 + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())
72 + events = [e.finalize() for e in c.fetch()]
73 + assert len(events) == 3
74 + ev = events[0]
75 + assert ev.source == "outaouais"
76 + assert ev.region == "Outaouais" # via tourist_region
77 + assert ev.start_date and ev.end_date
78 + assert ev.image.startswith("https://admin.tourismeoutaouais.com/")
79 +
80 +
81 +def test_lanaudiere_parses_fixture(monkeypatch):
82 + from sortika.connectors.lanaudiere import LanaudiereConnector
83 + c = LanaudiereConnector()
84 + _no_cache(monkeypatch, c)
85 + sitemap = (FIXTURES / "lanaudiere_sitemap.xml").read_text(encoding="utf-8")
86 + fiche = (FIXTURES / "lanaudiere_fiche.html").read_text(encoding="utf-8")
87 + responses = {True: sitemap}
88 + monkeypatch.setattr(
89 + c, "get", lambda url: type("R", (), {
90 + "text": sitemap if url.endswith(".xml") else fiche})())
91 + events = [e.finalize() for e in c.fetch()]
92 + # 2 URLs françaises dans le sitemap (l'anglaise est exclue), même fiche
93 + # → dédupliquées par external_id distinct des slugs
94 + assert 1 <= len(events) <= 2
95 + ev = events[0]
96 + assert ev.source == "lanaudiere"
97 + assert ev.title == "Festival Mémoire et Racines"
98 + assert ev.city == "Saint-Charles-Borromée"
99 + assert ev.region == "Lanaudière"
100 + assert (ev.start_date, ev.end_date) == ("2026-07-17", "2026-07-26")
101 + assert "tourinsoft" in ev.image
102 +
103 +
104 +def test_saguenaysorties_parses_fixture(monkeypatch):
105 + from sortika.connectors.saguenaysorties import SaguenaySortiesConnector
106 + c = SaguenaySortiesConnector()
107 + _no_cache(monkeypatch, c)
108 + liste = (FIXTURES / "saguenaysorties_liste.html").read_text(encoding="utf-8")
109 + detail = (FIXTURES / "saguenaysorties_detail.html").read_text(encoding="utf-8")
110 + monkeypatch.setattr(
111 + c, "_post", lambda url, data: detail if url.endswith("details")
112 + else (liste if data["offset"] == "0" else ""))
113 + events = [e.finalize() for e in c.fetch()]
114 + assert len(events) == 2
115 + ev = events[0]
116 + assert ev.source == "saguenaysorties"
117 + assert ev.city and ev.venue
118 + assert ev.region == "Saguenay–Lac-Saint-Jean"
119 + assert ev.start_date == "2026-08-20" and ev.start_time == "08:30"
120 + assert ev.url.startswith("https://sorties.saguenay.ca/festivals?id=")
121 + assert "archéologues" in ev.description # détail ajax branché
122 +
123 +
124 +def test_bassaintlaurent_parses_fixture(monkeypatch):
125 + from sortika.connectors.bassaintlaurent import BasSaintLaurentConnector
126 + c = BasSaintLaurentConnector()
127 + _no_cache(monkeypatch, c)
128 + liste = (FIXTURES / "bassaintlaurent_liste.html").read_text(encoding="utf-8")
129 + fiche = (FIXTURES / "bassaintlaurent_fiche.html").read_text(encoding="utf-8")
130 + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())
131 + monkeypatch.setattr(c, "fetch_many",
132 + lambda urls, worker, max_workers=3:
133 + {u: c._parse_fiche(fiche) for u in urls})
134 + events = [e.finalize() for e in c.fetch()]
135 + assert len(events) == 2
136 + ev = events[0]
137 + assert ev.source == "bassaintlaurent"
138 + assert ev.region == "Bas-Saint-Laurent"
139 + assert ev.start_date and ev.end_date and ev.city
140 + assert ev.lat and 44.0 < ev.lat < 63.0 # GPS exact de la fiche
141 +
142 +
143 +def test_quebecanimee_parses_fixture(monkeypatch):
144 + from sortika.connectors.quebecanimee import QuebecAnimeeConnector
145 + c = QuebecAnimeeConnector()
146 + _no_cache(monkeypatch, c)
147 + html = (FIXTURES / "quebecanimee_liste.html").read_text(encoding="utf-8")
148 + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())
149 + monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None)
150 + events = [e.finalize() for e in c.fetch()]
151 + assert len(events) == 3
152 + ev = events[0]
153 + assert ev.source == "quebecanimee"
154 + assert ev.city == "Québec" and ev.region == "Capitale-Nationale"
155 + assert ev.start_date and ev.start_date.startswith("2026")
156 + # data-prix « Gratuit » → gratuité réelle, jamais inventée
157 + assert any(e.is_free for e in events)
158 +
159 +
160 +def test_placedesarts_parses_fixture(monkeypatch):
161 + from sortika.connectors.placedesarts import PlaceDesArtsConnector
162 + c = PlaceDesArtsConnector()
163 + _no_cache(monkeypatch, c)
164 + fiche = (FIXTURES / "placedesarts_fiche.html").read_text(encoding="utf-8")
165 + liste = '<a href="/evenement/24-poses-portraits">x</a>'
166 + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())
167 + monkeypatch.setattr(c, "fetch_many",
168 + lambda paths, worker, max_workers=4:
169 + {p: c._parse_fiche(fiche) for p in paths})
170 + events = [e.finalize() for e in c.fetch()]
171 + assert len(events) >= 3 # une fiche = N séances
172 + ev = events[0]
173 + assert ev.source == "placedesarts"
174 + assert ev.venue == "Théâtre Jean-Duceppe"
175 + assert ev.city == "Montréal" and ev.organizer == "Duceppe"
176 + assert ev.start_time and len(ev.start_time) == 5
177 + # prix réels de la barre latérale (« 33 $ à 97,50 $ »)
178 + assert ev.price_min == 33.0 and ev.is_free is False
179 + # deux séances distinctes = deux dedup_key distincts (heure incluse)
180 + assert events[0].dedup_key() != events[1].dedup_key()
181 +
182 +
183 +def test_lhjmq_parses_fixture(monkeypatch):
184 + from sortika.connectors.lhjmq import LhjmqConnector
185 + c = LhjmqConnector()
186 + seasons = json.loads((FIXTURES / "lhjmq_seasons.json").read_text(encoding="utf-8"))
187 + sched = json.loads((FIXTURES / "lhjmq_schedule.json").read_text(encoding="utf-8"))
188 + monkeypatch.setattr(c, "get_json",
189 + lambda url: seasons if "view=seasons" in url else sched)
190 + monkeypatch.setattr(c, "_current_seasons", lambda: ["214"])
191 + events = [e.finalize() for e in c.fetch()]
192 + # seuls les matchs à domicile AU QUÉBEC sont publiés (Terre-Neuve exclue)
193 + assert events and all(e.city != "Terre-Neuve" for e in events)
194 + ev = events[0]
195 + assert ev.source == "lhjmq"
196 + assert ev.title.startswith("LHJMQ : ")
197 + assert ev.venue and ev.start_date and ev.start_time
198 + assert "sport" in ev.categories
199 + assert ev.url.startswith("https://theqmjhl.ca/gamecentre/")
200 +
201 +
202 +def test_sallesjsonld_parses_fixture(monkeypatch):
203 + from sortika.connectors.sallesjsonld import SallesJsonLdConnector
204 + c = SallesJsonLdConnector()
205 + _no_cache(monkeypatch, c)
206 + liste = (FIXTURES / "sallesjsonld_liste.html").read_text(encoding="utf-8")
207 + fiche = (FIXTURES / "sallesjsonld_fiche.html").read_text(encoding="utf-8")
208 + salle = {"id": "grandtheatre", "nom": "Grand Théâtre de Québec",
209 + "listes": ["https://grandtheatre.qc.ca/programmation"],
210 + "motif": r"grandtheatre\.qc\.ca/programmation/[a-z0-9-]{3,}",
211 + "salle": "Grand Théâtre de Québec", "ville": "Québec"}
212 + monkeypatch.setattr(c, "_load_config", lambda: [salle])
213 + monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())
214 + monkeypatch.setattr(c, "fetch_many",
215 + lambda urls, worker, max_workers=3:
216 + {u: c._fiche_events(salle, u, fiche) for u in urls})
217 + events = [e.finalize() for e in c.fetch()]
218 + assert len(events) == 1
219 + ev = events[0]
220 + assert ev.source == "sallesjsonld"
221 + assert ev.venue and ev.city == "Québec"
222 + # le Grand Théâtre publie ses horodatages en UTC (+00:00) → convertis
223 + # en heure locale de Montréal (18:00 UTC = 14:00 EDT)
224 + assert ev.start_date == "2027-04-18"
225 + assert ev.start_time == "14:00"
226 +
227 +
228 +def test_venues_directory(tmp_path):
229 + """Annuaire de salles : apprentissage, matching prudent, application."""
230 + from sortika import db, venues
231 + con = db.connect(tmp_path / "t.db")
232 + venues.ensure_schema(con)
233 + now = 0
234 + # 2 événements géocodés par la source (même salle) + 2 sans GPS
235 + rows = [
236 + ("a:1", "a", "1", "MTelus", "Montréal", 45.51, -73.56),
237 + ("a:2", "a", "2", "MTelus", "Montréal", 45.51, -73.56),
238 + ("b:1", "b", "1", "MTelus", "Montréal", None, None),
239 + # nom composé : le segment le plus long doit matcher l'annuaire
240 + ("b:2", "b", "2", "Théâtre Desjardins | MTelus", "Montréal", None, None),
241 + # ville en conflit : ne doit JAMAIS matcher
242 + ("b:3", "b", "3", "MTelus", "Québec", None, None),
243 + ]
244 + for uid, src, ext, venue, city, lat, lng in rows:
245 + con.execute(
246 + "INSERT INTO events (uid, source, external_id, title, venue, city,"
247 + " lat, lng, active) VALUES (?,?,?,?,?,?,?,?,1)",
248 + (uid, src, ext, "T", venue, city, lat, lng))
249 + con.commit()
250 + assert venues.learn(con) >= 1
251 + stats = venues.apply(con)
252 + assert stats["events_filled"] == 2
253 + got = con.execute("SELECT lat FROM events WHERE uid='b:1'").fetchone()
254 + assert got["lat"] == 45.51
255 + conflict = con.execute("SELECT lat FROM events WHERE uid='b:3'").fetchone()
256 + assert conflict["lat"] is None # jamais de GPS inventé
257 + # alias : nom seul matché uniquement si UNIQUE dans l'annuaire
258 + con.execute("INSERT INTO venues (name, name_key, city, city_key, lat, lng,"
259 + " origin) VALUES ('MTelus','mtelus','Trois-Rivières',"
260 + "'trois rivieres', 46.34, -72.54, 'osm')")
261 + con.commit()
262 + assert venues._match_row(con, "MTelus", "Laval") is None # ambigu → rien
263 +
264 +
265 +def test_venues_aliases():
266 + from sortika.venues import name_aliases
267 + assert name_aliases("Salle Wilfrid-Pelletier, Place des Arts") == [
268 + "salle wilfrid pelletier place des arts",
269 + "salle wilfrid pelletier", "place des arts"]
270 + assert name_aliases("") == []
271