# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sitq.py : connecteur SIT Québec — Ministère du Tourisme # Source : Système d'information touristique Québec (Données Québec) # Extraction: API JSON officielle Tourinsoft (syndication publique) # Accès : données ouvertes — Licence CC 4.0 (Données Québec), API publiée # par le Ministère du Tourisme. Aucune restriction de scraping. # Couverture: ~1 600 événements touristiques, TOUTES les régions du Québec # (festivals, salons, manifestations sportives et culturelles), # avec région touristique, thèmes, dates, géolocalisation, site web. # Contacts : TelephonePrincipals / Courriels (moyens de communication) → # ligne « Renseignements : … » en description (le flux syndiqué # ne publie AUCUN groupe Descriptions* ni bloc tarifs — vérifié # sur l'union des clés des 1 598 fiches, 2026-08, re-vérifié # 2026-08-25 : aucune heure non plus, tous les Datedebut/Datefin # à T00:00:00 ; is_free/price_*/start_time restent donc # inconnus, jamais inventés). # Fiches : le site officiel de chaque événement (SiteInternets) est # visité incrémentalement (cache disque, plafond SITQ_IMG_MAX) # pour og:image ET og:description (v2 — ~60 % des 40 sites # sondés la publient, 2026-08-25) ; AUCUN des 40 sites sondés # ne publie de JSON-LD Event → l'heure n'est pas récupérable à # coût raisonnable. La fiche quebecoriginal.com n'est pas # dérivable du SyndicObjectID (301 → bonjourquebec.com → 404, # vérifié 2026-08-25). # https://www.donneesquebec.ca/recherche/dataset/sit-quebec-evenements # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from ..normalize import clean_text from ..schema import Event from .base import BaseConnector FEED = ("https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/" "c6ab1c9e-f594-463e-b036-5a029197f3a9?format=json") # le flux ne contient ni photos ni textes → enrichissement og:image + # og:description du site officiel de chaque événement (cache disque, # plafonné par sync via SITQ_IMG_MAX) IMG_MAX = int(os.environ.get("SITQ_IMG_MAX", "200")) SITE_V = 2 # version du parseur de site (v1 : image seule → backfill desc) _OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') def _parse_site(html: str) -> dict: """og:image + og:description du site officiel d'un événement (v2).""" m = _OG_IMG_RE.search(html) img = m.group(1) if m else "" if img.startswith("//"): img = "https:" + img if not img.startswith("http"): img = "" d = _OG_DESC_RE.search(html) desc = clean_text(d.group(1)) if d else "" if len(desc) < 30: # slogan/nom de site : pas une description utile desc = "" return {"image": img, "desc": desc, "v": SITE_V} def _first(lst, *keys, default=None): """Descend prudemment dans les structures imbriquées Tourinsoft.""" cur = lst for k in keys: if isinstance(cur, list): cur = cur[0] if cur else None if isinstance(cur, dict): cur = cur.get(k) if cur is None: return default if isinstance(cur, list): cur = cur[0] if cur else default return cur def _thes_labels(groups, inner_key) -> list[str]: """Extrait tous les ThesLibelle d'une liste de groupes Tourinsoft.""" out: list[str] = [] for g in groups or []: for item in g.get(inner_key) or []: lbl = (item or {}).get("ThesLibelle", "") if lbl and lbl not in out: out.append(lbl) return out class SitqConnector(BaseConnector): source_id = "sitq" request_delay = 0.3 timeout = 15 def _enrich_images(self, events: list[Event]) -> None: """og:image + og:description du site web officiel — incrémental, cache disque, pool de threads (8), tolérant (sites hétérogènes : échec ou absence = entrée vide mémorisée, jamais re-visitée). Événements à venir seulement. Les entrées héritées v1 (chaîne image seule) sont re-visitées en priorité basse pour acquérir la description, dans le même plafond SITQ_IMG_MAX.""" from datetime import date today = date.today().isoformat() cache = self.load_cache() to_fetch, legacy = [], [] for ev in events: site = ev.website if not site or site in to_fetch or site in legacy: continue last_day = (ev.end_date or ev.start_date or "")[:10] if last_day and last_day < today: continue entry = cache.get(site) if entry is None: to_fetch.append(site) # jamais visité : prioritaire elif isinstance(entry, str) and entry: legacy.append(site) # v1 avec image : backfill desc to_fetch = (to_fetch + legacy)[:IMG_MAX] def worker(url, session): return _parse_site(session.get(url, timeout=10).text) for url, info in self.fetch_many(to_fetch, worker, max_workers=8).items(): if info is None: # échec : garder l'image v1 old = cache.get(url) info = {"image": old if isinstance(old, str) else "", "desc": "", "v": SITE_V} cache[url] = info self.save_cache(cache) for ev in events: entry = cache.get(ev.website) if ev.website else None if isinstance(entry, str): # cache hérité v1 : image seule entry = {"image": entry} if not isinstance(entry, dict): continue if not ev.image: ev.image = entry.get("image") or "" desc = entry.get("desc") or "" if desc: # résumé publié par le site officiel + coordonnées ev.description = (f"{desc} — {ev.description}" if ev.description else desc) def fetch(self) -> list[Event]: payload = self.get_json(FEED) events: list[Event] = [] for rec in payload.get("value", []): ext_id = rec.get("SyndicObjectID", "") title = rec.get("SyndicObjectName", "") if not ext_id or not title: continue addr = _first(rec.get("Adresses"), default={}) or {} geo = _first(rec.get("Geolocalisations"), default={}) or {} # Phase 3 : DÉROULAGE des périodes multiples (reporté de la # Phase 2, audit 02 §4) — une occurrence datée PAR période # publiée. Cap 24 : au-delà, c'est un calendrier d'ouverture # hebdomadaire, on garde l'enveloppe min-max (jamais 40 fiches # pour un même marché public). periods = sorted( (pp for pp in (rec.get("PeriodeOuvertures") or []) if isinstance(pp, dict) and pp.get("Datedebut")), key=lambda pp: str(pp.get("Datedebut"))) if len(periods) > 24: periods = [{"Datedebut": periods[0].get("Datedebut"), "Datefin": periods[-1].get("Datefin")}] if not periods: periods = [{}] struct = rec.get("Structure") or {} address = ", ".join( p for p in (addr.get("Numerovoie", ""), addr.get("Libellevoie", "")) if p) or addr.get("Adresse1", "") website = "" for site in rec.get("SiteInternets") or []: url = (site or {}).get("Coordonnees", "") if url.startswith("http"): website = url break raw_cats = _thes_labels(rec.get("Identifications") and [ {"x": [i.get("Typedetablissementoudequipement") or {} for i in rec["Identifications"]]}], "x") raw_cats += _thes_labels(rec.get("Themes"), "Theme") raw_cats += _thes_labels( rec.get("ActivitesRecreativesCulturelless"), "Activitesrecreativesetculturelles") audience = ", ".join(_thes_labels( rec.get("ClientelesAdmisess"), "Clientelesadmises")) tourist_region = _first( rec.get("RegionsDesserviess"), "Regionstouristiques", "ThesLibelle", default="") or "" # moyens de communication (téléphone principal, courriel) : le # flux n'offre aucun texte descriptif → on publie au moins les # coordonnées de renseignements, toujours factuelles. phone = _first(rec.get("TelephonePrincipals"), "Coordonnees", default="") or "" email = _first(rec.get("Courriels"), "Coordonnees", default="") or "" contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c) description = f"Renseignements : {contacts}" if contacts else "" for pi, period in enumerate(periods): events.append(Event( source=self.source_id, # 1re période = identifiant historique (zéro churn) ; # périodes supplémentaires suffixées -p external_id=ext_id if pi == 0 else f"{ext_id}-p{pi}", url=website or "https://www.quebecoriginal.com/", title=title, description=description, raw_categories=raw_cats, audience=audience, venue=addr.get("Nomdupointdeservice", "") or "", address=address, city=addr.get("Municipalite", "") or "", tourist_region=tourist_region, postal_code=addr.get("CodePostal", "") or "", lat=geo.get("Latitude"), lng=geo.get("Longitude"), start_date=period.get("Datedebut"), end_date=period.get("Datefin"), organizer=struct.get("Name", "") or "", website=website, )) self._enrich_images(events) return events