# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gaspesie.py : Tourisme Gaspésie — festivals et événements (ATR, # Phase 4). Région parmi les plus faibles au baseline (Phase 3 : candidate # prioritaire consignée pour une vague Scrapfly) — la voici. # Source : https://www.tourisme-gaspesie.com/fr/festival-et-evenements/ # (CMS Umbraco). Le site est derrière un WAF : 403 sur GET # identifié (revérifié 2026-08-21) → extraction via SCRAPFLY # (asp, country=ca, sans render_js — le HTML est rendu serveur), # usage consigné et budgeté (GASP_FICHE_MAX/sync). La liste # publie les 27 fiches d'un coup (pagination purement côté # client, 12/page — revérifié 2026-08-25) : rien de caché. # Extraction: liste (1 crédit) → fiches détail incrémentales (cache # data/gaspesie_cache.json, GASP_FICHE_MAX/sync, re-visite 14 j # — festivals annuels stables, version v=2) : titre

, plage # de dates FR (« 18 au 21 juin 2026 »), og:description, og:image. # ENRICHI (2026-08-25) : JSON-LD Organization de la fiche → GPS # exact (GeoCoordinates), adresse civique/ville/code postal # structurés, site web officiel (sameAs non-réseau-social). # Pas d'heure ni de tarif structurés → jamais inventés. # Accès : contenu public de l'ATR ; anti-bot contourné avec modération # (≤ 1 + GASP_FICHE_MAX crédits Scrapfly/sync, re-visite 14 j), # attribution et lien vers la fiche officielle conservés. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from ..normalize import clean_text, parse_date_range_fr from ..schema import Event from .base import BaseConnector BASE = "https://www.tourisme-gaspesie.com" LIST_URL = BASE + "/fr/festival-et-evenements/" FICHE_MAX = int(os.environ.get("GASP_FICHE_MAX", "8")) REFRESH_AFTER = 14 * 86400 # festivals annuels : 2 semaines CACHE_V = 2 # v2 : + GPS/adresse structurée/site web (JSON-LD # Organization) — 2026-08-25 _FICHE_RE = re.compile(r'href="(/fr/festival-et-evenements/[a-z0-9-]+/)"') _TITLE_RE = re.compile(r"]*>\s*([^<]+?)\s*

") _DATE_RE = re.compile( r'font-extrabold[^>]*>\s*((?:[Dd]u\s+)?\d{1,2}(?:er)?\s+[^<]*?\d{4})\s*<') # bloc coordonnées : «

775, route Flavie-Drapeau
Sainte-Flavie, # Québec G0J 2L0

» ou «

Carleton-sur-Mer, Québec

» — le « é » est # publié en entité HTML (é) par le CMS _ADDR_CITY_RE = re.compile( r"

\s*(?:(?P[^<>]{3,80}?)\s*]*>\s*)?" r"(?P[^<>]{2,60}?),\s*Qu(?:é|é|é)bec" r"(?:\s*(?P[A-Z]\d[A-Z]\s?\d[A-Z]\d))?", re.S) _OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"') _OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"') _LDJSON_RE = re.compile( r'', re.S) _SOCIAL = ("facebook.", "instagram.", "youtube.", "twitter.", "x.com", "tiktok.", "linkedin.") def _ld_organization(html: str) -> dict: """Bloc JSON-LD Organization d'une fiche (GPS, adresse, sameAs).""" for m in _LDJSON_RE.finditer(html): try: d = json.loads(m.group(1)) except ValueError: continue for doc in (d if isinstance(d, list) else [d]): if isinstance(doc, dict) and doc.get("@type") == "Organization": return doc return {} class GaspesieConnector(BaseConnector): source_id = "gaspesie" request_delay = 1.5 # crédits Scrapfly : modération def _get_waf(self, url: str) -> str: """GET via Scrapfly (WAF) — HTML rendu serveur, pas de render_js.""" return self.get_rendered(url, render_js=False) def _parse_fiche(self, url: str, html: str) -> dict | None: title = _TITLE_RE.search(html) if not title: return None date_raw = _DATE_RE.search(html) raw = date_raw.group(1) if date_raw else "" # « 18 au 21 juin 2026 » sans « du » initial → on le préfixe pour que # parse_date_range_fr retrouve le jour de début (jamais inventé) if re.match(r"^\d", raw or ""): raw = "du " + raw start, end = parse_date_range_fr(raw) coord = _ADDR_CITY_RE.search(html) img = _OG_IMG_RE.search(html) desc = _OG_DESC_RE.search(html) # JSON-LD Organization : GPS + adresse structurée + site web officiel org = _ld_organization(html) geo = org.get("geo") or {} addr = org.get("address") or {} website = "" for same in (org.get("sameAs") or []): s = str(same).strip() if s.startswith("http") and not any(k in s for k in _SOCIAL): website = s break return { "external_id": url.rstrip("/").split("/")[-1], "url": BASE + url, "title": clean_text(title.group(1)), "description": clean_text(desc.group(1)) if desc else "", "address": clean_text(addr.get("streetAddress") or "") or (clean_text(coord.group("addr") or "") if coord else ""), "city": clean_text(addr.get("addressLocality") or "") or (clean_text(coord.group("city")) if coord else ""), "postal_code": (addr.get("postalCode") or "").strip() or (clean_text(coord.group("pc") or "") if coord else ""), "lat": geo.get("latitude"), "lng": geo.get("longitude"), "website": website, "start_date": start, "end_date": end, "image": clean_text(img.group(1)) if img else "", } def fetch(self) -> list[Event]: html = self._get_waf(LIST_URL) urls = sorted({u for u in _FICHE_RE.findall(html) if u.rstrip("/") != "/fr/festival-et-evenements"}) cache = self.load_cache() now = time.time() current = set(urls) cache = {u: c for u, c in cache.items() if u in current} # nouvelles fiches puis celles parsées avant v2 (sans GPS/site web), # puis re-visite roulante — budget Scrapfly plafonné to_fetch = sorted( (u for u in urls if u not in cache or cache[u].get("v", 1) < CACHE_V or now - cache[u].get("ts", 0) > REFRESH_AFTER), key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0, cache.get(u, {}).get("ts", 0)))[:FICHE_MAX] for url in to_fetch: # séquentiel : budget crédits try: row = self._parse_fiche(url, self._get_waf(BASE + url)) except Exception: row = None if row is None: print(f"[sorti-ka] gaspesie : fiche ignorée {url}") cache[url] = {"ts": now, "row": row, "v": CACHE_V} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for entry in cache.values(): r = entry.get("row") if not r or r["external_id"] in seen or not r["start_date"]: continue seen.add(r["external_id"]) events.append(Event( source=self.source_id, external_id=r["external_id"], url=r["url"], title=r["title"], description=r.get("description", ""), raw_categories=[r["title"]], address=r.get("address", ""), city=r["city"], postal_code=r.get("postal_code", ""), tourist_region="Gaspésie", lat=r.get("lat"), lng=r.get("lng"), start_date=r["start_date"], end_date=r["end_date"], website=r.get("website", ""), image=r["image"], )) return events