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/cotenord.py : connecteur Tourisme Côte-Nord (ATR, vague ATR 2026-08)5# Source : tourismecote-nord.com/fr/activites/culture-et-decouverte/6# evenements-et-spectacles/ — calendrier officiel de l'ATR.7# Extraction: la page liste (rendue serveur) embarque TOUTES les fiches dans8# un bloc JavaScript « var fiches = [...] » (JSON complet vérifié9# 2026-08-25 : ~37 fiches avec titre, description, dates ISO10# dateDebutFiche/dateFinFiche, ville, adresse civique, GPS,11# tarif entreeFicheLangue, site web). 1 GET/sync pour tout le12# gisement. Fiches détail incrémentales (cache disque13# data/cotenord_cache.json, plafond CN_FICHE_MAX/sync,14# re-visite 7 j) UNIQUEMENT pour résoudre l'URL d'image15# optimisée (fichiersUploadOpt/<hash>/<fichier>, hash non16# dérivable du JSON).17# Accès : pages publiques de l'ATR, robots.txt « Allow: / » — GET directs18# throttlés, identification honnête, lien vers la fiche19# originale. Prix : texte brut de la source (price_label),20# jamais inventé.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import os26import re27import time2829from ..normalize import clean_text30from ..schema import Event31from .base import BaseConnector3233BASE = "https://www.tourismecote-nord.com"34LIST_URL = BASE + "/fr/activites/culture-et-decouverte/evenements-et-spectacles/"35CN_FICHE_MAX = int(os.environ.get("CN_FICHE_MAX", "10")) # fiches image/sync36REFRESH_AFTER = 7 * 864003738# « opt-<x>-<y>-<w>-<h>-<a>-<b>-<fichier> » → nom de fichier original39_IMG_NAME_RE = re.compile(r"^opt(?:-\d+){6}-")404142def _fiches_json(html: str) -> list[dict]:43 """Le bloc « var fiches = [...] » de la page liste (JSON brut)."""44 i = html.find("var fiches = ")45 if i < 0:46 return []47 try:48 data, _ = json.JSONDecoder().raw_decode(html, html.index("[", i))49 except (ValueError, IndexError):50 return []51 return [f for f in data if isinstance(f, dict)]525354def _date_or_none(raw: str | None) -> str | None:55 """Date ISO de la source, en écartant les placeholders « 0000-00-00 »."""56 raw = (raw or "").strip()57 return raw if re.match(r"^(?!0000)\d{4}-\d{2}-\d{2}$", raw) else None585960def _latlng(fiche: dict) -> tuple[float | None, float | None]:61 """GPS le plus précis publié : adresse > fiche > ville (0 = absent)."""62 for lat_k, lng_k in (("latitudeAdresse", "longitudeAdresse"),63 ("latitudeFiche", "longitudeFiche"),64 ("latitudeVille", "longitudeVille")):65 try:66 lat, lng = float(fiche.get(lat_k) or 0), float(fiche.get(lng_k) or 0)67 except (TypeError, ValueError):68 continue69 if lat and lng:70 return lat, lng71 return None, None727374class CoteNordConnector(BaseConnector):75 source_id = "cotenord"76 request_delay = 0.87778 def _image_from_fiche(self, html: str, image_name: str) -> str:79 """URL optimisée de l'image de la fiche (hash présent sur la page)."""80 fname = _IMG_NAME_RE.sub("", image_name or "").strip()81 if not fname:82 return ""83 m = re.search(r"fichiersUploadOpt/[^\"'\\ )]*/" + re.escape(fname), html)84 return BASE + "/" + m.group(0) if m else ""8586 def fetch(self) -> list[Event]:87 fiches = _fiches_json(self.get(LIST_URL).text)88 cache = self.load_cache()89 now = time.time()90 current = {str(f.get("noFiche")) for f in fiches}91 cache = {k: v for k, v in cache.items() if k in current}9293 # fiches détail (image seulement) : incrémental + plafond94 to_fetch = sorted(95 (f for f in fiches96 if f.get("repertoireMembreLangue") and f.get("imageFiche")97 and (str(f.get("noFiche")) not in cache98 or now - cache[str(f.get("noFiche"))].get("ts", 0) > REFRESH_AFTER)),99 key=lambda f: cache.get(str(f.get("noFiche")), {}).get("ts", 0)100 )[:CN_FICHE_MAX]101 for fiche in to_fetch:102 eid = str(fiche.get("noFiche"))103 url = f"{BASE}/fr/{fiche['repertoireMembreLangue']}/evenements-et-spectacles/"104 image = ""105 try:106 image = self._image_from_fiche(self.get(url).text,107 fiche.get("imageFiche", ""))108 except Exception:109 pass110 cache[eid] = {"ts": now, "image": image}111 self.save_cache(cache)112113 events: list[Event] = []114 seen: set[str] = set()115 for f in fiches:116 eid = str(f.get("noFiche") or "")117 title = clean_text(f.get("overrideTitreFicheLangue")118 or f.get("titreFicheLangue")119 or f.get("titreMembreLangue") or "")120 slug = (f.get("repertoireMembreLangue") or "").strip()121 if not eid or eid in seen or not title or not slug:122 continue123 seen.add(eid)124 lat, lng = _latlng(f)125 address = clean_text(" ".join(126 p for p in (f.get("noCiviqueAdresse"), f.get("rueAdresse"))127 if p) or f.get("adresseFiche") or "")128 price_label = clean_text(" — ".join(129 p for p in (f.get("entreeFicheLangue"), f.get("detailPrixLangue"))130 if p and str(p).strip()))131 website = (f.get("siteInternetFicheLangue")132 or f.get("siteInternetFiche") or "").strip()133 if website and not website.startswith("http"):134 website = "https://" + website135 events.append(Event(136 source=self.source_id,137 external_id=eid,138 url=f"{BASE}/fr/{slug}/evenements-et-spectacles/",139 title=title,140 description=clean_text(f.get("descriptionFicheLangue") or ""),141 raw_categories=["Événements et spectacles", title],142 address=address,143 city=clean_text(f.get("titreVille") or f.get("villeFiche") or ""),144 postal_code=(f.get("codePostalAdresse") or f.get("codePostalFiche")145 or f.get("cpFiche") or "").strip(),146 tourist_region="Côte-Nord",147 lat=lat, lng=lng,148 start_date=_date_or_none(f.get("dateDebutFiche")),149 end_date=_date_or_none(f.get("dateFinFiche")),150 price_label=price_label,151 website=website,152 image=cache.get(eid, {}).get("image", ""),153 ))154 return events155