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%
7.5 KB · 174 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/evenementsattractions.py : connecteur Événements Attractions Québec5#   Source    : répertoire consommateur des festivals/événements membres d'EAQ,6#               publié sur quebecvacances.com (~350 fiches fr, toute la province).7#               (Le site corporatif evenementsattractions.quebec ne liste que8#               des formations aux membres — sondé 2026-08-25, sans intérêt.)9#   Extraction: sitemap paginé (/sitemap.xml?page=1..N) → fiches10#               /fr/evenements/<slug>, chacune avec JSON-LD Event complet11#               (nom, description, dates ISO de la prochaine édition, lieu,12#               adresse, image Cloudinary). Le site est derrière un anti-bot13#               (403 en GET direct) → chaque GET passe par la chaîne de secours14#               du BaseConnector (Oxylabs → Scrapfly ASP → Bright Data),15#               d'où un cache INCRÉMENTAL agressif (data/16#               evenementsattractions_cache.json) : nouvelles fiches d'abord,17#               re-visite après 14 jours (festivals annuels stables), plafond18#               EAQ_DETAIL_MAX fiches/sync.19#   Accès     : quebecvacances.com/robots.txt « User-agent: * / Allow: / » →20#               crawl permis ; rate-limiting poli, identification honnête.21#   Prix      : non publié dans le JSON-LD → is_free inconnu (jamais inventé).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import os27import re28import time2930from ..normalize import clean_text31from ..schema import Event32from .base import BaseConnector3334BASE = "https://www.quebecvacances.com"35SITEMAP_PAGES = 4                 # /sitemap.xml?page=1..4 (sondé 2026-08-25)3637DETAIL_MAX = int(os.environ.get("EAQ_DETAIL_MAX", "80"))38REFRESH_AFTER = 14 * 86400        # festivals annuels : fiches stables3940_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")41_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',42                    re.S)43# « Montréal (ville de) » / « Québec (ville de) » → nom de ville nu44_CITY_SUFFIX_RE = re.compile(r"\s*\((?:ville|municipalité|village|paroisse|"45                             r"canton|mrc)[^)]*\)\s*$", re.I)464748def _utf8_text(resp) -> str:49    """Texte de la réponse décodé en UTF-8 réel.5051    Le site sert son HTML UTF-8 sans charset dans Content-Type ; certains52    backends de la chaîne de secours anti-bot décodent alors en latin-1 →53    mojibake (« siège » au lieu de « siège », constaté live 2026-08-25).54    On répare de façon déterministe : latin-1 est bijectif octet↔caractère,55    donc ré-encoder puis décoder en UTF-8 restaure le texte d'origine.56    """57    text = resp.text58    if "Ã" in text:                    # marqueur sûr de mojibake UTF-8/latin-159        try:60            return text.encode("latin-1").decode("utf-8")61        except (UnicodeEncodeError, UnicodeDecodeError):62            pass63    return text646566class EvenementsAttractionsConnector(BaseConnector):67    source_id = "evenementsattractions"68    request_delay = 1.06970    # -- découverte (sitemap paginé) -------------------------------------------71    def _fiches(self) -> list[str]:72        urls: list[str] = []73        for page in range(1, SITEMAP_PAGES + 1):74            try:75                xml = self.get(f"{BASE}/sitemap.xml", params={"page": page}).text76            except Exception as exc:  # une page cassée ne bloque pas le reste77                print(f"[sorti-ka] evenementsattractions : sitemap p{page} "78                      f"ignorée : {exc}")79                continue80            urls += [u for u in _LOC_RE.findall(xml)81                     if "/fr/evenements/" in u]82        return sorted(set(urls))8384    # -- fiche --------------------------------------------------------------------85    def _parse_fiche(self, url: str, html: str) -> dict | None:86        data = None87        for blob in _LD_RE.findall(html):88            try:89                d = json.loads(blob.strip())90            except ValueError:91                continue92            if d.get("@type") == "Event" and d.get("startDate"):93                data = d94                break95        if not data:96            return None97        title = clean_text(data.get("name") or "")98        if not title:99            return None100        loc = data.get("location") or {}101        addr = loc.get("address") or {}102        city = _CITY_SUFFIX_RE.sub("", clean_text(addr.get("addressLocality")103                                                  or ""))104        venue = clean_text(loc.get("name") or "")105        if venue == title:            # la source répète le nom de l'événement106            venue = ""107        return {108            "title": title,109            "description": clean_text(data.get("description") or "")[:2000],110            "raw_categories": [title],111            "venue": venue,112            "address": clean_text(addr.get("streetAddress") or ""),113            "city": city,114            "postal_code": addr.get("postalCode") or "",115            "start_date": str(data.get("startDate") or "")[:10] or None,116            "end_date": str(data.get("endDate") or "")[:10] or None,117            "website": data.get("url") or "",118            "image": data.get("image") or "",119        }120121    # -- pipeline -------------------------------------------------------------------122    def fetch(self) -> list[Event]:123        cache = self.load_cache()124        urls = self._fiches()125        if urls:                      # sitemap indisponible → on garde le cache126            current = set(urls)127            cache = {u: c for u, c in cache.items() if u in current}128        else:129            urls = list(cache)130131        now = time.time()132        candidates = sorted(133            (u for u in urls134             if u not in cache135             or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),136            key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX]137138        for url in candidates:        # séquentiel : chaque GET peut escalader139            try:140                row = self._parse_fiche(url, _utf8_text(self.get(url)))141            except Exception as exc:  # fiche cassée : on ne bloque pas la source142                print(f"[sorti-ka] evenementsattractions : fiche ignorée "143                      f"{url} : {exc}")144                row = cache.get(url, {}).get("row")145            cache[url] = {"fetched_at": now, "row": row}146        self.save_cache(cache)147148        events: list[Event] = []149        seen: set[str] = set()150        for url, entry in cache.items():151            row = entry.get("row")152            if not row:153                continue154            slug = url.rstrip("/").rsplit("/", 1)[-1]155            if slug in seen:156                continue157            seen.add(slug)158            events.append(Event(159                source=self.source_id,160                external_id=slug,161                url=url, title=row["title"],162                description=row.get("description", ""),163                raw_categories=row.get("raw_categories") or [],164                venue=row.get("venue", ""),165                address=row.get("address", ""),166                city=row.get("city", ""),167                postal_code=row.get("postal_code", ""),168                start_date=row.get("start_date"),169                end_date=row.get("end_date"),170                website=row.get("website", ""),171                image=row.get("image", ""),172            ))173        return events174