# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/centreduquebec.py : Tourisme Centre-du-Québec — événements # (ATR, Phase 4). Première source de la région Centre-du-Québec (0 fiche # avant connexion). # Source : https://tourismecentreduquebec.com/evenements/ — WordPress, # liste rendue serveur (~9 fiches à venir, revérifié 2026-08-25, # aucune pagination — la liste est complète) : chaque bloc # events_listing_results_event = plage de dates FR # (« 1 septembre 2026 au 6 septembre 2026 ») + titre + lien. # Extraction: GET liste (1 requête) → fiches détail incrémentales (cache # data/centreduquebec_cache.json, CDQ_FICHE_MAX/sync, re-visite # 7 j, version v=2) : champs étiquetés (Adresse, VILLE, # Entreprise → organisateur), description du bloc # event_details_row_description, « Programme » (liste d'activités # → catégories brutes), lien « Visiter le site web » (href vide # sur toutes les fiches en 2026-08 — bogue du gabarit à la # source, champ émis dès qu'il sera rempli), og:image. # Ni heure ni tarif ni GPS publiés → jamais inventés. # Accès : site public, robots.txt sans interdiction sur /evenements/ — # GET throttlés, UA honnête. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from ..normalize import clean_text, parse_date_range_fr from ..schema import Event from .base import BaseConnector BASE = "https://tourismecentreduquebec.com" LIST_URL = BASE + "/evenements/" FICHE_MAX = int(os.environ.get("CDQ_FICHE_MAX", "10")) REFRESH_AFTER = 7 * 86400 CACHE_V = 2 # v2 : + ville, organisateur, site web, programme, # description du corps de fiche (2026-08-25) _BLOCK_RE = re.compile( r'events_listing_results_event_date">\s*

\s*(?P[^<]+?)\s*

.*?' r'events_listing_results_event_name">\s*

(?P[^<]+)</h4>.*?' r'href="(?P<url>https://tourismecentreduquebec\.com/evenements/[^"]+)"', re.S) # champs étiquetés de la fiche : <small>Adresse</small> 1910, chemin Dublin _FIELD_RE = re.compile( r"<small>\s*(Adresse|Ville|Entreprise)\s*</small>\s*([^<]+)") _DESC_RE = re.compile( r'event_details_row_description">\s*(.*?)\s*</div>', re.S) _PROG_RE = re.compile(r"<h5>Programme</h5>\s*<p>(.*?)</p>", re.S) _WEBSITE_RE = re.compile( r'href="(https?://[^"]+)"[^>]*>\s*Visiter le site web') _OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') class CentreDuQuebecConnector(BaseConnector): source_id = "centreduquebec" request_delay = 0.8 def _parse_fiche(self, html: str) -> dict: fields = {label: clean_text(val) for label, val in _FIELD_RE.findall(html)} desc_m = _DESC_RE.search(html) og_desc = _OG_DESC_RE.search(html) desc = clean_text(desc_m.group(1))[:2000] if desc_m else ( clean_text(og_desc.group(1)) if og_desc else "") prog = _PROG_RE.search(html) programme = [clean_text(x) for x in re.split(r"<br\s*/?>", prog.group(1))] if prog else [] site = _WEBSITE_RE.search(html) img = _OG_IMG_RE.search(html) return { "address": fields.get("Adresse", ""), "city": fields.get("Ville", ""), "organizer": fields.get("Entreprise", ""), "website": site.group(1) if site else "", "raw_categories": [p for p in programme if p], "image": clean_text(img.group(1)) if img else "", "description": desc, } def fetch(self) -> list[Event]: html = self.get(LIST_URL).text rows: list[dict] = [] for m in _BLOCK_RE.finditer(html): url = m.group("url").rstrip("/") + "/" start, end = parse_date_range_fr(m.group("date")) rows.append({ "external_id": url.rstrip("/").split("/")[-1], "url": url, "title": clean_text(m.group("title")), "start_date": start, "end_date": end, }) cache = self.load_cache() now = time.time() current = {r["external_id"] for r in rows} cache = {k: v for k, v in cache.items() if k in current} to_fetch = sorted( (r for r in rows if r["external_id"] not in cache or cache[r["external_id"]].get("v", 1) < CACHE_V or now - cache[r["external_id"]].get("ts", 0) > REFRESH_AFTER), key=lambda r: (cache.get(r["external_id"], {}).get("v", 1) if r["external_id"] in cache else 0, cache.get(r["external_id"], {}).get("ts", 0)))[:FICHE_MAX] for r in to_fetch: try: detail = self._parse_fiche(self.get(r["url"]).text) except Exception: detail = {} cache[r["external_id"]] = {"ts": now, "detail": detail, "v": CACHE_V} self.save_cache(cache) events: list[Event] = [] for r in rows: detail = cache.get(r["external_id"], {}).get("detail") or {} events.append(Event( source=self.source_id, external_id=r["external_id"], url=r["url"], title=r["title"], description=detail.get("description", ""), raw_categories=(detail.get("raw_categories") or []) + [r["title"]], address=detail.get("address", ""), city=detail.get("city", ""), tourist_region="Centre-du-Québec", start_date=r["start_date"], end_date=r["end_date"], organizer=detail.get("organizer", ""), website=detail.get("website", ""), image=detail.get("image", ""), )) return events