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%
6.8 KB · 155 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/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