Phase 3 — 9 nouveaux connecteurs + déroulage des périodes SITQ (fiches d évaluation avant connexion)
- lavalweb : calendrier VIVANT laval.ca (SSR paginé, ~470 fiches — remplace le jeu Données Québec mort à la source, consigné Phase 2) - saguenaysorties : portail sorties.saguenay.ca (ajax/plus + ajax/details, 445 séances horodatées — remplace le CSV mort de 2022) - lhjmq : API HockeyTech modulekit (matchs à domicile QC, 12 villes/10 régions) - placedesarts : fiches Craft (eventbannerdata/eventGTM + représentations) — une fiche PAR séance avec heure et PRIX RÉELS - sallesjsonld : moteur générique schema.org/Event configurable (data/salles_jsonld.json — amorce Grand Théâtre/Trident/Capitole ; heures UTC du Grand Théâtre converties en heure locale) - quebecanimee : activités gratuites Ville de Québec (attributs data-*) - lanaudiere : ATR via sitemap (Crawl-delay 2 s et Disallow /*? respectés) - outaouais : calendrier complet dans les props de l îlot Astro (1 GET) - bassaintlaurent : ATR SSR + GPS exact des fiches - sitq : PeriodeOuvertures[] déroulées en occurrences datées (cap 24, 1re période = identifiant historique) — 1 599 → 1 803 fiches - tests : 46 → 59 verts (fixtures réelles pour chaque connecteur + annuaire) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27 changed files +2,124 −22
added
data/salles_jsonld.json
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Sorti-Ka — Salles à JSON-LD Event (connecteur générique sallesjsonld). Ajouter une salle = 1 bloc : listes (pages programmation), motif (regex des liens de fiche), ville/salle par défaut. robots.txt vérifié salle par salle AVANT ajout (2026-08-19) — voir docs/connecteurs/sallesjsonld.md. Salles sondées SANS JSON-LD Event (ne pas ajouter sans re-sonder) : palaismontcalm.ca, lediamant.ca, imperialbell.com, mtelus.com, clubsoda.ca, theatrecorona.ca (evenko, déjà couvert). Auteur : Simon-Pierre Boucher — contact@spboucher.ai", | |
| 3 | + "salles": [ | |
| 4 | + { | |
| 5 | + "id": "grandtheatre", | |
| 6 | + "nom": "Grand Théâtre de Québec", | |
| 7 | + "listes": ["https://grandtheatre.qc.ca/programmation"], | |
| 8 | + "motif": "grandtheatre\\.qc\\.ca/programmation/[a-z0-9-]{3,}", | |
| 9 | + "salle": "Grand Théâtre de Québec", | |
| 10 | + "ville": "Québec", | |
| 11 | + "robots": "Allow: / (vérifié 2026-08-19)" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "id": "trident", | |
| 15 | + "nom": "Théâtre du Trident", | |
| 16 | + "listes": ["https://www.letrident.com/spectacles/"], | |
| 17 | + "motif": "letrident\\.com/piece/[a-z0-9-]{3,}", | |
| 18 | + "salle": "Théâtre du Trident", | |
| 19 | + "ville": "Québec", | |
| 20 | + "robots": "Disallow: (vide — tout permis, vérifié 2026-08-19)" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "id": "capitole", | |
| 24 | + "nom": "Théâtre Capitole (Gestev)", | |
| 25 | + "listes": ["https://theatrecapitole.com/calendrier-evenements/"], | |
| 26 | + "motif": "(theatrecapitole|gestev)\\.com/calendrier-evenements/[a-z0-9-]{3,}", | |
| 27 | + "salle": "Théâtre Capitole", | |
| 28 | + "ville": "Québec", | |
| 29 | + "robots": "Disallow: /cpresources/ /vendor/ seulement (vérifié 2026-08-19)" | |
| 30 | + } | |
| 31 | + ] | |
| 32 | +} | |
added
sortika/connectors/bassaintlaurent.py
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/bassaintlaurent.py : Tourisme Bas-Saint-Laurent — événements (ATR) | |
| 5 | +# Source : https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/ — | |
| 6 | +# WordPress (Bedrock), liste rendue serveur (~27 fiches à venir, | |
| 7 | +# vérifié 2026-08-19). | |
| 8 | +# Extraction: cartes .card--type-event (lien, plage de dates FR, titre, | |
| 9 | +# municipalité, image) + fiches détail incrémentales (cache | |
| 10 | +# data/bassaintlaurent_cache.json, BSL_FICHE_MAX/sync, re-visite | |
| 11 | +# 7 j) : og:description + GPS exact (coordonnées de la carte | |
| 12 | +# embarquées dans la fiche). | |
| 13 | +# Accès : pages publiques de l'ATR, rendu serveur — GET throttlés 0,8 s, | |
| 14 | +# identification honnête, lien vers la fiche originale. | |
| 15 | +# Décision : le Bas-Saint-Laurent n'avait que 168 fiches au baseline — | |
| 16 | +# équilibre régional (04-SOURCES-CANDIDATES §A). | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import os | |
| 21 | +import re | |
| 22 | +import time | |
| 23 | + | |
| 24 | +from ..normalize import clean_text, parse_date_range_fr | |
| 25 | +from ..schema import Event | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://www.bassaintlaurent.ca" | |
| 29 | +LIST_URL = BASE + "/fr/quoi-faire/evenements/" | |
| 30 | +FICHE_MAX = int(os.environ.get("BSL_FICHE_MAX", "15")) | |
| 31 | +REFRESH_AFTER = 7 * 86400 | |
| 32 | + | |
| 33 | +_CARD_RE = re.compile( | |
| 34 | + r'<a href="(https://www\.bassaintlaurent\.ca/fr/quoi-faire/evenements/' | |
| 35 | + r'[^"]+)"[^>]*class="card-location card--type-event">(.*?)</a>', re.S) | |
| 36 | +_DATE_RE = re.compile(r'card-location__dates">([^<]+)<') | |
| 37 | +_TITLE_RE = re.compile(r'card-location__title">([^<]+)<') | |
| 38 | +_CITY_RE = re.compile(r'card-location__city">(?:<[^>]+>)*([^<]+)<') | |
| 39 | +_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"') | |
| 40 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 41 | +_GPS_RE = re.compile( | |
| 42 | + r'"lat(?:itude)?"\s*[:=]\s*"?(-?\d{2}\.\d+)"?\s*,\s*' | |
| 43 | + r'"(?:lng|lon|longitude)"\s*[:=]\s*"?(-?\d{2}\.\d+)"?') | |
| 44 | + | |
| 45 | + | |
| 46 | +class BasSaintLaurentConnector(BaseConnector): | |
| 47 | + source_id = "bassaintlaurent" | |
| 48 | + request_delay = 0.8 | |
| 49 | + | |
| 50 | + def _parse_fiche(self, html: str) -> dict: | |
| 51 | + m = _OG_DESC_RE.search(html) | |
| 52 | + gps = _GPS_RE.search(html) | |
| 53 | + return { | |
| 54 | + "desc": clean_text(m.group(1)) if m else "", | |
| 55 | + "lat": float(gps.group(1)) if gps else None, | |
| 56 | + "lng": float(gps.group(2)) if gps else None, | |
| 57 | + } | |
| 58 | + | |
| 59 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 60 | + cache = self.load_cache() | |
| 61 | + now = time.time() | |
| 62 | + current = {r["url"] for r in rows} | |
| 63 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 64 | + to_fetch = sorted( | |
| 65 | + (u for u in current | |
| 66 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 67 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 68 | + | |
| 69 | + def worker(url, session): | |
| 70 | + return self._parse_fiche(session.get(url, timeout=25).text) | |
| 71 | + | |
| 72 | + for url, det in self.fetch_many(to_fetch, worker, max_workers=3).items(): | |
| 73 | + cache[url] = {"ts": now, **(det or {"desc": "", "lat": None, "lng": None})} | |
| 74 | + self.save_cache(cache) | |
| 75 | + for r in rows: | |
| 76 | + det = cache.get(r["url"]) or {} | |
| 77 | + r["description"] = det.get("desc", "") | |
| 78 | + r["lat"], r["lng"] = det.get("lat"), det.get("lng") | |
| 79 | + | |
| 80 | + def fetch(self) -> list[Event]: | |
| 81 | + html = self.get(LIST_URL).text | |
| 82 | + rows: list[dict] = [] | |
| 83 | + for m in _CARD_RE.finditer(html): | |
| 84 | + url, card = m.group(1), m.group(2) | |
| 85 | + title = _TITLE_RE.search(card) | |
| 86 | + if not title: | |
| 87 | + continue | |
| 88 | + date_raw = _DATE_RE.search(card) | |
| 89 | + start, end = parse_date_range_fr( | |
| 90 | + date_raw.group(1) if date_raw else "") | |
| 91 | + city = _CITY_RE.search(card) | |
| 92 | + img = _IMG_RE.search(card) | |
| 93 | + rows.append({ | |
| 94 | + "external_id": url.rstrip("/").split("/")[-1], | |
| 95 | + "url": url, | |
| 96 | + "title": clean_text(title.group(1)), | |
| 97 | + "city": clean_text(city.group(1)) if city else "", | |
| 98 | + "start_date": start, "end_date": end, | |
| 99 | + "image": img.group(1) if img else "", | |
| 100 | + }) | |
| 101 | + self._enrich_fiches(rows) | |
| 102 | + | |
| 103 | + events: list[Event] = [] | |
| 104 | + seen: set[str] = set() | |
| 105 | + for r in rows: | |
| 106 | + if r["external_id"] in seen: | |
| 107 | + continue | |
| 108 | + seen.add(r["external_id"]) | |
| 109 | + events.append(Event( | |
| 110 | + source=self.source_id, | |
| 111 | + external_id=r["external_id"], | |
| 112 | + url=r["url"], | |
| 113 | + title=r["title"], | |
| 114 | + description=r.get("description", ""), | |
| 115 | + raw_categories=[r["title"]], | |
| 116 | + city=r["city"], | |
| 117 | + tourist_region="Bas-Saint-Laurent", | |
| 118 | + lat=r.get("lat"), lng=r.get("lng"), | |
| 119 | + start_date=r["start_date"], | |
| 120 | + end_date=r["end_date"], | |
| 121 | + image=r["image"], | |
| 122 | + )) | |
| 123 | + return events | |
added
sortika/connectors/lanaudiere.py
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lanaudiere.py : Tourisme Lanaudière — événements (ATR, Phase 3) | |
| 5 | +# Source : https://lanaudiere.ca/fr/evenements-lanaudiere — CMS Umbraco, | |
| 6 | +# médias Tourinsoft (mto.media.tourinsoft.eu — même gisement que | |
| 7 | +# le SIT Québec, recoupement géré par dedup_key). La liste rendue | |
| 8 | +# serveur ne montre que ~10 cartes (le reste charge en JS) → on | |
| 9 | +# passe par le SITEMAP officiel (robots.txt), qui publie TOUTES | |
| 10 | +# les fiches /fr/evenements-lanaudiere/<slug>/ (~44 à venir, | |
| 11 | +# vérifié 2026-08-19). | |
| 12 | +# Extraction: sitemap.xml (1 GET) → fiches détail incrémentales (cache | |
| 13 | +# data/lanaudiere_cache.json, LAN_FICHE_MAX/sync, re-visite 7 j) : | |
| 14 | +# en-tête structuré member-title-1-1 (titre, plage de dates FR | |
| 15 | +# « Du 17 au 26 juillet 2026 », municipalité) + og:image + | |
| 16 | +# og:description. Pas d'heure ni tarif structurés → jamais inventés. | |
| 17 | +# Accès : robots.txt : « Disallow: /*? » (aucune URL à paramètres | |
| 18 | +# requêtée) + « Crawl-delay: 2 » → GET séquentiels 2,1 s. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import os | |
| 23 | +import re | |
| 24 | +import time | |
| 25 | + | |
| 26 | +from ..normalize import clean_text, parse_date_range_fr | |
| 27 | +from ..schema import Event | |
| 28 | +from .base import BaseConnector | |
| 29 | + | |
| 30 | +BASE = "https://lanaudiere.ca" | |
| 31 | +SITEMAP_URL = BASE + "/sitemap.xml" | |
| 32 | +FICHE_MAX = int(os.environ.get("LAN_FICHE_MAX", "10")) | |
| 33 | +REFRESH_AFTER = 7 * 86400 | |
| 34 | + | |
| 35 | +_SITEMAP_RE = re.compile( | |
| 36 | + r"<loc>\s*(https://lanaudiere\.ca/fr/evenements-lanaudiere/" | |
| 37 | + r"[a-z0-9-]+/?)\s*</loc>") | |
| 38 | +_TITLE_RE = re.compile(r'member-title-1-1__title">\s*([^<]+?)\s*<') | |
| 39 | +_DATE_RE = re.compile(r'member-title-1-1__subtitle">\s*([^<]+?)\s*<') | |
| 40 | +_CITY_RE = re.compile(r'member-title-1-1__location"><span>([^<]+)<') | |
| 41 | +_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') | |
| 42 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 43 | + | |
| 44 | + | |
| 45 | +class LanaudiereConnector(BaseConnector): | |
| 46 | + source_id = "lanaudiere" | |
| 47 | + request_delay = 2.1 # Crawl-delay: 2 (robots.txt) | |
| 48 | + | |
| 49 | + def _parse_fiche(self, url: str, html: str) -> dict | None: | |
| 50 | + title = _TITLE_RE.search(html) | |
| 51 | + if not title: | |
| 52 | + return None | |
| 53 | + date_raw = _DATE_RE.search(html) | |
| 54 | + start, end = parse_date_range_fr( | |
| 55 | + date_raw.group(1) if date_raw else "") | |
| 56 | + city = _CITY_RE.search(html) | |
| 57 | + img = _OG_IMG_RE.search(html) | |
| 58 | + desc = _OG_DESC_RE.search(html) | |
| 59 | + return { | |
| 60 | + "external_id": url.rstrip("/").split("/")[-1], | |
| 61 | + "url": url, | |
| 62 | + "title": clean_text(title.group(1)), | |
| 63 | + "description": clean_text(desc.group(1)) if desc else "", | |
| 64 | + "city": clean_text(city.group(1)) if city else "", | |
| 65 | + "start_date": start, "end_date": end, | |
| 66 | + "image": clean_text(img.group(1)) if img else "", | |
| 67 | + } | |
| 68 | + | |
| 69 | + def fetch(self) -> list[Event]: | |
| 70 | + xml = self.get(SITEMAP_URL).text | |
| 71 | + urls = sorted({u.rstrip("/") + "/" for u in _SITEMAP_RE.findall(xml) | |
| 72 | + if not u.rstrip("/").endswith("evenements-lanaudiere")}) | |
| 73 | + cache = self.load_cache() | |
| 74 | + now = time.time() | |
| 75 | + current = set(urls) | |
| 76 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 77 | + to_fetch = sorted( | |
| 78 | + (u for u in urls | |
| 79 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 80 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 81 | + for url in to_fetch: # séquentiel : Crawl-delay respecté | |
| 82 | + try: | |
| 83 | + row = self._parse_fiche(url, self.get(url).text) | |
| 84 | + except Exception: | |
| 85 | + row = None | |
| 86 | + if row is None: | |
| 87 | + print(f"[sorti-ka] lanaudiere : fiche ignorée {url}") | |
| 88 | + cache[url] = {"ts": now, "row": None} | |
| 89 | + continue | |
| 90 | + cache[url] = {"ts": now, "row": row} | |
| 91 | + self.save_cache(cache) | |
| 92 | + | |
| 93 | + events: list[Event] = [] | |
| 94 | + seen: set[str] = set() | |
| 95 | + for entry in cache.values(): | |
| 96 | + r = entry.get("row") | |
| 97 | + if not r or r["external_id"] in seen: | |
| 98 | + continue | |
| 99 | + seen.add(r["external_id"]) | |
| 100 | + events.append(Event( | |
| 101 | + source=self.source_id, | |
| 102 | + external_id=r["external_id"], | |
| 103 | + url=r["url"], | |
| 104 | + title=r["title"], | |
| 105 | + description=r.get("description", ""), | |
| 106 | + raw_categories=[r["title"]], | |
| 107 | + city=r["city"], | |
| 108 | + tourist_region="Lanaudière", | |
| 109 | + start_date=r["start_date"], | |
| 110 | + end_date=r["end_date"], | |
| 111 | + image=r["image"], | |
| 112 | + )) | |
| 113 | + return events | |
added
sortika/connectors/lavalweb.py
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lavalweb.py : Ville de Laval — calendrier VIVANT laval.ca (Phase 3) | |
| 5 | +# Remplace la source « laval » (jeu Données Québec MORT À LA SOURCE depuis | |
| 6 | +# 2025-12, consigné Phase 2). L'ancien connecteur reste branché (réveil | |
| 7 | +# automatique si la Ville republie) ; le recoupement éventuel est géré par | |
| 8 | +# dedup_key. | |
| 9 | +# Source : https://www.laval.ca/calendrier-activites/ — WordPress, listes | |
| 10 | +# rendues serveur, pagination ?findstr[group]=events&findstr[page]=N | |
| 11 | +# (~58 pages × 8 cartes ≈ 460 activités à venir, vérifié 2026-08-19). | |
| 12 | +# Extraction: cartes .event-item (date FR « 18 août 2026 | 18 h », titre, | |
| 13 | +# lieu, lien, image lazy) + fiches détail incrémentales (cache | |
| 14 | +# data/lavalweb_cache.json, LAVALWEB_FICHE_MAX/sync, re-visite | |
| 15 | +# 7 j) : og:description. GPS via annuaire de salles/géocodeur. | |
| 16 | +# Accès : robots.txt laval.ca : « Disallow: /wp-admin/ » seulement — les | |
| 17 | +# pages calendrier sont libres. GET throttlés 0,5 s, UA honnête. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_date_range_fr, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://www.laval.ca" | |
| 30 | +LIST_URL = BASE + "/calendrier-activites/?findstr%5Bgroup%5D=events&findstr%5Bpage%5D={page}" | |
| 31 | +PAGES_MAX = int(os.environ.get("LAVALWEB_PAGES_MAX", "60")) | |
| 32 | +FICHE_MAX = int(os.environ.get("LAVALWEB_FICHE_MAX", "30")) | |
| 33 | +REFRESH_AFTER = 7 * 86400 | |
| 34 | + | |
| 35 | +_CARD_RE = re.compile(r'class="event-item">(.*?)</a>', re.S) | |
| 36 | +_LINK_RE = re.compile(r'href="(/calendrier-activites/[^"?#]+)"') | |
| 37 | +_DATE_RE = re.compile(r'event-item__date">([^<]+)<') | |
| 38 | +_TITLE_RE = re.compile(r'event-item__title"><span>([^<]+)<') | |
| 39 | +_VENUE_RE = re.compile(r'event-item__localisation">\s*<span>([^<]+)<') | |
| 40 | +_IMG_RE = re.compile(r'data-lazy-src="(https://www\.laval\.ca/wp-content/[^"]+)"') | |
| 41 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 42 | + | |
| 43 | + | |
| 44 | +class LavalWebConnector(BaseConnector): | |
| 45 | + source_id = "lavalweb" | |
| 46 | + request_delay = 0.5 | |
| 47 | + | |
| 48 | + def _parse_page(self, html: str) -> list[dict]: | |
| 49 | + rows = [] | |
| 50 | + for m in _CARD_RE.finditer(html): | |
| 51 | + card = m.group(1) | |
| 52 | + link = _LINK_RE.search(card) | |
| 53 | + title = _TITLE_RE.search(card) | |
| 54 | + if not link or not title: | |
| 55 | + continue | |
| 56 | + date_raw = _DATE_RE.search(card) | |
| 57 | + # « 18 août 2026 | 18 h » (séance) ou « Du 4 juin au 22 août 2026 » | |
| 58 | + start = end = start_time = None | |
| 59 | + if date_raw: | |
| 60 | + txt = clean_text(date_raw.group(1)) | |
| 61 | + parts = txt.split("|") | |
| 62 | + start, end = parse_date_range_fr(parts[0]) | |
| 63 | + if len(parts) > 1: | |
| 64 | + start_time = parse_time(parts[1]) | |
| 65 | + venue = _VENUE_RE.search(card) | |
| 66 | + img = _IMG_RE.search(card) | |
| 67 | + slug = link.group(1).strip("/").split("/")[-1] | |
| 68 | + rows.append({ | |
| 69 | + "external_id": slug, | |
| 70 | + "url": BASE + link.group(1), | |
| 71 | + "title": clean_text(title.group(1)), | |
| 72 | + "venue": clean_text(venue.group(1)) if venue else "", | |
| 73 | + "start_date": start, "end_date": end, "start_time": start_time, | |
| 74 | + "image": img.group(1) if img else "", | |
| 75 | + }) | |
| 76 | + return rows | |
| 77 | + | |
| 78 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 79 | + """og:description des fiches — incrémental, plafonné, re-visite 7 j.""" | |
| 80 | + cache = self.load_cache() | |
| 81 | + now = time.time() | |
| 82 | + current = {r["url"] for r in rows} | |
| 83 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 84 | + to_fetch = sorted( | |
| 85 | + (u for u in current | |
| 86 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 87 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 88 | + | |
| 89 | + def worker(url, session): | |
| 90 | + m = _OG_DESC_RE.search(session.get(url, timeout=20).text) | |
| 91 | + return clean_text(m.group(1)) if m else "" | |
| 92 | + | |
| 93 | + for url, desc in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 94 | + cache[url] = {"ts": now, "desc": desc or ""} | |
| 95 | + self.save_cache(cache) | |
| 96 | + for r in rows: | |
| 97 | + r["description"] = (cache.get(r["url"]) or {}).get("desc", "") | |
| 98 | + | |
| 99 | + def fetch(self) -> list[Event]: | |
| 100 | + rows: list[dict] = [] | |
| 101 | + for page in range(1, PAGES_MAX + 1): | |
| 102 | + html = self.get(LIST_URL.format(page=page)).text | |
| 103 | + batch = self._parse_page(html) | |
| 104 | + if not batch: | |
| 105 | + break | |
| 106 | + rows.extend(batch) | |
| 107 | + self._enrich_fiches(rows) | |
| 108 | + | |
| 109 | + events: list[Event] = [] | |
| 110 | + seen: set[str] = set() | |
| 111 | + for r in rows: | |
| 112 | + if r["external_id"] in seen: | |
| 113 | + continue | |
| 114 | + seen.add(r["external_id"]) | |
| 115 | + events.append(Event( | |
| 116 | + source=self.source_id, | |
| 117 | + external_id=r["external_id"], | |
| 118 | + url=r["url"], | |
| 119 | + title=r["title"], | |
| 120 | + description=r.get("description", ""), | |
| 121 | + raw_categories=[r["title"]], | |
| 122 | + venue=r["venue"], | |
| 123 | + city="Laval", | |
| 124 | + region="Laval", | |
| 125 | + start_date=r["start_date"], | |
| 126 | + start_time=r["start_time"], | |
| 127 | + end_date=r["end_date"], | |
| 128 | + image=r["image"], | |
| 129 | + organizer="Ville de Laval", | |
| 130 | + )) | |
| 131 | + return events | |
added
sortika/connectors/lhjmq.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lhjmq.py : LHJMQ — matchs des 12 équipes québécoises (Phase 3) | |
| 5 | +# Source : API HockeyTech « modulekit » (lscluster.hockeytech.com) — le | |
| 6 | +# flux JSON public qui motorise le site officiel theqmjhl.ca | |
| 7 | +# (clé publique embarquée dans les sites de la ligue, largement | |
| 8 | +# documentée). 2 GET/sync : saisons + calendrier de la saison | |
| 9 | +# courante (~580 matchs, dont ~384 à domicile au Québec, | |
| 10 | +# vérifié 2026-08-19 pour 2026-27). | |
| 11 | +# Extraction: date + heure locale exactes, aréna (venue_name), équipes, | |
| 12 | +# statut. Seuls les matchs À DOMICILE au Québec sont publiés | |
| 13 | +# (Chicoutimi, Baie-Comeau, Victoriaville, Blainville-Boisbriand, | |
| 14 | +# Sherbrooke, Gatineau, Québec, Rouyn-Noranda, Rimouski, | |
| 15 | +# Val-d'Or, Shawinigan, Drummondville). Prix non publié par la | |
| 16 | +# ligue → jamais inventé. Lien = fiche de match officielle. | |
| 17 | +# Accès : usage index + lien vers theqmjhl.ca ; GET throttlés, | |
| 18 | +# identification honnête. Couverture régionale forte | |
| 19 | +# (Abitibi, Côte-Nord, Bas-Saint-Laurent, Saguenay…). | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +from datetime import date | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +FEED = ("https://lscluster.hockeytech.com/feed/?feed=modulekit&view={view}" | |
| 30 | + "&key=f322673b6bcae299&fmt=json&client_code=lhjmq&lang=fr{extra}") | |
| 31 | +GAME_URL = "https://theqmjhl.ca/gamecentre/{gid}" | |
| 32 | + | |
| 33 | +# villes des équipes QC → la ville affichée sur la fiche Sorti-Ka | |
| 34 | +QC_TEAM_CITIES = { | |
| 35 | + "Chicoutimi": "Saguenay", "Baie-Comeau": "Baie-Comeau", | |
| 36 | + "Victoriaville": "Victoriaville", "Blainville-Boisbriand": "Boisbriand", | |
| 37 | + "Sherbrooke": "Sherbrooke", "Gatineau": "Gatineau", "Québec": "Québec", | |
| 38 | + "Rouyn-Noranda": "Rouyn-Noranda", "Rimouski": "Rimouski", | |
| 39 | + "Val-d'Or": "Val-d'Or", "Shawinigan": "Shawinigan", | |
| 40 | + "Drummondville": "Drummondville", | |
| 41 | +} | |
| 42 | + | |
| 43 | + | |
| 44 | +def _label(team: dict, side: str) -> str: | |
| 45 | + """« Remparts de Québec » à partir de city/nickname ; repli sur le nom brut.""" | |
| 46 | + nick = clean_text(team.get(f"{side}_team_nickname") or "") | |
| 47 | + city = clean_text(team.get(f"{side}_team_city") or "") | |
| 48 | + if nick and city: | |
| 49 | + return f"{nick} ({city})" | |
| 50 | + return clean_text(team.get(f"{side}_team_name") or "") | |
| 51 | + | |
| 52 | + | |
| 53 | +class LhjmqConnector(BaseConnector): | |
| 54 | + source_id = "lhjmq" | |
| 55 | + request_delay = 1.0 | |
| 56 | + | |
| 57 | + def _current_seasons(self) -> list[str]: | |
| 58 | + """Saisons non terminées (régulière + séries), les plus récentes.""" | |
| 59 | + data = self.get_json(FEED.format(view="seasons", extra="")) | |
| 60 | + seasons = (data.get("SiteKit") or {}).get("Seasons") or [] | |
| 61 | + today = date.today().isoformat() | |
| 62 | + keep = [s["season_id"] for s in seasons | |
| 63 | + if (s.get("end_date") or "") >= today] | |
| 64 | + return sorted(keep, key=int)[-2:] # au plus 2 (régulière + séries) | |
| 65 | + | |
| 66 | + def fetch(self) -> list[Event]: | |
| 67 | + events: list[Event] = [] | |
| 68 | + seen: set[str] = set() | |
| 69 | + for sid in self._current_seasons(): | |
| 70 | + data = self.get_json(FEED.format( | |
| 71 | + view="schedule", extra=f"&season_id={sid}")) | |
| 72 | + for g in (data.get("SiteKit") or {}).get("Schedule") or []: | |
| 73 | + home_city = clean_text(g.get("home_team_city") or "") | |
| 74 | + if home_city not in QC_TEAM_CITIES: | |
| 75 | + continue | |
| 76 | + gid = str(g.get("game_id") or "") | |
| 77 | + d = g.get("date_played") or "" | |
| 78 | + if not gid or not d or gid in seen: | |
| 79 | + continue | |
| 80 | + seen.add(gid) | |
| 81 | + home, vis = _label(g, "home"), _label(g, "visiting") | |
| 82 | + heure = parse_time(g.get("schedule_time") or "") | |
| 83 | + venue = clean_text(g.get("venue_name") or "") | |
| 84 | + status = "" | |
| 85 | + if str(g.get("game_status") or "").lower().startswith( | |
| 86 | + ("cancel", "annul")): | |
| 87 | + status = "cancelled" | |
| 88 | + events.append(Event( | |
| 89 | + source=self.source_id, | |
| 90 | + external_id=gid, | |
| 91 | + url=GAME_URL.format(gid=gid), | |
| 92 | + title=f"LHJMQ : {vis} c. {home}", | |
| 93 | + description=(f"Match de la Ligue de hockey junior " | |
| 94 | + f"Maritimes Québec (LHJMQ) — {vis} en " | |
| 95 | + f"visite chez {home}, {venue}."), | |
| 96 | + raw_categories=["hockey", "sport"], | |
| 97 | + venue=venue, | |
| 98 | + city=QC_TEAM_CITIES[home_city], | |
| 99 | + start_date=d, | |
| 100 | + start_time=heure, | |
| 101 | + status=status, | |
| 102 | + organizer="LHJMQ", | |
| 103 | + )) | |
| 104 | + return events | |
added
sortika/connectors/outaouais.py
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/outaouais.py : Tourisme Outaouais — calendrier des événements (ATR) | |
| 5 | +# Source : https://tourismeoutaouais.com/calendrier-des-evenements — site | |
| 6 | +# Astro : la page embarque TOUT le calendrier dans les props | |
| 7 | +# sérialisées de l'îlot <astro-island CalendrierDesEvenements> | |
| 8 | +# (162 événements dont 25 à venir, vérifié 2026-08-19) → | |
| 9 | +# UNE SEULE requête par sync. | |
| 10 | +# Extraction: props JSON (format Svelte [type, valeur] déballé) : id, titre, | |
| 11 | +# slug/url (interne /attraits/<slug> ou site officiel externe), | |
| 12 | +# municipalité, territoire, photo (CDN admin.tourismeoutaouais.com), | |
| 13 | +# startDate/endDate ISO. Pas d'heure ni de tarif publiés → jamais | |
| 14 | +# inventés. GPS via annuaire de salles/géocodeur central. | |
| 15 | +# Accès : robots.txt « Allow: / » — GET direct, identification honnête. | |
| 16 | +# Décision : comble l'échec Gatineau (site 100 % JS, consigné 2026-08-18) — | |
| 17 | +# l'Outaouais n'avait que 337 fiches au baseline Phase 3. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import html as _html | |
| 22 | +import json | |
| 23 | +import re | |
| 24 | + | |
| 25 | +from ..normalize import clean_text | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://tourismeoutaouais.com" | |
| 30 | +ADMIN = "https://admin.tourismeoutaouais.com" | |
| 31 | +LIST_URL = BASE + "/calendrier-des-evenements" | |
| 32 | + | |
| 33 | +def _unwrap(v): | |
| 34 | + """Déballe le format de sérialisation Astro/Svelte : [0, valeur] scalaire, | |
| 35 | + [1, [...]] liste — récursif, prudent.""" | |
| 36 | + if isinstance(v, list) and len(v) == 2 and v[0] in (0, 1): | |
| 37 | + return _unwrap(v[1]) | |
| 38 | + if isinstance(v, list): | |
| 39 | + return [_unwrap(x) for x in v] | |
| 40 | + if isinstance(v, dict): | |
| 41 | + return {k: _unwrap(x) for k, x in v.items()} | |
| 42 | + return v | |
| 43 | + | |
| 44 | + | |
| 45 | +def _extract_events(page_html: str) -> list[dict]: | |
| 46 | + """Événements des props de l'îlot calendrier ([] si structure changée).""" | |
| 47 | + idx = page_html.find("CalendrierDesEvenements") | |
| 48 | + if idx < 0: | |
| 49 | + return [] | |
| 50 | + m = re.search(r'props="([^"]*)"', page_html[max(0, idx - 2000):]) | |
| 51 | + if not m: | |
| 52 | + return [] | |
| 53 | + try: | |
| 54 | + data = _unwrap(json.loads(_html.unescape(m.group(1)))) | |
| 55 | + except Exception: | |
| 56 | + return [] | |
| 57 | + events = data.get("events") if isinstance(data, dict) else None | |
| 58 | + return [e for e in (events or []) if isinstance(e, dict)] | |
| 59 | + | |
| 60 | + | |
| 61 | +class OutaouaisConnector(BaseConnector): | |
| 62 | + source_id = "outaouais" | |
| 63 | + request_delay = 0.8 | |
| 64 | + | |
| 65 | + def fetch(self) -> list[Event]: | |
| 66 | + html = self.get(LIST_URL).text | |
| 67 | + events: list[Event] = [] | |
| 68 | + seen: set[str] = set() | |
| 69 | + for e in _extract_events(html): | |
| 70 | + ext_id = str(e.get("id") or "") | |
| 71 | + title = clean_text(e.get("title") or "") | |
| 72 | + if not ext_id or not title or ext_id in seen: | |
| 73 | + continue | |
| 74 | + seen.add(ext_id) | |
| 75 | + url = e.get("url") or "" | |
| 76 | + if url.startswith("/"): | |
| 77 | + url = BASE + url | |
| 78 | + if not url.startswith("http"): | |
| 79 | + url = LIST_URL | |
| 80 | + photo = e.get("photo") or "" | |
| 81 | + if photo.startswith("/"): | |
| 82 | + photo = ADMIN + photo | |
| 83 | + muni_slug = e.get("municipality") or "" | |
| 84 | + if muni_slug.startswith("municipalite-exterieure"): | |
| 85 | + muni_slug = "" # « extérieur de la région » ≠ ville | |
| 86 | + muni = clean_text(muni_slug.replace("-", " ")).title() | |
| 87 | + events.append(Event( | |
| 88 | + source=self.source_id, | |
| 89 | + external_id=ext_id, | |
| 90 | + url=url, | |
| 91 | + title=title, | |
| 92 | + raw_categories=[title, clean_text(e.get("territory") or "")], | |
| 93 | + city=muni, | |
| 94 | + tourist_region="Outaouais", | |
| 95 | + start_date=e.get("startDate"), | |
| 96 | + end_date=e.get("endDate") or e.get("startDate"), | |
| 97 | + image=photo, | |
| 98 | + )) | |
| 99 | + return events | |
added
sortika/connectors/placedesarts.py
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/placedesarts.py : Place des Arts — calendrier multi-salles (Phase 3) | |
| 5 | +# Source : https://www.placedesarts.com/programmation (Craft CMS) — | |
| 6 | +# ~97 spectacles à l'affiche sur les 6 salles (Wilfrid-Pelletier, | |
| 7 | +# Maison symphonique, Théâtre Maisonneuve, Jean-Duceppe, | |
| 8 | +# Cinquième Salle, Claude-Léveillée), vérifié 2026-08-19. | |
| 9 | +# Extraction: liste /programmation (1 GET) → fiches /evenement/<slug> | |
| 10 | +# incrémentales (cache data/placedesarts_cache.json, | |
| 11 | +# PDA_FICHE_MAX/sync, re-visite 7 j). Chaque fiche embarque : | |
| 12 | +# · #eventbannerdata (JSON) : titre, url, image, plage de dates ; | |
| 13 | +# · #eventGTM (JSON) : salle(s), producteur, discipline ; | |
| 14 | +# · barre latérale #sb-performances : CHAQUE représentation avec | |
| 15 | +# heure (« 19h30 »), date FR complète et PRIX RÉELS | |
| 16 | +# (« 33 $ à 97,50 $ ») → une fiche Sorti-Ka PAR séance | |
| 17 | +# (dedup_key par heure, patron Phase 2). | |
| 18 | +# Accès : robots.txt n'interdit que /cpresources/ /vendor/ /cache/ — | |
| 19 | +# GET throttlés 0,8 s, identification honnête, lien billetterie | |
| 20 | +# officiel. Recoupement lavitrine/evenko géré par dedup_key. | |
| 21 | +# ----------------------------------------------------------------------------- | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import json | |
| 25 | +import os | |
| 26 | +import re | |
| 27 | +import time | |
| 28 | + | |
| 29 | +from ..normalize import clean_text, parse_date_fr, parse_date_range_fr, parse_time | |
| 30 | +from ..schema import Event | |
| 31 | +from .base import BaseConnector | |
| 32 | + | |
| 33 | +BASE = "https://www.placedesarts.com" | |
| 34 | +LIST_URL = BASE + "/programmation" | |
| 35 | +FICHE_MAX = int(os.environ.get("PDA_FICHE_MAX", "25")) | |
| 36 | +REFRESH_AFTER = 7 * 86400 | |
| 37 | + | |
| 38 | +_LINK_RE = re.compile(r'href="(?:https://www\.placedesarts\.com)?(/evenement/[a-z0-9-]+)"') | |
| 39 | +_BANNER_RE = re.compile(r'id="eventbannerdata">\s*(\{.*?\})\s*</script>', re.S) | |
| 40 | +_GTM_RE = re.compile(r'id="eventGTM">\s*(\{.*?\})\s*</script>', re.S) | |
| 41 | + | |
| 42 | + | |
| 43 | +class PlaceDesArtsConnector(BaseConnector): | |
| 44 | + source_id = "placedesarts" | |
| 45 | + request_delay = 0.8 | |
| 46 | + | |
| 47 | + def _parse_fiche(self, html: str) -> dict | None: | |
| 48 | + m = _BANNER_RE.search(html) | |
| 49 | + if not m: | |
| 50 | + return None | |
| 51 | + try: | |
| 52 | + banner = json.loads(m.group(1)) | |
| 53 | + except Exception: | |
| 54 | + return None | |
| 55 | + gtm = {} | |
| 56 | + mg = _GTM_RE.search(html) | |
| 57 | + if mg: | |
| 58 | + try: | |
| 59 | + gtm = json.loads(mg.group(1)) | |
| 60 | + except Exception: | |
| 61 | + gtm = {} | |
| 62 | + # représentations de la barre latérale (texte : heure, date, prix) | |
| 63 | + i = html.find('id="sb-performances"') | |
| 64 | + perfs = [] | |
| 65 | + if i >= 0: | |
| 66 | + seg = re.sub(r"\s+", " ", html[i:i + 60000]) | |
| 67 | + txt = re.sub(r"<[^>]+>", "|", seg) | |
| 68 | + for pm in re.finditer( | |
| 69 | + r"(\d{1,2}h\d{2})[|\s]+" | |
| 70 | + r"([A-Za-zÀ-ÿ]+ \d{1,2}(?:er)? [a-zà-ÿ]+ \d{4})" | |
| 71 | + r"(?:[|\s]+([^|]*\$[^|]*))?", txt): | |
| 72 | + heure = parse_time(pm.group(1)) | |
| 73 | + date_iso = parse_date_fr(pm.group(2)) | |
| 74 | + if date_iso: | |
| 75 | + perfs.append({"date": date_iso, "time": heure, | |
| 76 | + "prix": clean_text(pm.group(3) or "")}) | |
| 77 | + start, end = parse_date_range_fr(banner.get("dates") or "") | |
| 78 | + return { | |
| 79 | + "title": clean_text(banner.get("title") or ""), | |
| 80 | + "url": banner.get("url") or "", | |
| 81 | + "image": banner.get("mainImage") or "", | |
| 82 | + "start": start, "end": end, | |
| 83 | + "passed": bool(banner.get("hasPassed")), | |
| 84 | + "venue": clean_text((gtm.get("eventPlace") or [""])[0]), | |
| 85 | + "organizer": clean_text(gtm.get("eventProducer") or ""), | |
| 86 | + "cats": [clean_text(c) for c in | |
| 87 | + (gtm.get("eventDiscipline") or []) + | |
| 88 | + (gtm.get("eventSubCategory") or [])], | |
| 89 | + "perfs": perfs, | |
| 90 | + } | |
| 91 | + | |
| 92 | + def fetch(self) -> list[Event]: | |
| 93 | + html = self.get(LIST_URL).text | |
| 94 | + paths = sorted(set(_LINK_RE.findall(html))) | |
| 95 | + cache = self.load_cache() | |
| 96 | + now = time.time() | |
| 97 | + current = set(paths) | |
| 98 | + cache = {p: c for p, c in cache.items() if p in current} | |
| 99 | + to_fetch = sorted( | |
| 100 | + (p for p in paths | |
| 101 | + if p not in cache or now - cache[p].get("ts", 0) > REFRESH_AFTER), | |
| 102 | + key=lambda p: cache.get(p, {}).get("ts", 0))[:FICHE_MAX] | |
| 103 | + | |
| 104 | + def worker(path, session): | |
| 105 | + return self._parse_fiche( | |
| 106 | + session.get(BASE + path, timeout=25).text) | |
| 107 | + | |
| 108 | + for path, row in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 109 | + if row is None: | |
| 110 | + print(f"[sorti-ka] placedesarts : fiche ignorée {path}") | |
| 111 | + continue | |
| 112 | + cache[path] = {"ts": now, "row": row} | |
| 113 | + self.save_cache(cache) | |
| 114 | + | |
| 115 | + events: list[Event] = [] | |
| 116 | + seen: set[str] = set() | |
| 117 | + for path, entry in cache.items(): | |
| 118 | + row = entry.get("row") | |
| 119 | + if not row or not row.get("title") or row.get("passed"): | |
| 120 | + continue | |
| 121 | + slug = path.strip("/").split("/")[-1] | |
| 122 | + base_kwargs = dict( | |
| 123 | + source=self.source_id, | |
| 124 | + url=row["url"] or BASE + path, | |
| 125 | + title=row["title"], | |
| 126 | + raw_categories=row["cats"] or [row["title"]], | |
| 127 | + venue=row["venue"], | |
| 128 | + city="Montréal", | |
| 129 | + region="Montréal", | |
| 130 | + organizer=row["organizer"], | |
| 131 | + image=row["image"], | |
| 132 | + ) | |
| 133 | + if row["perfs"]: | |
| 134 | + for p in row["perfs"]: | |
| 135 | + ext = f"{slug}-{p['date']}-{p['time'] or 'nd'}" | |
| 136 | + if ext in seen: | |
| 137 | + continue | |
| 138 | + seen.add(ext) | |
| 139 | + events.append(Event( | |
| 140 | + external_id=ext, | |
| 141 | + start_date=p["date"], start_time=p["time"], | |
| 142 | + end_date=p["date"], | |
| 143 | + price_label=p["prix"], | |
| 144 | + **base_kwargs)) | |
| 145 | + else: | |
| 146 | + if slug in seen: | |
| 147 | + continue | |
| 148 | + seen.add(slug) | |
| 149 | + events.append(Event( | |
| 150 | + external_id=slug, | |
| 151 | + start_date=row["start"], end_date=row["end"], | |
| 152 | + **base_kwargs)) | |
| 153 | + return events | |
added
sortika/connectors/quebecanimee.py
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/quebecanimee.py : Québec animée — Ville de Québec (Phase 3) | |
| 5 | +# Source : https://animee.ville.quebec.qc.ca/activites/ — portail officiel | |
| 6 | +# des activités gratuites/à faible coût dont la Ville est | |
| 7 | +# promotrice ou partenaire (73 fiches à venir, vérifié 2026-08-19). | |
| 8 | +# Comble l'absence de source municipale/gratuite pour la 2ᵉ ville | |
| 9 | +# du Québec (l'inventaire Capitale-Nationale était ~100 % billetteries). | |
| 10 | +# Extraction: la LISTE porte tout en attributs data- (1 requête) : | |
| 11 | +# data-date-debut / data-date-fin ISO, data-arrondissement, | |
| 12 | +# data-categorie, data-prix (« Gratuit ») + titre/image de la | |
| 13 | +# carte ; fiches détail incrémentales (cache | |
| 14 | +# data/quebecanimee_cache.json, QA_FICHE_MAX/sync, re-visite 7 j) : | |
| 15 | +# description, lieu (nom de salle/parc), heures si publiées. | |
| 16 | +# Accès : site municipal public rendu serveur — GET throttlés 0,8 s, | |
| 17 | +# identification honnête, lien vers la fiche officielle. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://animee.ville.quebec.qc.ca" | |
| 30 | +LIST_URL = BASE + "/activites/" | |
| 31 | +FICHE_MAX = int(os.environ.get("QA_FICHE_MAX", "20")) | |
| 32 | +REFRESH_AFTER = 7 * 86400 | |
| 33 | + | |
| 34 | +_CARD_RE = re.compile( | |
| 35 | + r'<a[^>]+href="(/activites/[a-z0-9-]+/)"[^>]*' | |
| 36 | + r'data-date-debut="([^"]*)"[^>]*data-date-fin="([^"]*)"[^>]*' | |
| 37 | + r'data-arrondissement="([^"]*)"[^>]*data-categorie="([^"]*)"[^>]*' | |
| 38 | + r'data-prix="([^"]*)"[^>]*>(.*?)</a>', re.S) | |
| 39 | +_TITLE_RE = re.compile(r'<h3[^>]*>\s*([^<]+?)\s*</h3>|<h2[^>]*>\s*([^<]+?)\s*</h2>') | |
| 40 | +_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"') | |
| 41 | +_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') | |
| 42 | +_LIEU_RE = re.compile(r'class="[^"]*lieu[^"]*"[^>]*>\s*(?:<[^>]+>\s*)*([^<]{3,120})') | |
| 43 | +_HEURE_RE = re.compile(r'(\d{1,2}\s*h(?:\s*\d{2})?)') | |
| 44 | + | |
| 45 | + | |
| 46 | +class QuebecAnimeeConnector(BaseConnector): | |
| 47 | + source_id = "quebecanimee" | |
| 48 | + request_delay = 0.8 | |
| 49 | + | |
| 50 | + def _parse_fiche(self, html: str) -> dict: | |
| 51 | + m = _OG_DESC_RE.search(html) | |
| 52 | + lieu = _LIEU_RE.search(html) | |
| 53 | + return { | |
| 54 | + "desc": clean_text(m.group(1)) if m else "", | |
| 55 | + "venue": clean_text(lieu.group(1)) if lieu else "", | |
| 56 | + } | |
| 57 | + | |
| 58 | + def _enrich_fiches(self, rows: list[dict]) -> None: | |
| 59 | + cache = self.load_cache() | |
| 60 | + now = time.time() | |
| 61 | + current = {r["url"] for r in rows} | |
| 62 | + cache = {u: c for u, c in cache.items() if u in current} | |
| 63 | + to_fetch = sorted( | |
| 64 | + (u for u in current | |
| 65 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 66 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:FICHE_MAX] | |
| 67 | + | |
| 68 | + def worker(url, session): | |
| 69 | + return self._parse_fiche(session.get(url, timeout=20).text) | |
| 70 | + | |
| 71 | + for url, det in self.fetch_many(to_fetch, worker, max_workers=4).items(): | |
| 72 | + cache[url] = {"ts": now, **(det or {"desc": "", "venue": ""})} | |
| 73 | + self.save_cache(cache) | |
| 74 | + for r in rows: | |
| 75 | + det = cache.get(r["url"]) or {} | |
| 76 | + r["description"] = det.get("desc", "") | |
| 77 | + r["venue"] = det.get("venue", "") | |
| 78 | + | |
| 79 | + def fetch(self) -> list[Event]: | |
| 80 | + html = self.get(LIST_URL).text | |
| 81 | + rows: list[dict] = [] | |
| 82 | + for m in _CARD_RE.finditer(html): | |
| 83 | + path, debut, fin, arrond, cats, prix, inner = m.groups() | |
| 84 | + t = _TITLE_RE.search(inner) | |
| 85 | + title = clean_text((t.group(1) or t.group(2)) if t else "") | |
| 86 | + if not title: | |
| 87 | + # repli : dernier texte non vide de la carte | |
| 88 | + texts = [clean_text(x) for x in re.findall(r">([^<>]{4,120})<", inner)] | |
| 89 | + title = texts[-1] if texts else "" | |
| 90 | + if not title: | |
| 91 | + continue | |
| 92 | + img = _IMG_RE.search(inner) | |
| 93 | + slug = path.strip("/").split("/")[-1] | |
| 94 | + # la liste répète une carte PAR occurrence datée d'une même | |
| 95 | + # activité → l'identifiant inclut la date (séances distinctes) | |
| 96 | + rows.append({ | |
| 97 | + "external_id": f"{slug}-{debut or 'nd'}", | |
| 98 | + "url": BASE + path, | |
| 99 | + "title": title, | |
| 100 | + "start_date": debut or None, | |
| 101 | + "end_date": fin or debut or None, | |
| 102 | + "arrondissement": clean_text(arrond), | |
| 103 | + "cats": [c.strip() for c in clean_text(cats).split(",") if c.strip()], | |
| 104 | + "prix": clean_text(prix), | |
| 105 | + "image": img.group(1) if img else "", | |
| 106 | + }) | |
| 107 | + self._enrich_fiches(rows) | |
| 108 | + | |
| 109 | + events: list[Event] = [] | |
| 110 | + seen: set[str] = set() | |
| 111 | + for r in rows: | |
| 112 | + if r["external_id"] in seen: | |
| 113 | + continue | |
| 114 | + seen.add(r["external_id"]) | |
| 115 | + img = r["image"] | |
| 116 | + if img.startswith("/"): | |
| 117 | + img = BASE + img | |
| 118 | + events.append(Event( | |
| 119 | + source=self.source_id, | |
| 120 | + external_id=r["external_id"], | |
| 121 | + url=r["url"], | |
| 122 | + title=r["title"], | |
| 123 | + description=r.get("description", ""), | |
| 124 | + raw_categories=r["cats"] or [r["title"]], | |
| 125 | + venue=r.get("venue", ""), | |
| 126 | + city="Québec", | |
| 127 | + region="Capitale-Nationale", | |
| 128 | + start_date=r["start_date"], | |
| 129 | + end_date=r["end_date"], | |
| 130 | + price_label=r["prix"], | |
| 131 | + image=img, | |
| 132 | + organizer="Ville de Québec", | |
| 133 | + )) | |
| 134 | + return events | |
added
sortika/connectors/saguenaysorties.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/saguenaysorties.py : Sorties Saguenay — portail officiel de la | |
| 5 | +# Ville de Saguenay (Phase 3). Remplace le CSV Données Québec mort (2022). | |
| 6 | +# Source : https://sorties.saguenay.ca — portail événementiel municipal | |
| 7 | +# (motorisé par les données La Vitrine culturelle, médias | |
| 8 | +# lvc-medias.lavitrine.com ; recoupement géré par dedup_key). | |
| 9 | +# Extraction: POST /ajax/plus (offset par pas de 36, ~300-400 séances | |
| 10 | +# horodatées, vérifié 2026-08-19) — chaque <article> = UNE séance : | |
| 11 | +# discipline, date FR, heure « 08 h 30 », titre, « Ville, Salle », | |
| 12 | +# image, data-detail-id. Détails incrémentaux POST /ajax/details | |
| 13 | +# (cache data/saguenaysorties_cache.json, SAG_DETAIL_MAX/sync, | |
| 14 | +# re-visite 7 j) : description + adresse civique. | |
| 15 | +# Accès : portail public municipal, pas de robots.txt publié (page de | |
| 16 | +# maintenance) — volumes modestes, POST throttlés 0,8 s, UA | |
| 17 | +# honnête, lien vers la fiche officielle /festivals?id=<id>. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | +import time | |
| 24 | + | |
| 25 | +from ..normalize import clean_text, parse_date_fr, parse_time | |
| 26 | +from ..schema import Event | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://sorties.saguenay.ca" | |
| 30 | +AJAX_LIST = BASE + "/ajax/plus" | |
| 31 | +AJAX_DETAIL = BASE + "/ajax/details" | |
| 32 | +BATCH = 36 | |
| 33 | +BATCH_MAX = int(os.environ.get("SAG_BATCH_MAX", "15")) # ≤ 540 séances | |
| 34 | +DETAIL_MAX = int(os.environ.get("SAG_DETAIL_MAX", "40")) | |
| 35 | +REFRESH_AFTER = 7 * 86400 | |
| 36 | + | |
| 37 | +_DISC_RE = re.compile(r'discipline_name">\s*([^<]+?)\s*</div>', re.S) | |
| 38 | +_IMG_RE = re.compile(r'<img src="([^"]+)"') | |
| 39 | +_DATE_RE = re.compile(r'article-date[^>]*>\s*([^<]+?)\s*</p>', re.S) | |
| 40 | +_TITLE_RE = re.compile(r'data-detail-id="(\d+)"[^>]*>\s*([^<]+?)\s*</a>', re.S) | |
| 41 | +_PLACE_RE = re.compile(r'</h3>\s*<p>\s*(.*?)\s*</p>', re.S) | |
| 42 | +_ADDR_RE = re.compile(r'<br\s*/?>\s*([^<]+?)\s*</p>', re.S) | |
| 43 | + | |
| 44 | + | |
| 45 | +class SaguenaySortiesConnector(BaseConnector): | |
| 46 | + source_id = "saguenaysorties" | |
| 47 | + request_delay = 0.8 | |
| 48 | + | |
| 49 | + def _post(self, url: str, data: dict) -> str: | |
| 50 | + self._throttle() | |
| 51 | + resp = self.session.post(url, data=data, timeout=self.timeout) | |
| 52 | + self._last_request = time.time() | |
| 53 | + resp.raise_for_status() | |
| 54 | + return resp.text | |
| 55 | + | |
| 56 | + def _parse_batch(self, html: str) -> list[dict]: | |
| 57 | + rows = [] | |
| 58 | + for art in html.split("<article")[1:]: | |
| 59 | + m_title = _TITLE_RE.search(art) | |
| 60 | + if not m_title: | |
| 61 | + continue | |
| 62 | + dates = _DATE_RE.findall(art) | |
| 63 | + date_iso = parse_date_fr(dates[0]) if dates else None | |
| 64 | + heure = parse_time(dates[1]) if len(dates) > 1 else None | |
| 65 | + m_place = _PLACE_RE.search(art) | |
| 66 | + city = venue = "" | |
| 67 | + if m_place: | |
| 68 | + place = clean_text(m_place.group(1)) | |
| 69 | + if "," in place: | |
| 70 | + city, venue = (p.strip() for p in place.split(",", 1)) | |
| 71 | + else: | |
| 72 | + city = place | |
| 73 | + # événements multi-lieux : la carte liste TOUS les sites séparés | |
| 74 | + # par des virgules — un « lieu » de 100+ caractères n'est pas une | |
| 75 | + # salle, on ne garde que la ville (jamais un faux GPS de salle) | |
| 76 | + if len(venue) > 100: | |
| 77 | + venue = "" | |
| 78 | + if len(city) > 60: | |
| 79 | + city = "" | |
| 80 | + disc = _DISC_RE.search(art) | |
| 81 | + img = _IMG_RE.search(art) | |
| 82 | + rows.append({ | |
| 83 | + "detail_id": m_title.group(1), | |
| 84 | + "title": clean_text(m_title.group(2)), | |
| 85 | + "date": date_iso, "time": heure, | |
| 86 | + "city": city, "venue": venue, | |
| 87 | + "discipline": clean_text(disc.group(1)) if disc else "", | |
| 88 | + "image": img.group(1) if img else "", | |
| 89 | + }) | |
| 90 | + return rows | |
| 91 | + | |
| 92 | + def _details(self, ids: list[str]) -> dict: | |
| 93 | + """description + adresse par detail_id — incrémental, plafonné.""" | |
| 94 | + cache = self.load_cache() | |
| 95 | + now = time.time() | |
| 96 | + to_fetch = sorted( | |
| 97 | + (i for i in ids | |
| 98 | + if i not in cache or now - cache[i].get("ts", 0) > REFRESH_AFTER), | |
| 99 | + key=lambda i: cache.get(i, {}).get("ts", 0))[:DETAIL_MAX] | |
| 100 | + for did in to_fetch: | |
| 101 | + try: | |
| 102 | + html = self._post(AJAX_DETAIL, {"id": did}) | |
| 103 | + m = re.search(r"</h3>\s*(.*?)\s*<h4", html, re.S) | |
| 104 | + desc = clean_text(m.group(1)) if m else "" | |
| 105 | + m_addr = _ADDR_RE.search(html) | |
| 106 | + addr = clean_text(m_addr.group(1)) if m_addr else "" | |
| 107 | + cache[did] = {"ts": now, "desc": desc, "addr": addr} | |
| 108 | + except Exception: | |
| 109 | + cache[did] = {"ts": now, "desc": "", "addr": ""} | |
| 110 | + self.save_cache(cache) | |
| 111 | + return cache | |
| 112 | + | |
| 113 | + def fetch(self) -> list[Event]: | |
| 114 | + rows: list[dict] = [] | |
| 115 | + for i in range(BATCH_MAX): | |
| 116 | + html = self._post(AJAX_LIST, {"offset": str(i * BATCH), "page": "1"}) | |
| 117 | + batch = self._parse_batch(html) | |
| 118 | + rows.extend(batch) | |
| 119 | + if len(batch) < BATCH // 2: # dernière page (lots partiels vus) | |
| 120 | + break | |
| 121 | + details = self._details(sorted({r["detail_id"] for r in rows})) | |
| 122 | + | |
| 123 | + events: list[Event] = [] | |
| 124 | + seen: set[str] = set() | |
| 125 | + for r in rows: | |
| 126 | + ext = f"{r['detail_id']}-{r['date'] or 'nd'}-{r['time'] or 'nd'}" | |
| 127 | + if ext in seen or not r["title"]: | |
| 128 | + continue | |
| 129 | + seen.add(ext) | |
| 130 | + det = details.get(r["detail_id"]) or {} | |
| 131 | + events.append(Event( | |
| 132 | + source=self.source_id, | |
| 133 | + external_id=ext, | |
| 134 | + url=f"{BASE}/festivals?id={r['detail_id']}", | |
| 135 | + title=r["title"], | |
| 136 | + description=det.get("desc", ""), | |
| 137 | + raw_categories=[r["discipline"]] if r["discipline"] else [], | |
| 138 | + venue=r["venue"], | |
| 139 | + address=det.get("addr", ""), | |
| 140 | + city=r["city"], | |
| 141 | + tourist_region="Saguenay–Lac-Saint-Jean", | |
| 142 | + start_date=r["date"], | |
| 143 | + start_time=r["time"], | |
| 144 | + image=r["image"], | |
| 145 | + )) | |
| 146 | + return events | |
added
sortika/connectors/sallesjsonld.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/sallesjsonld.py : moteur générique « schema.org/Event » (Phase 3) | |
| 5 | +# La LONGUE TRAÎNE des salles de spectacle : beaucoup de sites de salles | |
| 6 | +# embarquent un bloc JSON-LD Event complet (dates horodatées, salle, offres, | |
| 7 | +# statut) sur chaque fiche. Ce connecteur prend une LISTE DE SALLES | |
| 8 | +# configurable (data/salles_jsonld.json, patron stores.json de fabri-ka) : | |
| 9 | +# chaque entrée = page(s) de programmation + motif des liens de fiche + | |
| 10 | +# ville/salle par défaut. Ajouter une salle = 1 bloc de config, 0 code. | |
| 11 | +# Extraction: page liste (1 GET/salle) → fiches incrémentales (cache | |
| 12 | +# data/sallesjsonld_cache.json, SALLES_FICHE_MAX/sync partagé, | |
| 13 | +# re-visite 7 j) → TOUS les blocs JSON-LD @type Event de la | |
| 14 | +# fiche (une fiche multi-dates = plusieurs séances). | |
| 15 | +# Salles d'amorce validées 2026-08-19 (Event JSON-LD réel constaté) : | |
| 16 | +# Grand Théâtre de Québec, Théâtre du Trident, Théâtre Capitole (Gestev). | |
| 17 | +# Accès : robots.txt vérifié PAR SALLE avant l'ajout à la config | |
| 18 | +# (consigné dans docs/connecteurs/sallesjsonld.md) ; GET | |
| 19 | +# throttlés 0,8 s, identification honnête, lien fiche originale. | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import json | |
| 24 | +import os | |
| 25 | +import re | |
| 26 | +import time | |
| 27 | +from pathlib import Path | |
| 28 | +from urllib.parse import urljoin | |
| 29 | + | |
| 30 | +from ..normalize import clean_text | |
| 31 | +from ..schema import Event, normalize_status | |
| 32 | +from .base import BaseConnector | |
| 33 | + | |
| 34 | +CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "salles_jsonld.json" | |
| 35 | +FICHE_MAX = int(os.environ.get("SALLES_FICHE_MAX", "40")) # total, toutes salles | |
| 36 | +REFRESH_AFTER = 7 * 86400 | |
| 37 | + | |
| 38 | +_LD_RE = re.compile(r'<script[^>]*type="application/ld\+json"[^>]*>') | |
| 39 | +_HREF_RE = re.compile(r'href="([^"#]+)"') | |
| 40 | + | |
| 41 | + | |
| 42 | +def ld_events(html: str) -> list[dict]: | |
| 43 | + """Tous les blocs JSON-LD de @type Event d'une page (listes et @graph | |
| 44 | + déballés), tolérant aux JSON malformés voisins.""" | |
| 45 | + out: list[dict] = [] | |
| 46 | + for m in _LD_RE.finditer(html): | |
| 47 | + try: | |
| 48 | + d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip()) | |
| 49 | + except Exception: | |
| 50 | + continue | |
| 51 | + docs = d if isinstance(d, list) else ( | |
| 52 | + d.get("@graph") if isinstance(d, dict) and "@graph" in d else [d]) | |
| 53 | + for doc in docs or []: | |
| 54 | + if isinstance(doc, dict) and "Event" in str(doc.get("@type", "")): | |
| 55 | + out.append(doc) | |
| 56 | + return out | |
| 57 | + | |
| 58 | + | |
| 59 | +def _first(v): | |
| 60 | + if isinstance(v, list): | |
| 61 | + return v[0] if v else None | |
| 62 | + return v | |
| 63 | + | |
| 64 | + | |
| 65 | +def _local_dt(iso: str) -> tuple[str | None, str | None]: | |
| 66 | + """Horodatage JSON-LD → (date, heure) locales Québec, sans inventer. | |
| 67 | + | |
| 68 | + Un décalage EXPLICITE (« +00:00 », « Z », « -05:00 ») est converti en | |
| 69 | + heure de Montréal (le Grand Théâtre publie ses heures en UTC — vérifié | |
| 70 | + 2026-08-19 : « 2026-08-19T23:30:00+00:00 » = 19 h 30 locales). Sans | |
| 71 | + décalage, l'heure est prise littérale. Minuit = placeholder « toute la | |
| 72 | + journée » (constaté sur les fiches Trident) → heure inconnue. | |
| 73 | + """ | |
| 74 | + from ..normalize import parse_date_iso, parse_time, utc_to_local | |
| 75 | + if not iso: | |
| 76 | + return None, None | |
| 77 | + if re.search(r"(Z|[+-]\d{2}:\d{2})$", iso) and "T" in iso: | |
| 78 | + return utc_to_local(iso) | |
| 79 | + t = parse_time(iso) if "T" in iso else None | |
| 80 | + return parse_date_iso(iso), (None if t == "00:00" else t) | |
| 81 | + | |
| 82 | + | |
| 83 | +class SallesJsonLdConnector(BaseConnector): | |
| 84 | + source_id = "sallesjsonld" | |
| 85 | + request_delay = 0.8 | |
| 86 | + | |
| 87 | + def _load_config(self) -> list[dict]: | |
| 88 | + try: | |
| 89 | + return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["salles"] | |
| 90 | + except Exception: | |
| 91 | + return [] | |
| 92 | + | |
| 93 | + def _fiche_events(self, salle: dict, url: str, html: str) -> list[dict]: | |
| 94 | + rows = [] | |
| 95 | + for ld in ld_events(html): | |
| 96 | + title = clean_text(str(ld.get("name") or "")) | |
| 97 | + start = str(ld.get("startDate") or "") | |
| 98 | + if not title or not start: | |
| 99 | + continue | |
| 100 | + loc = ld.get("location") | |
| 101 | + if isinstance(loc, list): | |
| 102 | + loc = loc[0] if loc else {} | |
| 103 | + loc = loc if isinstance(loc, dict) else {} | |
| 104 | + addr = loc.get("address") | |
| 105 | + addr = addr if isinstance(addr, dict) else {} | |
| 106 | + offers = ld.get("offers") | |
| 107 | + if isinstance(offers, list): | |
| 108 | + offers = offers[0] if offers else {} | |
| 109 | + offers = offers if isinstance(offers, dict) else {} | |
| 110 | + price_label = "" | |
| 111 | + low = offers.get("lowPrice") or offers.get("price") | |
| 112 | + if low not in (None, "", 0, "0"): | |
| 113 | + high = offers.get("highPrice") | |
| 114 | + price_label = (f"{low} $ à {high} $" if high and high != low | |
| 115 | + else f"{low} $") | |
| 116 | + img = _first(ld.get("image")) or "" | |
| 117 | + slug = url.rstrip("/").split("/")[-1] | |
| 118 | + date_part = start[:16].replace(":", "").replace("T", "-") | |
| 119 | + rows.append({ | |
| 120 | + "external_id": f"{salle['id']}-{slug}-{date_part}", | |
| 121 | + "url": url, | |
| 122 | + "title": title, | |
| 123 | + "description": clean_text(str(ld.get("description") or "")), | |
| 124 | + "venue": clean_text(str(loc.get("name") or "")) or salle.get("salle", ""), | |
| 125 | + "address": clean_text(str(addr.get("streetAddress") or "")), | |
| 126 | + "city": clean_text(str(addr.get("addressLocality") or "")) or salle.get("ville", ""), | |
| 127 | + "postal_code": str(addr.get("postalCode") or "").strip(), | |
| 128 | + "start": start, | |
| 129 | + "end": str(ld.get("endDate") or "") or start, | |
| 130 | + "status": normalize_status(str(ld.get("eventStatus") or "")), | |
| 131 | + "price_label": price_label, | |
| 132 | + "image": img if isinstance(img, str) else "", | |
| 133 | + "website": str(offers.get("url") or "") or url, | |
| 134 | + }) | |
| 135 | + return rows | |
| 136 | + | |
| 137 | + def fetch(self) -> list[Event]: | |
| 138 | + cache = self.load_cache() | |
| 139 | + now = time.time() | |
| 140 | + budget = FICHE_MAX | |
| 141 | + all_urls: set[str] = set() | |
| 142 | + for salle in self._load_config(): | |
| 143 | + motif = re.compile(salle["motif"]) | |
| 144 | + urls: set[str] = set() | |
| 145 | + for liste in salle.get("listes", []): | |
| 146 | + try: | |
| 147 | + html = self.get(liste).text | |
| 148 | + except Exception: | |
| 149 | + print(f"[sorti-ka] sallesjsonld : liste inaccessible {liste}") | |
| 150 | + continue | |
| 151 | + for href in _HREF_RE.findall(html): | |
| 152 | + full = urljoin(liste, href) | |
| 153 | + if motif.search(full): | |
| 154 | + urls.add(full.split("?")[0]) | |
| 155 | + all_urls |= urls | |
| 156 | + to_fetch = sorted( | |
| 157 | + (u for u in urls | |
| 158 | + if u not in cache or now - cache[u].get("ts", 0) > REFRESH_AFTER), | |
| 159 | + key=lambda u: cache.get(u, {}).get("ts", 0))[:max(0, budget)] | |
| 160 | + budget -= len(to_fetch) | |
| 161 | + | |
| 162 | + def worker(url, session, _salle=salle): | |
| 163 | + return self._fiche_events( | |
| 164 | + _salle, url, session.get(url, timeout=25).text) | |
| 165 | + | |
| 166 | + for url, rows in self.fetch_many(to_fetch, worker, | |
| 167 | + max_workers=3).items(): | |
| 168 | + cache[url] = {"ts": now, "rows": rows or []} | |
| 169 | + # on ne garde en cache que les fiches encore listées quelque part | |
| 170 | + cache = {u: c for u, c in cache.items() if u in all_urls} | |
| 171 | + self.save_cache(cache) | |
| 172 | + | |
| 173 | + events: list[Event] = [] | |
| 174 | + seen: set[str] = set() | |
| 175 | + for entry in cache.values(): | |
| 176 | + for r in entry.get("rows") or []: | |
| 177 | + if r["external_id"] in seen: | |
| 178 | + continue | |
| 179 | + seen.add(r["external_id"]) | |
| 180 | + sd, st = _local_dt(r["start"]) | |
| 181 | + ed, et = (_local_dt(r["end"]) | |
| 182 | + if r["end"] != r["start"] else (sd, None)) | |
| 183 | + events.append(Event( | |
| 184 | + source=self.source_id, | |
| 185 | + external_id=r["external_id"], | |
| 186 | + url=r["url"], | |
| 187 | + title=r["title"], | |
| 188 | + description=r["description"], | |
| 189 | + raw_categories=[r["title"]], | |
| 190 | + venue=r["venue"], | |
| 191 | + address=r["address"], | |
| 192 | + city=r["city"], | |
| 193 | + postal_code=r["postal_code"], | |
| 194 | + start_date=sd, | |
| 195 | + start_time=st, | |
| 196 | + end_date=ed, | |
| 197 | + end_time=et, | |
| 198 | + status=r["status"], | |
| 199 | + price_label=r["price_label"], | |
| 200 | + image=r["image"], | |
| 201 | + website=r["website"], | |
| 202 | + )) | |
| 203 | + return events | |
modified
sortika/connectors/sitq.py
+37 −21
@@ -105,7 +105,20 @@ class SitqConnector(BaseConnector): | ||
| 105 | 105 | |
| 106 | 106 | addr = _first(rec.get("Adresses"), default={}) or {} |
| 107 | 107 | geo = _first(rec.get("Geolocalisations"), default={}) or {} |
| 108 | − period = _first(rec.get("PeriodeOuvertures"), default={}) or {} | |
| 108 | + # Phase 3 : DÉROULAGE des périodes multiples (reporté de la | |
| 109 | + # Phase 2, audit 02 §4) — une occurrence datée PAR période | |
| 110 | + # publiée. Cap 24 : au-delà, c'est un calendrier d'ouverture | |
| 111 | + # hebdomadaire, on garde l'enveloppe min-max (jamais 40 fiches | |
| 112 | + # pour un même marché public). | |
| 113 | + periods = sorted( | |
| 114 | + (pp for pp in (rec.get("PeriodeOuvertures") or []) | |
| 115 | + if isinstance(pp, dict) and pp.get("Datedebut")), | |
| 116 | + key=lambda pp: str(pp.get("Datedebut"))) | |
| 117 | + if len(periods) > 24: | |
| 118 | + periods = [{"Datedebut": periods[0].get("Datedebut"), | |
| 119 | + "Datefin": periods[-1].get("Datefin")}] | |
| 120 | + if not periods: | |
| 121 | + periods = [{}] | |
| 109 | 122 | struct = rec.get("Structure") or {} |
| 110 | 123 | |
| 111 | 124 | address = ", ".join( |
@@ -143,25 +156,28 @@ class SitqConnector(BaseConnector): | ||
| 143 | 156 | contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c) |
| 144 | 157 | description = f"Renseignements : {contacts}" if contacts else "" |
| 145 | 158 | |
| 146 | − events.append(Event( | |
| 147 | − source=self.source_id, | |
| 148 | − external_id=ext_id, | |
| 149 | − url=website or "https://www.quebecoriginal.com/", | |
| 150 | − title=title, | |
| 151 | − description=description, | |
| 152 | − raw_categories=raw_cats, | |
| 153 | − audience=audience, | |
| 154 | − venue=addr.get("Nomdupointdeservice", "") or "", | |
| 155 | − address=address, | |
| 156 | − city=addr.get("Municipalite", "") or "", | |
| 157 | − tourist_region=tourist_region, | |
| 158 | − postal_code=addr.get("CodePostal", "") or "", | |
| 159 | − lat=geo.get("Latitude"), | |
| 160 | − lng=geo.get("Longitude"), | |
| 161 | − start_date=period.get("Datedebut"), | |
| 162 | − end_date=period.get("Datefin"), | |
| 163 | − organizer=struct.get("Name", "") or "", | |
| 164 | − website=website, | |
| 165 | − )) | |
| 159 | + for pi, period in enumerate(periods): | |
| 160 | + events.append(Event( | |
| 161 | + source=self.source_id, | |
| 162 | + # 1re période = identifiant historique (zéro churn) ; | |
| 163 | + # périodes supplémentaires suffixées -p<i> | |
| 164 | + external_id=ext_id if pi == 0 else f"{ext_id}-p{pi}", | |
| 165 | + url=website or "https://www.quebecoriginal.com/", | |
| 166 | + title=title, | |
| 167 | + description=description, | |
| 168 | + raw_categories=raw_cats, | |
| 169 | + audience=audience, | |
| 170 | + venue=addr.get("Nomdupointdeservice", "") or "", | |
| 171 | + address=address, | |
| 172 | + city=addr.get("Municipalite", "") or "", | |
| 173 | + tourist_region=tourist_region, | |
| 174 | + postal_code=addr.get("CodePostal", "") or "", | |
| 175 | + lat=geo.get("Latitude"), | |
| 176 | + lng=geo.get("Longitude"), | |
| 177 | + start_date=period.get("Datedebut"), | |
| 178 | + end_date=period.get("Datefin"), | |
| 179 | + organizer=struct.get("Name", "") or "", | |
| 180 | + website=website, | |
| 181 | + )) | |
| 166 | 182 | self._enrich_images(events) |
| 167 | 183 | return events |
added
tests/fixtures/bassaintlaurent_fiche.html
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +<html><head><meta property="og:description" content="Grand rendez-vous festif marquant la fin de la saison estivale. | |
| 2 | + | |
| 3 | +Dégustez les excellentes bières de microbrasseries de partout au Québec et cassez la croûte avec d'excellents produits du terroir. | |
| 4 | + | |
| 5 | +Animation sur place et site accessible gratuitement au coeur du Vieux Rivière-du-Loup." /></head><body><script>js"></script><script id="mlmi-locator-data" type="text/javascript"> | |
| 6 | +window.locations = [{"id":6461,"title":"Bi\u00e8re Fest Rivi\u00e8re-du-Loup","display_name":"Bi\u00e8re Fest Rivi\u00e8re-du-Loup","lat":47.8306127999999972644218360073864459991455078125,"lng":-69.534986900000006926347850821912288665771484375,"coords":{"address":"Parc Blais, rue Lafontaine, Rivi\u00e8re-du-Loup, Qu\u00e9bec, Canada","l</script></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/bassaintlaurent_liste.html
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +<html><body><a href="https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/degelis-ta-riviere-tappelle/" target="_self" aria-label="Dégelis, ta rivière t’appelle" class="card-location card--type-event"> | |
| 2 | + <div class="featured card-location__featured"><i class="icon-star"></i><span>En vedette</span></div><div class="favorite"> | |
| 3 | + <button class="favorite__toggle" data-content-uid="ahekl-pera-fkz1ef-ffxt5of-jn63kh"></button> | |
| 4 | +</div> | |
| 5 | + <div class="card-location__image"> | |
| 6 | + <picture class="picture grid-picture-33851"> | |
| 7 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-666x518.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb.png.webp 2x" type="image/webp"> | |
| 8 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-666x518.png 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb.png 2x"> | |
| 9 | + <source srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-868x675.png.webp 2x" type="image/webp"> | |
| 10 | + <img onload="picture(this)" src="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png" srcset="https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-434x337.png 1x, https://www.bassaintlaurent.ca/app/uploads/2026/07/Image-siteweb-868x675.png 2x" alt=""> | |
| 11 | +</picture> | |
| 12 | + </div> | |
| 13 | + <div class="card-location__content"> <div class="card-location__dates">Du 27 au 30 août 2026</div> <p class="card-location__title">Dégelis, ta rivière t’appelle</p> | |
| 14 | + <div class="card-location__city"><i class="icon-pin-2"></i>Dégelis</div> </div> </a> | |
| 15 | +<a href="https://www.bassaintlaurent.ca/fr/quoi-faire/evenements/riviere-du-loup-en-spectacles/" target="_self" aria-label="Rivière-du-Loup en spectacles" class="card-location card--type-event"> | |
| 16 | + <div class="favorite"> | |
| 17 | + <button class="favorite__toggle" data-content-uid="kyttwxchbxegedh8-grj4afyykhq-88y"></button> | |
| 18 | +</div> | |
| 19 | + <div class="card-location__image"> | |
| 20 | + <picture class="picture grid-picture-15187"> | |
| 21 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-666x455.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248.png.webp 2x" type="image/webp"> | |
| 22 | + <source media="(min-width: 435px)" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-666x455.png 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248.png 2x"> | |
| 23 | + <source srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png.webp 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-868x593.png.webp 2x" type="image/webp"> | |
| 24 | + <img onload="picture(this)" src="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png" srcset="https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-434x296.png 1x, https://www.bassaintlaurent.ca/app/uploads/2024/10/Capture-dE28099C3A9cran-2024-10-24-113248-868x593.png 2x" alt=""> | |
| 25 | +</picture> | |
| 26 | + </div> | |
| 27 | + <div class="card-location__content"> <div class="card-location__dates">Du 1er janvier au 31 décembre 2026</div> <p class="card-location__title">Rivière-du-Loup en spectacles</p> | |
| 28 | + <div class="card-location__city"><i class="icon-pin-2"></i>Rivière-du-Loup</div> </div> </a></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lanaudiere_fiche.html
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +<html><head><meta property="og:image" content="https://mto.media.tourinsoft.eu/upload/moring-220730-01498-HD.jpg" /><meta property="og:description" content="Le grand rendez-vous trad de Lanaudière." /></head><body><div class="member-title-1-1"> | |
| 2 | + <div class="member-title-1-1__container"> | |
| 3 | + <h1 class="member-title-1-1__title">Festival Mémoire et Racines</h1> | |
| 4 | + | |
| 5 | + <h2 class="member-title-1-1__subtitle"> | |
| 6 | +Du 17 au 26 juillet 2026<br /> | |
| 7 | + </h2> | |
| 8 | + | |
| 9 | + <div class="member-title-1-1__details"> | |
| 10 | + <div class="member-title-1-1__location"><span>Saint-Charles-Borromée</span></div> | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + </div> | |
| 15 | + </div> | |
| 16 | +</div> | |
| 17 | + | |
| 18 | + <div class="member-1-1__container"> | |
| 19 | + <div class="member-1-1__content"> | |
| 20 | + <div class="member-1-1__content-container"> | |
| 21 | + <div class="member-1-1__content--gutter"></div> | |
| 22 | + <div class="pictograms-1-1"> | |
| 23 | + <div class="pictograms-1-1__items"> | |
| 24 | + <div class="pictograms-1-1__item"> | |
| 25 | + <img class="pictograms-1-1__item-icon" src="/dist/catalog/pictograms-1/view-1/img/payant.svg" alt="" /> | |
| 26 | + <div class="pictograms-1-1__item-label">Entrée payante</div> | |
| 27 | + </div> | |
| 28 | + <div class="pictograms-1-1__item"> | |
| 29 | + <img class="pictograms-1-1__item-icon" src="/dist/catalog/pictograms-1/view-1/img/restauration.svg" alt="" /> | |
| 30 | + <div class="pictograms-1-1__item-label">Restauration sur place</div> | |
| 31 | + </div> | |
| 32 | + <div class="pictograms-1-1__item"> | |
| 33 | + <img class="pictograms-1-1__item-icon" src=</body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lanaudiere_sitemap.xml
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<urlset><url><loc>https://lanaudiere.ca/fr/evenements-lanaudiere/festival-memoire-et-racines/</loc></url> | |
| 3 | +<url><loc>https://lanaudiere.ca/fr/evenements-lanaudiere/apero-musical/</loc></url> | |
| 4 | +<url><loc>https://lanaudiere.ca/en/whats-going-on/some-event/</loc></url></urlset> | |
| \ No newline at end of file | ||
added
tests/fixtures/lavalweb_page.html
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +<html><body><div class="event-item"> | |
| 2 | + <a class="event-item__inner" href="/calendrier-activites/hockey-cosom-6-12-ans-dehors-champfleury-2026-08-18/" aria-label="Hockey cosom pour les 6-12 ans. Aller sur la page de cet événement."> | |
| 3 | + <div class="event-item__img-wrapper"> | |
| 4 | + <div class="event-item__tag-container"> | |
| 5 | + <span class="event-occurrence">Les mardis du 30 juin au 18 août</span> | |
| 6 | + </div> | |
| 7 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 8 | +<source type="image/webp" data-lazy-srcset="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 9 | +<img width="420" height="235" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20420%20235'%3E%3C/svg%3E" alt="Hockey cosom : une balle et deux bâtons rouges" data-lazy-sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async" data-lazy-src="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png"/><noscript><img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/hockey-cosom-1216x516-1-420x235.png" alt="Hockey cosom : une balle et deux bâtons rouges" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/></noscript> | |
| 10 | +</picture> | |
| 11 | + | |
| 12 | + </div> | |
| 13 | + <div class="event-item__content__info"> | |
| 14 | + <p class="event-item__date">18 août 2026 | 17 h</p> | |
| 15 | + <h3 class="event-item__title"><span>Hockey cosom pour les 6-12 ans</span></h3> | |
| 16 | + <p class="event-item__localisation"> | |
| 17 | + <span>Parc Champfleury</span> | |
| 18 | + </p> | |
| 19 | + </div> | |
| 20 | + </a> | |
| 21 | +</div> | |
| 22 | +<div class="event-item"> | |
| 23 | + <a class="event-item__inner" href="/calendrier-activites/oeuvre-art-public-temporaire-khadija-baker-appartenances-emap/" aria-label="Œuvre d’art public temporaire | Khadija Baker – Appartenances. Aller sur la page de cet événement."> | |
| 24 | + <div class="event-item__img-wrapper"> | |
| 25 | + <div class="event-item__tag-container"> | |
| 26 | + </div> | |
| 27 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 28 | +<source type="image/webp" srcset="https://www.laval.ca/wp-content/uploads/2026/05/appartenances-1216x516-1-420x235.jpg.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 29 | +<img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/appartenances-1216x516-1-420x235.jpg" alt="Maison avec en avant plan une oeuvre" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/> | |
| 30 | +</picture> | |
| 31 | + | |
| 32 | + </div> | |
| 33 | + <div class="event-item__content__info"> | |
| 34 | + <p class="event-item__date">Du 27 mai 2026 au 28 août 2026</p> | |
| 35 | + <h3 class="event-item__title"><span>Œuvre d’art public temporaire | Khadija Baker – Appartenances</span></h3> | |
| 36 | + <p class="event-item__localisation"> | |
| 37 | + <span>Résidence de création Marcel-Saint-Pierre</span> | |
| 38 | + </p> | |
| 39 | + </div> | |
| 40 | + </a> | |
| 41 | +</div> | |
| 42 | +<div class="event-item"> | |
| 43 | + <a class="event-item__inner" href="/calendrier-activites/oeuvre-art-public-temporaire-khadija-baker-relations-emap/" aria-label="Œuvre d’art public temporaire | xecê Khadija Baker – Nos relations. Aller sur la page de cet événement."> | |
| 44 | + <div class="event-item__img-wrapper"> | |
| 45 | + <div class="event-item__tag-container"> | |
| 46 | + </div> | |
| 47 | + <picture class="event-item__img wp-post-image" decoding="async"> | |
| 48 | +<source type="image/webp" srcset="https://www.laval.ca/wp-content/uploads/2026/05/oeuvre-art-khadija-baker-1216x516-1-420x235.jpg.webp" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw"/> | |
| 49 | +<img width="420" height="235" src="https://www.laval.ca/wp-content/uploads/2026/05/oeuvre-art-khadija-baker-1216x516-1-420x235.jpg" alt="" sizes="(min-width:1441px) 360px, (min-width:1200px) 21vw, (min-width:768px) 41vw, 77vw" decoding="async"/> | |
| 50 | +</picture> | |
| 51 | + | |
| 52 | + </div> | |
| 53 | + <div class="event-item__content__info"> | |
| 54 | + <p class="event-item__date">Du 27 mai 2026 au 25 août 2026</p> | |
| 55 | + <h3 class="event-item__title"><span>Œuvre d’art public temporaire | xecê Khadija Baker – Nos relations</span></h3> | |
| 56 | + <p class="event-item__localisation"> | |
| 57 | + <span>Parc Bernard-Landry</span> | |
| 58 | + </p> | |
| 59 | + </div> | |
| 60 | + </a> | |
| 61 | +</div></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/lhjmq_schedule.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"SiteKit": {"Parameters": {"feed": "modulekit", "view": "schedule", "key": "f322673b6bcae299", "fmt": "json", "client_code": "lhjmq", "lang": "fr", "season_id": 214, "lang_id": 2, "league_id": "1"}, "Schedule": [{"id": "32940", "game_id": "32940", "season_id": "214", "quick_score": "0", "date_played": "2026-09-18", "date_tbd": "0", "date": "Sep. 18", "date_with_day": "Fri, Sep 18", "date_time_played": "2026-09-18T19:00:00Z", "GameDateISO8601": "2026-09-18T19:00:00-02:30", "home_team": "2", "visiting_team": "8", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "1", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Saint John, Sea Dogs", "visiting_team_code": "SNB", "visiting_team_nickname": "Sea Dogs", "visiting_team_city": "Saint John", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16026080", "flo_live_event_id": "253475", "htv_game_id": "1152686", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32940"}, {"id": "32779", "game_id": "32779", "season_id": "214", "quick_score": "0", "date_played": "2026-09-18", "date_tbd": "0", "date": "Sep. 18", "date_with_day": "Fri, Sep 18", "date_time_played": "2026-09-18T19:00:00Z", "GameDateISO8601": "2026-09-18T19:00:00-04:00", "home_team": "10", "visiting_team": "13", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "6", "shootout": "0", "attendance": "", "status": "1", "location": "6", "game_status": "19:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Chicoutimi, Saguenéens", "home_team_code": "Chi", "home_team_nickname": "Saguenéens", "home_team_city": "Chicoutimi", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Shawinigan, Cataractes", "visiting_team_code": "Sha", "visiting_team_nickname": "Cataractes", "visiting_team_city": "Shawinigan", "visiting_team_division_long": "Association Ouest", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Georges-Vézina", "venue_url": "", "venue_location": "Chicoutimi QC", "last_modified": "2026-06-26 17:15:04", "flo_core_event_id": "0", "flo_live_event_id": "253281", "htv_game_id": "1152519", "client_code": "lhjmq", "scheduled_time": "19:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32779"}, {"id": "32941", "game_id": "32941", "season_id": "214", "quick_score": "0", "date_played": "2026-09-19", "date_tbd": "0", "date": "Sep. 19", "date_with_day": "Sat, Sep 19", "date_time_played": "2026-09-19T19:00:00Z", "GameDateISO8601": "2026-09-19T19:00:00-02:30", "home_team": "2", "visiting_team": "8", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "13", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Saint John, Sea Dogs", "visiting_team_code": "SNB", "visiting_team_nickname": "Sea Dogs", "visiting_team_city": "Saint John", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16025883", "flo_live_event_id": "253375", "htv_game_id": "1152607", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32941"}, {"id": "32780", "game_id": "32780", "season_id": "214", "quick_score": "0", "date_played": "2026-09-20", "date_tbd": "0", "date": "Sep. 20", "date_with_day": "Sun, Sep 20", "date_time_played": "2026-09-20T16:00:00Z", "GameDateISO8601": "2026-09-20T16:00:00-04:00", "home_team": "10", "visiting_team": "9", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "16:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "17", "shootout": "0", "attendance": "", "status": "1", "location": "6", "game_status": "16:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Chicoutimi, Saguenéens", "home_team_code": "Chi", "home_team_nickname": "Saguenéens", "home_team_city": "Chicoutimi", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Québec, Remparts", "visiting_team_code": "Que", "visiting_team_nickname": "Remparts", "visiting_team_city": "Québec", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Georges-Vézina", "venue_url": "", "venue_location": "Chicoutimi QC", "last_modified": "2026-06-26 17:15:04", "flo_core_event_id": "0", "flo_live_event_id": "252996", "htv_game_id": "1152231", "client_code": "lhjmq", "scheduled_time": "16:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32780"}, {"id": "32942", "game_id": "32942", "season_id": "214", "quick_score": "0", "date_played": "2026-09-25", "date_tbd": "0", "date": "Sep. 25", "date_with_day": "Fri, Sep 25", "date_time_played": "2026-09-25T19:00:00Z", "GameDateISO8601": "2026-09-25T19:00:00-02:30", "home_team": "2", "visiting_team": "5", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Newfoundland", "game_number": "19", "shootout": "0", "attendance": "", "status": "1", "location": "244", "game_status": "19:00 HT", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Terre-Neuve, Régiment", "home_team_code": "NFL", "home_team_nickname": "Régiment", "home_team_city": "Terre-Neuve", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Halifax, Mooseheads", "visiting_team_code": "Hal", "visiting_team_nickname": "Mooseheads", "visiting_team_city": "Halifax", "visiting_team_division_long": "Association Est", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Mary Brown's Centre", "venue_url": "https://mbcentre.ca/events-and-tickets/", "venue_location": "St. John's (NL)", "last_modified": "2026-07-01 18:45:04", "flo_core_event_id": "16025436", "flo_live_event_id": "253156", "htv_game_id": "1152393", "client_code": "lhjmq", "scheduled_time": "19:00 HAT", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32942"}, {"id": "32974", "game_id": "32974", "season_id": "214", "quick_score": "0", "date_played": "2026-09-25", "date_tbd": "0", "date": "Sep. 25", "date_with_day": "Fri, Sep 25", "date_time_played": "2026-09-25T19:00:00Z", "GameDateISO8601": "2026-09-25T19:00:00-04:00", "home_team": "9", "visiting_team": "13", "home_goal_count": "0", "visiting_goal_count": "0", "period": "1", "overtime": "0", "schedule_time": "19:00:00", "time_tbd": "0", "schedule_notes": "", "game_clock": "00:00:00", "timezone": "Canada/Eastern", "game_number": "24", "shootout": "0", "attendance": "", "status": "1", "location": "182", "game_status": "19:00 HE", "intermission": "0", "game_type": "", "game_letter": "", "if_necessary": "0", "period_trans": "1", "started": "0", "final": "0", "tickets_url": "", "home_audio_url": "", "visiting_audio_url": "", "uses_spalk_rc": null, "home_team_name": "Québec, Remparts", "home_team_code": "Que", "home_team_nickname": "Remparts", "home_team_city": "Québec", "home_team_division_long": "Association Est", "home_team_division_short": "Association", "visiting_team_name": "Shawinigan, Cataractes", "visiting_team_code": "Sha", "visiting_team_nickname": "Cataractes", "visiting_team_city": "Shawinigan", "visiting_team_division_long": "Association Ouest", "visiting_team_division_short": "Association", "notes_text": "", "use_shootouts": "1", "venue_name": "Centre Vidéotron", "venue_url": "", "venue_location": "Québec (QC)", "last_modified": "2026-06-26 17:00:05", "flo_core_event_id": "0", "flo_live_event_id": "252143", "htv_game_id": "1151389", "client_code": "lhjmq", "scheduled_time": "19:00 HAE", "broadcasters": {}, "mobile_calendar": "https://lscluster.hockeytech.com/components/calendar/ical_add_games.php?client_code=lhjmq&game_ids=32974"}], "Copyright": {"required_copyright": "Official statistics provided by LHJMQ", "required_link": "http://leaguestat.com", "powered_by": "Powered by HockeyTech.com", "powered_by_url": "http://hockeytech.com"}}} | |
| \ No newline at end of file | ||
added
tests/fixtures/lhjmq_seasons.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"SiteKit": {"Seasons": [{"season_id": "214", "season_name": "2026-27 | Saison régulière", "career": "1", "playoff": "0", "start_date": "2026-09-15", "end_date": "2027-03-22"}, {"season_id": "213", "season_name": "Pré-saison", "career": "0", "playoff": "0", "start_date": "2026-08-01", "end_date": "2026-09-10"}]}} | |
| \ No newline at end of file | ||
added
tests/fixtures/outaouais_page.html
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<html><body><astro-island component-url="/_astro/CalendrierDesEvenements.X.js" props="{"categoryUrl": [0, "/quoi-faire/festivals-et-evenements"], "events": [1, [[0, {"id": [0, "69379d207e36e5aaa7518767"], "title": [0, "Premiers royaumes d’Europe"], "slug": [0, ""], "url": [0, "https://www.museedelhistoire.ca/premiers-royaumes/?_ga=2.164185151.1465875516.1711477939-1738617796.1711477939&_gl=1*1fmnd74*_ga*MTczODYxNzc5Ni4xNzExNDc3OTM5*_ga_6SV303E8VY*MTcxMTQ3NzkzOS4xLjEuMTcxMTQ3ODAzNi42MC4wLjA."], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/couronne-field-museum-adam-vago-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-04-05"], "endDate": [0, "2025-01-19"]}], [0, {"id": [0, "69379d1f7e36e5aaa75186b9"], "title": [0, "SORTIES DES RANGS"], "slug": [0, ""], "url": [0, "https://www.museedelaguerre.ca/sorties-des-rangs/"], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/ld-mark-holleron-musee-canadien-de-la-gue-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-05-28"], "endDate": [0, "2025-01-05"]}], [0, {"id": [0, "69379d137e36e5aaa7517f36"], "title": [0, "Les Olympiques de Gatineau"], "slug": [0, ""], "url": [0, "https://centreslushpuppie.com/les-olympiques/"], "territory": [0, null], "municipality": [0, null], "photo": [0, "/api/photos/file/ld-centre-slush-puppie-centre-slush-puppi-1-620x620.webp"], "focalX": [0, 50], "focalY": [0, 50], "startDate": [0, "2024-12-01"], "endDate": [0, "2025-03-24"]}]]], "initialFilterQuery": [0, {"sectors": [0, {"territories": [1, []], "municipalities": [1, []]}], "date": [0, {"startDate": [0, ""], "endDate": [0, ""]}]}], "initialFilterDefinitions": [1, [[0, {"type": [0, "date"], "label": [0, "Quand"], "options": [1, []], "hasMany": [0, true]}], [0, {"type": [0, "sectors"], "label": [0, "Où"], "options": [1, [[0, {"name": [0, "gatineau"], "label": [0, "Gatineau"]}], [0, {"name": [0, "la-vallee-de-la-gatineau"], "label": [0, "La Vallée-de-la-Gatineau"]}], [0, {"name": [0, "les-collines-de-loutaouais"], "label": [0, "Les Collines-de-l'Outaouais"]}], [0, {"name": [0, "petite-nation"], "label": [0, "Petite Nation"]}], [0, {"name": [0, "pontiac"], "label": [0, "Pontiac"]}], [0, {"name": [0, "territoire-exterieur-de-loutaouais"], "label": [0, "Territoire extérieur de l'Outaouais"]}]]], "hasMany": [0, true]}]]], "locale": [0, "fr"]}"></astro-island></body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/placedesarts_fiche.html
+257 −0
@@ -0,0 +1,257 @@ | ||
| 1 | +<html><body><script type="application/json" id="eventbannerdata"> | |
| 2 | + {"mainImage":"https:\/\/cdn.placedesarts.com\/tranforms\/_300x140_crop_center-center_none_ns\/1657294\/24POSES_DU2526_2400x1035.webp","title":"24 poses (portraits)","url":"https:\/\/www.placedesarts.com\/evenement\/24-poses-portraits","hasPassed":false,"dates":"2 septembre au 3 octobre 2026"} | |
| 3 | + </script><script type="application/json" id="eventGTM"> | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + {"eventName":"24 poses (portraits)","eventPlace":["Th\u00e9\u00e2tre Jean-Duceppe"],"eventProducer":"Duceppe","eventDiscipline":["Th\u00e9\u00e2tre"],"eventSubCategory":["Th\u00e9\u00e2tre \u2022 Tragi-com\u00e9die"],"eventDateRange":"2 septembre au 3 octobre 2026","eventID":1657289} | |
| 9 | + </script><section rs__wrapper"> | |
| 10 | + | |
| 11 | +<section class="sidebar sb-performances" id="sb-performances" data-lenis-prevent> | |
| 12 | + <div class="sidebar__bg"></div> | |
| 13 | + <div class="sidebar__inner"> | |
| 14 | + <div class="sidebar__header"> | |
| 15 | + <p class="sidebar__title">Sélectionnez la date</p> | |
| 16 | + <span class="btn--close"></span> | |
| 17 | + </div> | |
| 18 | + | |
| 19 | + <template id="sb-performances-promotion-template"> | |
| 20 | + <div class="promotion__wrapper"> | |
| 21 | + <div class="promotion__content"> | |
| 22 | + <div class="promotion__sticker"> | |
| 23 | + <span class="promotion__sticker-text"></span> | |
| 24 | + </div> | |
| 25 | + <div class="promotion__infos"> | |
| 26 | + <div class="promotion__txt"> | |
| 27 | + <p class="promotion__txt-title"></p> | |
| 28 | + <div class="promotion__txt-description"></div> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + </template> | |
| 34 | + | |
| 35 | + <div class="sidebar__body"> | |
| 36 | + <div class="sidebar__promotion"> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <p class="sb-performances__subtitle"> | |
| 40 | + Vous serez redirigé·e vers notre système de billetterie. | |
| 41 | + </p> | |
| 42 | + <p class="sb-performances__subtitle"> | |
| 43 | + Tous les prix affichés incluent les taxes et frais associés. | |
| 44 | + </p> | |
| 45 | + | |
| 46 | + <div class="sb-performances__grid"> | |
| 47 | + <div class="sb-performance" data-performance-id="1671189"> | |
| 48 | + <div class="sb-performance__infos"> | |
| 49 | + | |
| 50 | + <p class="sb-performance__hour"> | |
| 51 | + <span class="hour"> | |
| 52 | + 19h30 | |
| 53 | + </span> | |
| 54 | + </p> | |
| 55 | + | |
| 56 | + <h6 class="sb-performance__date"> | |
| 57 | + Mercredi 2 septembre 2026 | |
| 58 | + </h6> | |
| 59 | + <div class="sb-performance__extrainfos"> | |
| 60 | + <template class="sb-performance-promotion-template"> | |
| 61 | + <div class="sb-performance__promo"></span> | |
| 62 | + <span class="font-primary"></span> | |
| 63 | + </div> | |
| 64 | + </template> | |
| 65 | + | |
| 66 | + | |
| 67 | + <p class="sb-performance__price"> | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + 33 $ à 97,50 $ | |
| 73 | + | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + | |
| 79 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwMg==" target="_blank" aria-label="Obtenir des billets"> | |
| 80 | + | |
| 81 | + <div class="btn__icon--right"> | |
| 82 | + <span class="icon btn--icon bg-black text-white"> | |
| 83 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 84 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 85 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 86 | + </svg> | |
| 87 | +</span> | |
| 88 | + </div> | |
| 89 | + </a> | |
| 90 | + </div> | |
| 91 | + <div class="sb-performance" data-performance-id="1671190"> | |
| 92 | + <div class="sb-performance__infos"> | |
| 93 | + | |
| 94 | + <p class="sb-performance__hour"> | |
| 95 | + <span class="hour"> | |
| 96 | + 20h00 | |
| 97 | + </span> | |
| 98 | + </p> | |
| 99 | + | |
| 100 | + <h6 class="sb-performance__date"> | |
| 101 | + Vendredi 4 septembre 2026 | |
| 102 | + </h6> | |
| 103 | + <div class="sb-performance__extrainfos"> | |
| 104 | + <template class="sb-performance-promotion-template"> | |
| 105 | + <div class="sb-performance__promo"></span> | |
| 106 | + <span class="font-primary"></span> | |
| 107 | + </div> | |
| 108 | + </template> | |
| 109 | + | |
| 110 | + | |
| 111 | + <p class="sb-performance__price"> | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + 33 $ à 97,50 $ | |
| 117 | + | |
| 118 | + </p> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + | |
| 122 | + | |
| 123 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwNA==" target="_blank" aria-label="Obtenir des billets"> | |
| 124 | + | |
| 125 | + <div class="btn__icon--right"> | |
| 126 | + <span class="icon btn--icon bg-black text-white"> | |
| 127 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 128 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 129 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 130 | + </svg> | |
| 131 | +</span> | |
| 132 | + </div> | |
| 133 | + </a> | |
| 134 | + </div> | |
| 135 | + <div class="sb-performance" data-performance-id="1671191"> | |
| 136 | + <div class="sb-performance__infos"> | |
| 137 | + | |
| 138 | + <p class="sb-performance__hour"> | |
| 139 | + <span class="hour"> | |
| 140 | + 15h00 | |
| 141 | + </span> | |
| 142 | + </p> | |
| 143 | + | |
| 144 | + <h6 class="sb-performance__date"> | |
| 145 | + Samedi 5 septembre 2026 | |
| 146 | + </h6> | |
| 147 | + <div class="sb-performance__extrainfos"> | |
| 148 | + <template class="sb-performance-promotion-template"> | |
| 149 | + <div class="sb-performance__promo"></span> | |
| 150 | + <span class="font-primary"></span> | |
| 151 | + </div> | |
| 152 | + </template> | |
| 153 | + | |
| 154 | + | |
| 155 | + <p class="sb-performance__price"> | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + 33 $ à 97,50 $ | |
| 161 | + | |
| 162 | + </p> | |
| 163 | + </div> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + | |
| 167 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwNVA=" target="_blank" aria-label="Obtenir des billets"> | |
| 168 | + | |
| 169 | + <div class="btn__icon--right"> | |
| 170 | + <span class="icon btn--icon bg-black text-white"> | |
| 171 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 172 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 173 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 174 | + </svg> | |
| 175 | +</span> | |
| 176 | + </div> | |
| 177 | + </a> | |
| 178 | + </div> | |
| 179 | + <div class="sb-performance" data-performance-id="1671192"> | |
| 180 | + <div class="sb-performance__infos"> | |
| 181 | + | |
| 182 | + <p class="sb-performance__hour"> | |
| 183 | + <span class="hour"> | |
| 184 | + 19h30 | |
| 185 | + </span> | |
| 186 | + </p> | |
| 187 | + | |
| 188 | + <h6 class="sb-performance__date"> | |
| 189 | + Mercredi 9 septembre 2026 | |
| 190 | + </h6> | |
| 191 | + <div class="sb-performance__extrainfos"> | |
| 192 | + <template class="sb-performance-promotion-template"> | |
| 193 | + <div class="sb-performance__promo"></span> | |
| 194 | + <span class="font-primary"></span> | |
| 195 | + </div> | |
| 196 | + </template> | |
| 197 | + | |
| 198 | + | |
| 199 | + <p class="sb-performance__price"> | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + 33 $ à 97,50 $ | |
| 205 | + | |
| 206 | + </p> | |
| 207 | + </div> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + | |
| 211 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkwOQ==" target="_blank" aria-label="Obtenir des billets"> | |
| 212 | + | |
| 213 | + <div class="btn__icon--right"> | |
| 214 | + <span class="icon btn--icon bg-black text-white"> | |
| 215 | + <svg width="24" height="22" viewbox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 216 | + <path d="M1.83203 10.8984L21.832 10.8984" stroke-width="2" stroke-linecap="round"/> | |
| 217 | + <path d="M12.1016 1L22.0011 10.8995L12.1016 20.799" stroke-width="2" stroke-linecap="round"/> | |
| 218 | + </svg> | |
| 219 | +</span> | |
| 220 | + </div> | |
| 221 | + </a> | |
| 222 | + </div> | |
| 223 | + <div class="sb-performance" data-performance-id="1671193"> | |
| 224 | + <div class="sb-performance__infos"> | |
| 225 | + | |
| 226 | + <p class="sb-performance__hour"> | |
| 227 | + <span class="hour"> | |
| 228 | + 20h00 | |
| 229 | + </span> | |
| 230 | + </p> | |
| 231 | + | |
| 232 | + <h6 class="sb-performance__date"> | |
| 233 | + Jeudi 10 septembre 2026 | |
| 234 | + </h6> | |
| 235 | + <div class="sb-performance__extrainfos"> | |
| 236 | + <template class="sb-performance-promotion-template"> | |
| 237 | + <div class="sb-performance__promo"></span> | |
| 238 | + <span class="font-primary"></span> | |
| 239 | + </div> | |
| 240 | + </template> | |
| 241 | + | |
| 242 | + | |
| 243 | + <p class="sb-performance__price"> | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + 33 $ à 97,50 $ | |
| 249 | + | |
| 250 | + </p> | |
| 251 | + </div> | |
| 252 | + </div> | |
| 253 | + | |
| 254 | + | |
| 255 | + <a class="btn btn--tertiary " href="https://am.ticketmaster.com/pda/fr-ca/buy/virtual-venue/RDZEMDkxMA==" target="_blank" aria-label="Obtenir des billets"> | |
| 256 | + | |
| 257 | + <div class="btn</body></html> | |
| \ No newline at end of file | ||
added
tests/fixtures/quebecanimee_liste.html
+89 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/saguenaysorties_detail.html
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/saguenaysorties_liste.html
+55 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/sallesjsonld_fiche.html
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/sallesjsonld_liste.html
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified
tests/test_connectors.py
+9 −1
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_expansion.py
+270 −0
Large diff (271 lines) is not loaded by default to keep the page fast.Load diff