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/gaspesie.py : Tourisme Gaspésie — festivals et événements (ATR,5# Phase 4). Région parmi les plus faibles au baseline (Phase 3 : candidate6# prioritaire consignée pour une vague Scrapfly) — la voici.7# Source : https://www.tourisme-gaspesie.com/fr/festival-et-evenements/8# (CMS Umbraco). Le site est derrière un WAF : 403 sur GET9# identifié (revérifié 2026-08-21) → extraction via SCRAPFLY10# (asp, country=ca, sans render_js — le HTML est rendu serveur),11# usage consigné et budgeté (GASP_FICHE_MAX/sync). La liste12# publie les 27 fiches d'un coup (pagination purement côté13# client, 12/page — revérifié 2026-08-25) : rien de caché.14# Extraction: liste (1 crédit) → fiches détail incrémentales (cache15# data/gaspesie_cache.json, GASP_FICHE_MAX/sync, re-visite 14 j16# — festivals annuels stables, version v=2) : titre <h1>, plage17# de dates FR (« 18 au 21 juin 2026 »), og:description, og:image.18# ENRICHI (2026-08-25) : JSON-LD Organization de la fiche → GPS19# exact (GeoCoordinates), adresse civique/ville/code postal20# structurés, site web officiel (sameAs non-réseau-social).21# Pas d'heure ni de tarif structurés → jamais inventés.22# Accès : contenu public de l'ATR ; anti-bot contourné avec modération23# (≤ 1 + GASP_FICHE_MAX crédits Scrapfly/sync, re-visite 14 j),24# attribution et lien vers la fiche officielle conservés.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import json29import os30import re31import time3233from ..normalize import clean_text, parse_date_range_fr34from ..schema import Event35from .base import BaseConnector3637BASE = "https://www.tourisme-gaspesie.com"38LIST_URL = BASE + "/fr/festival-et-evenements/"39FICHE_MAX = int(os.environ.get("GASP_FICHE_MAX", "8"))40REFRESH_AFTER = 14 * 86400 # festivals annuels : 2 semaines41CACHE_V = 2 # v2 : + GPS/adresse structurée/site web (JSON-LD42# Organization) — 2026-08-254344_FICHE_RE = re.compile(r'href="(/fr/festival-et-evenements/[a-z0-9-]+/)"')45_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]+?)\s*</h1>")46_DATE_RE = re.compile(47 r'font-extrabold[^>]*>\s*((?:[Dd]u\s+)?\d{1,2}(?:er)?\s+[^<]*?\d{4})\s*<')48# bloc coordonnées : « <p>775, route Flavie-Drapeau<br>Sainte-Flavie,49# Québec G0J 2L0</p> » ou « <p>Carleton-sur-Mer, Québec</p> » — le « é » est50# publié en entité HTML (é) par le CMS51_ADDR_CITY_RE = re.compile(52 r"<p>\s*(?:(?P<addr>[^<>]{3,80}?)\s*<br[^>]*>\s*)?"53 r"(?P<city>[^<>]{2,60}?),\s*Qu(?:é|é|é)bec"54 r"(?:\s*(?P<pc>[A-Z]\d[A-Z]\s?\d[A-Z]\d))?", re.S)55_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"')56_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')57_LDJSON_RE = re.compile(58 r'<script type="application/ld\+json"[^>]*>(.*?)</script>', re.S)59_SOCIAL = ("facebook.", "instagram.", "youtube.", "twitter.", "x.com",60 "tiktok.", "linkedin.")616263def _ld_organization(html: str) -> dict:64 """Bloc JSON-LD Organization d'une fiche (GPS, adresse, sameAs)."""65 for m in _LDJSON_RE.finditer(html):66 try:67 d = json.loads(m.group(1))68 except ValueError:69 continue70 for doc in (d if isinstance(d, list) else [d]):71 if isinstance(doc, dict) and doc.get("@type") == "Organization":72 return doc73 return {}747576class GaspesieConnector(BaseConnector):77 source_id = "gaspesie"78 request_delay = 1.5 # crédits Scrapfly : modération7980 def _get_waf(self, url: str) -> str:81 """GET via Scrapfly (WAF) — HTML rendu serveur, pas de render_js."""82 return self.get_rendered(url, render_js=False)8384 def _parse_fiche(self, url: str, html: str) -> dict | None:85 title = _TITLE_RE.search(html)86 if not title:87 return None88 date_raw = _DATE_RE.search(html)89 raw = date_raw.group(1) if date_raw else ""90 # « 18 au 21 juin 2026 » sans « du » initial → on le préfixe pour que91 # parse_date_range_fr retrouve le jour de début (jamais inventé)92 if re.match(r"^\d", raw or ""):93 raw = "du " + raw94 start, end = parse_date_range_fr(raw)95 coord = _ADDR_CITY_RE.search(html)96 img = _OG_IMG_RE.search(html)97 desc = _OG_DESC_RE.search(html)98 # JSON-LD Organization : GPS + adresse structurée + site web officiel99 org = _ld_organization(html)100 geo = org.get("geo") or {}101 addr = org.get("address") or {}102 website = ""103 for same in (org.get("sameAs") or []):104 s = str(same).strip()105 if s.startswith("http") and not any(k in s for k in _SOCIAL):106 website = s107 break108 return {109 "external_id": url.rstrip("/").split("/")[-1],110 "url": BASE + url,111 "title": clean_text(title.group(1)),112 "description": clean_text(desc.group(1)) if desc else "",113 "address": clean_text(addr.get("streetAddress") or "")114 or (clean_text(coord.group("addr") or "") if coord else ""),115 "city": clean_text(addr.get("addressLocality") or "")116 or (clean_text(coord.group("city")) if coord else ""),117 "postal_code": (addr.get("postalCode") or "").strip()118 or (clean_text(coord.group("pc") or "") if coord else ""),119 "lat": geo.get("latitude"), "lng": geo.get("longitude"),120 "website": website,121 "start_date": start, "end_date": end,122 "image": clean_text(img.group(1)) if img else "",123 }124125 def fetch(self) -> list[Event]:126 html = self._get_waf(LIST_URL)127 urls = sorted({u for u in _FICHE_RE.findall(html)128 if u.rstrip("/") != "/fr/festival-et-evenements"})129 cache = self.load_cache()130 now = time.time()131 current = set(urls)132 cache = {u: c for u, c in cache.items() if u in current}133 # nouvelles fiches puis celles parsées avant v2 (sans GPS/site web),134 # puis re-visite roulante — budget Scrapfly plafonné135 to_fetch = sorted(136 (u for u in urls137 if u not in cache or cache[u].get("v", 1) < CACHE_V138 or now - cache[u].get("ts", 0) > REFRESH_AFTER),139 key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,140 cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]141 for url in to_fetch: # séquentiel : budget crédits142 try:143 row = self._parse_fiche(url, self._get_waf(BASE + url))144 except Exception:145 row = None146 if row is None:147 print(f"[sorti-ka] gaspesie : fiche ignorée {url}")148 cache[url] = {"ts": now, "row": row, "v": CACHE_V}149 self.save_cache(cache)150151 events: list[Event] = []152 seen: set[str] = set()153 for entry in cache.values():154 r = entry.get("row")155 if not r or r["external_id"] in seen or not r["start_date"]:156 continue157 seen.add(r["external_id"])158 events.append(Event(159 source=self.source_id,160 external_id=r["external_id"],161 url=r["url"],162 title=r["title"],163 description=r.get("description", ""),164 raw_categories=[r["title"]],165 address=r.get("address", ""),166 city=r["city"],167 postal_code=r.get("postal_code", ""),168 tourist_region="Gaspésie",169 lat=r.get("lat"), lng=r.get("lng"),170 start_date=r["start_date"],171 end_date=r["end_date"],172 website=r.get("website", ""),173 image=r["image"],174 ))175 return events176