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.0 KB · 172 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/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