SPB Git forge

spb/sorti-ka

Public

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

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
10.6 KB · 231 lines python
Raw Blame History
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