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%
8.2 KB · 176 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/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 (&#xE9;) 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(?:&#xE9;|&eacute;|é)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