Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/quebecanimee.py : Québec animée — Ville de Québec (Phase 3)5# Source : https://animee.ville.quebec.qc.ca/activites/ — portail officiel6# des activités gratuites/à faible coût dont la Ville est7# promotrice ou partenaire (73 fiches à venir, vérifié 2026-08-19).8# Comble l'absence de source municipale/gratuite pour la 2ᵉ ville9# du Québec (l'inventaire Capitale-Nationale était ~100 % billetteries).10# Extraction: la LISTE porte tout en attributs data- (1 requête) :11# data-date-debut / data-date-fin ISO, data-arrondissement,12# data-categorie, data-prix (« Gratuit ») + titre/image de la13# carte ; fiches détail incrémentales (cache14# data/quebecanimee_cache.json, QA_FICHE_MAX/sync, re-visite 7 j,15# version de parse « v » — patron lavitrine) : description,16# lieu réel (.lieu-nom) + adresse civique (.lieu-adresse) pour17# le géocodage aval, et HEURES par représentation (attributs18# data-date / data-heure des boutons « Ajouter à mon horaire »,19# vérifié 2026-08-25) → start_time par occurrence datée.20# Accès : site municipal public rendu serveur — GET throttlés 0,8 s,21# identification honnête, lien vers la fiche officielle.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import os26import re27import time2829from ..normalize import clean_text30from ..schema import Event31from .base import BaseConnector3233BASE = "https://animee.ville.quebec.qc.ca"34LIST_URL = BASE + "/activites/"35FICHE_MAX = int(os.environ.get("QA_FICHE_MAX", "20"))36REFRESH_AFTER = 7 * 8640037PARSE_V = 2 # bump → re-visite des fiches parsées avant v23839_CARD_RE = re.compile(40 r'<a[^>]+href="(/activites/[a-z0-9-]+/)"[^>]*'41 r'data-date-debut="([^"]*)"[^>]*data-date-fin="([^"]*)"[^>]*'42 r'data-arrondissement="([^"]*)"[^>]*data-categorie="([^"]*)"[^>]*'43 r'data-prix="([^"]*)"[^>]*>(.*?)</a>', re.S)44_TITLE_RE = re.compile(r'<h3[^>]*>\s*([^<]+?)\s*</h3>|<h2[^>]*>\s*([^<]+?)\s*</h2>')45_IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"')46_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')47# le lieu réel de la fiche : <p class="lieu-nom">…</p> + <p class="lieu-adresse">…</p>48# (l'ancien motif générique « class=*lieu* » attrapait le menu « Lieux » du site49# — venue était 100 % du bruit, corrigé 2026-08-25)50_LIEU_NOM_RE = re.compile(r'class="lieu-nom">\s*([^<]{2,120}?)\s*</p>')51_LIEU_ADR_RE = re.compile(r'class="lieu-adresse">\s*([^<]{2,160}?)\s*</p>')52# une représentation datée = un bouton « Ajouter à mon horaire » :53# data-date="2026-08-30" … data-heure="13:30" (vérifié sur fiche réelle)54_REP_RE = re.compile(55 r'data-date="(\d{4}-\d{2}-\d{2})"\s+data-heure="(\d{1,2}:\d{2})"')565758class QuebecAnimeeConnector(BaseConnector):59 source_id = "quebecanimee"60 request_delay = 0.86162 def _parse_fiche(self, html: str) -> dict:63 m = _OG_DESC_RE.search(html)64 nom = _LIEU_NOM_RE.search(html)65 adr = _LIEU_ADR_RE.search(html)66 times: dict[str, str] = {}67 for d, h in _REP_RE.findall(html):68 times.setdefault(d, h) # 1ʳᵉ heure publiée pour ce jour69 return {70 "desc": clean_text(m.group(1)) if m else "",71 "venue": clean_text(nom.group(1)) if nom else "",72 "address": clean_text(adr.group(1)) if adr else "",73 "times": times,74 "v": PARSE_V,75 }7677 def _enrich_fiches(self, rows: list[dict]) -> None:78 cache = self.load_cache()79 now = time.time()80 current = {r["url"] for r in rows}81 cache = {u: c for u, c in cache.items() if u in current}82 to_fetch = sorted(83 (u for u in current84 if u not in cache or cache[u].get("v", 1) < PARSE_V85 or now - cache[u].get("ts", 0) > REFRESH_AFTER),86 key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,87 cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]8889 def worker(url, session):90 return self._parse_fiche(session.get(url, timeout=20).text)9192 for url, det in self.fetch_many(to_fetch, worker, max_workers=4).items():93 if det is None: # fiche cassée : garder l'entrée existante94 det = {k: v for k, v in (cache.get(url) or {}).items() if k != "ts"}95 det.setdefault("desc", "")96 det.setdefault("venue", "")97 cache[url] = {"ts": now, **det}98 self.save_cache(cache)99 for r in rows:100 det = cache.get(r["url"]) or {}101 r["description"] = det.get("desc", "")102 if det.get("v", 1) < PARSE_V:103 # entrée héritée v1 : son venue vient du motif générique qui104 # attrapait le menu « Lieux » du site (100 % bruit, constaté105 # en live 2026-08-25) — on n'en publie que la description en106 # attendant le backfill v2 (plafond QA_FICHE_MAX/sync).107 det = {}108 r["venue"] = det.get("venue", "")109 r["address"] = det.get("address", "")110 # heure de LA représentation de cette occurrence datée (jamais111 # inventée : seulement si la fiche publie ce jour précis)112 r["start_time"] = (det.get("times") or {}).get(r["start_date"] or "")113114 def fetch(self) -> list[Event]:115 html = self.get(LIST_URL).text116 rows: list[dict] = []117 for m in _CARD_RE.finditer(html):118 path, debut, fin, arrond, cats, prix, inner = m.groups()119 t = _TITLE_RE.search(inner)120 title = clean_text((t.group(1) or t.group(2)) if t else "")121 if not title:122 # repli : dernier texte non vide de la carte123 texts = [clean_text(x) for x in re.findall(r">([^<>]{4,120})<", inner)]124 title = texts[-1] if texts else ""125 if not title:126 continue127 img = _IMG_RE.search(inner)128 slug = path.strip("/").split("/")[-1]129 # la liste répète une carte PAR occurrence datée d'une même130 # activité → l'identifiant inclut la date (séances distinctes)131 rows.append({132 "external_id": f"{slug}-{debut or 'nd'}",133 "url": BASE + path,134 "title": title,135 "start_date": debut or None,136 "end_date": fin or debut or None,137 "arrondissement": clean_text(arrond),138 "cats": [c.strip() for c in clean_text(cats).split(",") if c.strip()],139 "prix": clean_text(prix),140 "image": img.group(1) if img else "",141 })142 self._enrich_fiches(rows)143144 events: list[Event] = []145 seen: set[str] = set()146 for r in rows:147 if r["external_id"] in seen:148 continue149 seen.add(r["external_id"])150 img = r["image"]151 if img.startswith("/"):152 img = BASE + img153 events.append(Event(154 source=self.source_id,155 external_id=r["external_id"],156 url=r["url"],157 title=r["title"],158 description=r.get("description", ""),159 raw_categories=r["cats"] or [r["title"]],160 venue=r.get("venue", ""),161 address=r.get("address", ""),162 city="Québec",163 region="Capitale-Nationale",164 start_date=r["start_date"],165 start_time=r.get("start_time") or None,166 end_date=r["end_date"],167 price_label=r["prix"],168 image=img,169 organizer="Ville de Québec",170 ))171 return events172