# -----------------------------------------------------------------------------
# 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