# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cotenord.py : connecteur Tourisme Côte-Nord (ATR, vague ATR 2026-08) # Source : tourismecote-nord.com/fr/activites/culture-et-decouverte/ # evenements-et-spectacles/ — calendrier officiel de l'ATR. # Extraction: la page liste (rendue serveur) embarque TOUTES les fiches dans # un bloc JavaScript « var fiches = [...] » (JSON complet vérifié # 2026-08-25 : ~37 fiches avec titre, description, dates ISO # dateDebutFiche/dateFinFiche, ville, adresse civique, GPS, # tarif entreeFicheLangue, site web). 1 GET/sync pour tout le # gisement. Fiches détail incrémentales (cache disque # data/cotenord_cache.json, plafond CN_FICHE_MAX/sync, # re-visite 7 j) UNIQUEMENT pour résoudre l'URL d'image # optimisée (fichiersUploadOpt//, hash non # dérivable du JSON). # Accès : pages publiques de l'ATR, robots.txt « Allow: / » — GET directs # throttlés, identification honnête, lien vers la fiche # originale. Prix : texte brut de la source (price_label), # jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://www.tourismecote-nord.com" LIST_URL = BASE + "/fr/activites/culture-et-decouverte/evenements-et-spectacles/" CN_FICHE_MAX = int(os.environ.get("CN_FICHE_MAX", "10")) # fiches image/sync REFRESH_AFTER = 7 * 86400 # « opt------- » → nom de fichier original _IMG_NAME_RE = re.compile(r"^opt(?:-\d+){6}-") def _fiches_json(html: str) -> list[dict]: """Le bloc « var fiches = [...] » de la page liste (JSON brut).""" i = html.find("var fiches = ") if i < 0: return [] try: data, _ = json.JSONDecoder().raw_decode(html, html.index("[", i)) except (ValueError, IndexError): return [] return [f for f in data if isinstance(f, dict)] def _date_or_none(raw: str | None) -> str | None: """Date ISO de la source, en écartant les placeholders « 0000-00-00 ».""" raw = (raw or "").strip() return raw if re.match(r"^(?!0000)\d{4}-\d{2}-\d{2}$", raw) else None def _latlng(fiche: dict) -> tuple[float | None, float | None]: """GPS le plus précis publié : adresse > fiche > ville (0 = absent).""" for lat_k, lng_k in (("latitudeAdresse", "longitudeAdresse"), ("latitudeFiche", "longitudeFiche"), ("latitudeVille", "longitudeVille")): try: lat, lng = float(fiche.get(lat_k) or 0), float(fiche.get(lng_k) or 0) except (TypeError, ValueError): continue if lat and lng: return lat, lng return None, None class CoteNordConnector(BaseConnector): source_id = "cotenord" request_delay = 0.8 def _image_from_fiche(self, html: str, image_name: str) -> str: """URL optimisée de l'image de la fiche (hash présent sur la page).""" fname = _IMG_NAME_RE.sub("", image_name or "").strip() if not fname: return "" m = re.search(r"fichiersUploadOpt/[^\"'\\ )]*/" + re.escape(fname), html) return BASE + "/" + m.group(0) if m else "" def fetch(self) -> list[Event]: fiches = _fiches_json(self.get(LIST_URL).text) cache = self.load_cache() now = time.time() current = {str(f.get("noFiche")) for f in fiches} cache = {k: v for k, v in cache.items() if k in current} # fiches détail (image seulement) : incrémental + plafond to_fetch = sorted( (f for f in fiches if f.get("repertoireMembreLangue") and f.get("imageFiche") and (str(f.get("noFiche")) not in cache or now - cache[str(f.get("noFiche"))].get("ts", 0) > REFRESH_AFTER)), key=lambda f: cache.get(str(f.get("noFiche")), {}).get("ts", 0) )[:CN_FICHE_MAX] for fiche in to_fetch: eid = str(fiche.get("noFiche")) url = f"{BASE}/fr/{fiche['repertoireMembreLangue']}/evenements-et-spectacles/" image = "" try: image = self._image_from_fiche(self.get(url).text, fiche.get("imageFiche", "")) except Exception: pass cache[eid] = {"ts": now, "image": image} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for f in fiches: eid = str(f.get("noFiche") or "") title = clean_text(f.get("overrideTitreFicheLangue") or f.get("titreFicheLangue") or f.get("titreMembreLangue") or "") slug = (f.get("repertoireMembreLangue") or "").strip() if not eid or eid in seen or not title or not slug: continue seen.add(eid) lat, lng = _latlng(f) address = clean_text(" ".join( p for p in (f.get("noCiviqueAdresse"), f.get("rueAdresse")) if p) or f.get("adresseFiche") or "") price_label = clean_text(" — ".join( p for p in (f.get("entreeFicheLangue"), f.get("detailPrixLangue")) if p and str(p).strip())) website = (f.get("siteInternetFicheLangue") or f.get("siteInternetFiche") or "").strip() if website and not website.startswith("http"): website = "https://" + website events.append(Event( source=self.source_id, external_id=eid, url=f"{BASE}/fr/{slug}/evenements-et-spectacles/", title=title, description=clean_text(f.get("descriptionFicheLangue") or ""), raw_categories=["Événements et spectacles", title], address=address, city=clean_text(f.get("titreVille") or f.get("villeFiche") or ""), postal_code=(f.get("codePostalAdresse") or f.get("codePostalFiche") or f.get("cpFiche") or "").strip(), tourist_region="Côte-Nord", lat=lat, lng=lng, start_date=_date_or_none(f.get("dateDebutFiche")), end_date=_date_or_none(f.get("dateFinFiche")), price_label=price_label, website=website, image=cache.get(eid, {}).get("image", ""), )) return events