# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quebecanimee.py : Québec animée — Ville de Québec (Phase 3) # Source : https://animee.ville.quebec.qc.ca/activites/ — portail officiel # des activités gratuites/à faible coût dont la Ville est # promotrice ou partenaire (73 fiches à venir, vérifié 2026-08-19). # Comble l'absence de source municipale/gratuite pour la 2ᵉ ville # du Québec (l'inventaire Capitale-Nationale était ~100 % billetteries). # Extraction: la LISTE porte tout en attributs data- (1 requête) : # data-date-debut / data-date-fin ISO, data-arrondissement, # data-categorie, data-prix (« Gratuit ») + titre/image de la # carte ; fiches détail incrémentales (cache # data/quebecanimee_cache.json, QA_FICHE_MAX/sync, re-visite 7 j, # version de parse « v » — patron lavitrine) : description, # lieu réel (.lieu-nom) + adresse civique (.lieu-adresse) pour # le géocodage aval, et HEURES par représentation (attributs # data-date / data-heure des boutons « Ajouter à mon horaire », # vérifié 2026-08-25) → start_time par occurrence datée. # Accès : site municipal public rendu serveur — GET throttlés 0,8 s, # identification honnête, lien vers la fiche officielle. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from ..normalize import clean_text from ..schema import Event from .base import BaseConnector BASE = "https://animee.ville.quebec.qc.ca" LIST_URL = BASE + "/activites/" FICHE_MAX = int(os.environ.get("QA_FICHE_MAX", "20")) REFRESH_AFTER = 7 * 86400 PARSE_V = 2 # bump → re-visite des fiches parsées avant v2 _CARD_RE = re.compile( r']+href="(/activites/[a-z0-9-]+/)"[^>]*' r'data-date-debut="([^"]*)"[^>]*data-date-fin="([^"]*)"[^>]*' r'data-arrondissement="([^"]*)"[^>]*data-categorie="([^"]*)"[^>]*' r'data-prix="([^"]*)"[^>]*>(.*?)', re.S) _TITLE_RE = re.compile(r'
…
+…
# (l'ancien motif générique « class=*lieu* » attrapait le menu « Lieux » du site # — venue était 100 % du bruit, corrigé 2026-08-25) _LIEU_NOM_RE = re.compile(r'class="lieu-nom">\s*([^<]{2,120}?)\s*') _LIEU_ADR_RE = re.compile(r'class="lieu-adresse">\s*([^<]{2,160}?)\s*') # une représentation datée = un bouton « Ajouter à mon horaire » : # data-date="2026-08-30" … data-heure="13:30" (vérifié sur fiche réelle) _REP_RE = re.compile( r'data-date="(\d{4}-\d{2}-\d{2})"\s+data-heure="(\d{1,2}:\d{2})"') class QuebecAnimeeConnector(BaseConnector): source_id = "quebecanimee" request_delay = 0.8 def _parse_fiche(self, html: str) -> dict: m = _OG_DESC_RE.search(html) nom = _LIEU_NOM_RE.search(html) adr = _LIEU_ADR_RE.search(html) times: dict[str, str] = {} for d, h in _REP_RE.findall(html): times.setdefault(d, h) # 1ʳᵉ heure publiée pour ce jour return { "desc": clean_text(m.group(1)) if m else "", "venue": clean_text(nom.group(1)) if nom else "", "address": clean_text(adr.group(1)) if adr else "", "times": times, "v": PARSE_V, } def _enrich_fiches(self, rows: list[dict]) -> None: cache = self.load_cache() now = time.time() current = {r["url"] for r in rows} cache = {u: c for u, c in cache.items() if u in current} to_fetch = sorted( (u for u in current if u not in cache or cache[u].get("v", 1) < PARSE_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] def worker(url, session): return self._parse_fiche(session.get(url, timeout=20).text) for url, det in self.fetch_many(to_fetch, worker, max_workers=4).items(): if det is None: # fiche cassée : garder l'entrée existante det = {k: v for k, v in (cache.get(url) or {}).items() if k != "ts"} det.setdefault("desc", "") det.setdefault("venue", "") cache[url] = {"ts": now, **det} self.save_cache(cache) for r in rows: det = cache.get(r["url"]) or {} r["description"] = det.get("desc", "") if det.get("v", 1) < PARSE_V: # entrée héritée v1 : son venue vient du motif générique qui # attrapait le menu « Lieux » du site (100 % bruit, constaté # en live 2026-08-25) — on n'en publie que la description en # attendant le backfill v2 (plafond QA_FICHE_MAX/sync). det = {} r["venue"] = det.get("venue", "") r["address"] = det.get("address", "") # heure de LA représentation de cette occurrence datée (jamais # inventée : seulement si la fiche publie ce jour précis) r["start_time"] = (det.get("times") or {}).get(r["start_date"] or "") def fetch(self) -> list[Event]: html = self.get(LIST_URL).text rows: list[dict] = [] for m in _CARD_RE.finditer(html): path, debut, fin, arrond, cats, prix, inner = m.groups() t = _TITLE_RE.search(inner) title = clean_text((t.group(1) or t.group(2)) if t else "") if not title: # repli : dernier texte non vide de la carte texts = [clean_text(x) for x in re.findall(r">([^<>]{4,120})<", inner)] title = texts[-1] if texts else "" if not title: continue img = _IMG_RE.search(inner) slug = path.strip("/").split("/")[-1] # la liste répète une carte PAR occurrence datée d'une même # activité → l'identifiant inclut la date (séances distinctes) rows.append({ "external_id": f"{slug}-{debut or 'nd'}", "url": BASE + path, "title": title, "start_date": debut or None, "end_date": fin or debut or None, "arrondissement": clean_text(arrond), "cats": [c.strip() for c in clean_text(cats).split(",") if c.strip()], "prix": clean_text(prix), "image": img.group(1) if img else "", }) self._enrich_fiches(rows) events: list[Event] = [] seen: set[str] = set() for r in rows: if r["external_id"] in seen: continue seen.add(r["external_id"]) img = r["image"] if img.startswith("/"): img = BASE + img 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["cats"] or [r["title"]], venue=r.get("venue", ""), address=r.get("address", ""), city="Québec", region="Capitale-Nationale", start_date=r["start_date"], start_time=r.get("start_time") or None, end_date=r["end_date"], price_label=r["prix"], image=img, organizer="Ville de Québec", )) return events