Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sitq.py : connecteur SIT Québec — Ministère du Tourisme5# Source : Système d'information touristique Québec (Données Québec)6# Extraction: API JSON officielle Tourinsoft (syndication publique)7# Accès : données ouvertes — Licence CC 4.0 (Données Québec), API publiée8# par le Ministère du Tourisme. Aucune restriction de scraping.9# Couverture: ~1 600 événements touristiques, TOUTES les régions du Québec10# (festivals, salons, manifestations sportives et culturelles),11# avec région touristique, thèmes, dates, géolocalisation, site web.12# Contacts : TelephonePrincipals / Courriels (moyens de communication) →13# ligne « Renseignements : … » en description (le flux syndiqué14# ne publie AUCUN groupe Descriptions* ni bloc tarifs — vérifié15# sur l'union des clés des 1 598 fiches, 2026-08, re-vérifié16# 2026-08-25 : aucune heure non plus, tous les Datedebut/Datefin17# à T00:00:00 ; is_free/price_*/start_time restent donc18# inconnus, jamais inventés).19# Fiches : le site officiel de chaque événement (SiteInternets) est20# visité incrémentalement (cache disque, plafond SITQ_IMG_MAX)21# pour og:image ET og:description (v2 — ~60 % des 40 sites22# sondés la publient, 2026-08-25) ; AUCUN des 40 sites sondés23# ne publie de JSON-LD Event → l'heure n'est pas récupérable à24# coût raisonnable. La fiche quebecoriginal.com n'est pas25# dérivable du SyndicObjectID (301 → bonjourquebec.com → 404,26# vérifié 2026-08-25).27# https://www.donneesquebec.ca/recherche/dataset/sit-quebec-evenements28# -----------------------------------------------------------------------------29from __future__ import annotations3031import os32import re3334from ..normalize import clean_text35from ..schema import Event36from .base import BaseConnector3738FEED = ("https://api-v3.tourinsoft.com/api/syndications/mto.tourinsoft.com/"39 "c6ab1c9e-f594-463e-b036-5a029197f3a9?format=json")4041# le flux ne contient ni photos ni textes → enrichissement og:image +42# og:description du site officiel de chaque événement (cache disque,43# plafonné par sync via SITQ_IMG_MAX)44IMG_MAX = int(os.environ.get("SITQ_IMG_MAX", "200"))45SITE_V = 2 # version du parseur de site (v1 : image seule → backfill desc)46_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"')47_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')484950def _parse_site(html: str) -> dict:51 """og:image + og:description du site officiel d'un événement (v2)."""52 m = _OG_IMG_RE.search(html)53 img = m.group(1) if m else ""54 if img.startswith("//"):55 img = "https:" + img56 if not img.startswith("http"):57 img = ""58 d = _OG_DESC_RE.search(html)59 desc = clean_text(d.group(1)) if d else ""60 if len(desc) < 30: # slogan/nom de site : pas une description utile61 desc = ""62 return {"image": img, "desc": desc, "v": SITE_V}636465def _first(lst, *keys, default=None):66 """Descend prudemment dans les structures imbriquées Tourinsoft."""67 cur = lst68 for k in keys:69 if isinstance(cur, list):70 cur = cur[0] if cur else None71 if isinstance(cur, dict):72 cur = cur.get(k)73 if cur is None:74 return default75 if isinstance(cur, list):76 cur = cur[0] if cur else default77 return cur787980def _thes_labels(groups, inner_key) -> list[str]:81 """Extrait tous les ThesLibelle d'une liste de groupes Tourinsoft."""82 out: list[str] = []83 for g in groups or []:84 for item in g.get(inner_key) or []:85 lbl = (item or {}).get("ThesLibelle", "")86 if lbl and lbl not in out:87 out.append(lbl)88 return out899091class SitqConnector(BaseConnector):92 source_id = "sitq"93 request_delay = 0.394 timeout = 159596 def _enrich_images(self, events: list[Event]) -> None:97 """og:image + og:description du site web officiel — incrémental,98 cache disque, pool de threads (8), tolérant (sites hétérogènes :99 échec ou absence = entrée vide mémorisée, jamais re-visitée).100 Événements à venir seulement. Les entrées héritées v1 (chaîne image101 seule) sont re-visitées en priorité basse pour acquérir la102 description, dans le même plafond SITQ_IMG_MAX."""103 from datetime import date104 today = date.today().isoformat()105 cache = self.load_cache()106 to_fetch, legacy = [], []107 for ev in events:108 site = ev.website109 if not site or site in to_fetch or site in legacy:110 continue111 last_day = (ev.end_date or ev.start_date or "")[:10]112 if last_day and last_day < today:113 continue114 entry = cache.get(site)115 if entry is None:116 to_fetch.append(site) # jamais visité : prioritaire117 elif isinstance(entry, str) and entry:118 legacy.append(site) # v1 avec image : backfill desc119 to_fetch = (to_fetch + legacy)[:IMG_MAX]120121 def worker(url, session):122 return _parse_site(session.get(url, timeout=10).text)123124 for url, info in self.fetch_many(to_fetch, worker, max_workers=8).items():125 if info is None: # échec : garder l'image v1126 old = cache.get(url)127 info = {"image": old if isinstance(old, str) else "",128 "desc": "", "v": SITE_V}129 cache[url] = info130 self.save_cache(cache)131 for ev in events:132 entry = cache.get(ev.website) if ev.website else None133 if isinstance(entry, str): # cache hérité v1 : image seule134 entry = {"image": entry}135 if not isinstance(entry, dict):136 continue137 if not ev.image:138 ev.image = entry.get("image") or ""139 desc = entry.get("desc") or ""140 if desc: # résumé publié par le site officiel + coordonnées141 ev.description = (f"{desc} — {ev.description}"142 if ev.description else desc)143144 def fetch(self) -> list[Event]:145 payload = self.get_json(FEED)146 events: list[Event] = []147 for rec in payload.get("value", []):148 ext_id = rec.get("SyndicObjectID", "")149 title = rec.get("SyndicObjectName", "")150 if not ext_id or not title:151 continue152153 addr = _first(rec.get("Adresses"), default={}) or {}154 geo = _first(rec.get("Geolocalisations"), default={}) or {}155 # Phase 3 : DÉROULAGE des périodes multiples (reporté de la156 # Phase 2, audit 02 §4) — une occurrence datée PAR période157 # publiée. Cap 24 : au-delà, c'est un calendrier d'ouverture158 # hebdomadaire, on garde l'enveloppe min-max (jamais 40 fiches159 # pour un même marché public).160 periods = sorted(161 (pp for pp in (rec.get("PeriodeOuvertures") or [])162 if isinstance(pp, dict) and pp.get("Datedebut")),163 key=lambda pp: str(pp.get("Datedebut")))164 if len(periods) > 24:165 periods = [{"Datedebut": periods[0].get("Datedebut"),166 "Datefin": periods[-1].get("Datefin")}]167 if not periods:168 periods = [{}]169 struct = rec.get("Structure") or {}170171 address = ", ".join(172 p for p in (addr.get("Numerovoie", ""), addr.get("Libellevoie", ""))173 if p) or addr.get("Adresse1", "")174175 website = ""176 for site in rec.get("SiteInternets") or []:177 url = (site or {}).get("Coordonnees", "")178 if url.startswith("http"):179 website = url180 break181182 raw_cats = _thes_labels(rec.get("Identifications") and [183 {"x": [i.get("Typedetablissementoudequipement") or {}184 for i in rec["Identifications"]]}], "x")185 raw_cats += _thes_labels(rec.get("Themes"), "Theme")186 raw_cats += _thes_labels(187 rec.get("ActivitesRecreativesCulturelless"),188 "Activitesrecreativesetculturelles")189190 audience = ", ".join(_thes_labels(191 rec.get("ClientelesAdmisess"), "Clientelesadmises"))192193 tourist_region = _first(194 rec.get("RegionsDesserviess"),195 "Regionstouristiques", "ThesLibelle", default="") or ""196197 # moyens de communication (téléphone principal, courriel) : le198 # flux n'offre aucun texte descriptif → on publie au moins les199 # coordonnées de renseignements, toujours factuelles.200 phone = _first(rec.get("TelephonePrincipals"),201 "Coordonnees", default="") or ""202 email = _first(rec.get("Courriels"), "Coordonnees", default="") or ""203 contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c)204 description = f"Renseignements : {contacts}" if contacts else ""205206 for pi, period in enumerate(periods):207 events.append(Event(208 source=self.source_id,209 # 1re période = identifiant historique (zéro churn) ;210 # périodes supplémentaires suffixées -p<i>211 external_id=ext_id if pi == 0 else f"{ext_id}-p{pi}",212 url=website or "https://www.quebecoriginal.com/",213 title=title,214 description=description,215 raw_categories=raw_cats,216 audience=audience,217 venue=addr.get("Nomdupointdeservice", "") or "",218 address=address,219 city=addr.get("Municipalite", "") or "",220 tourist_region=tourist_region,221 postal_code=addr.get("CodePostal", "") or "",222 lat=geo.get("Latitude"),223 lng=geo.get("Longitude"),224 start_date=period.get("Datedebut"),225 end_date=period.get("Datefin"),226 organizer=struct.get("Name", "") or "",227 website=website,228 ))229 self._enrich_images(events)230 return events231