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%
15.4 KB · 313 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/lavitrine.py : connecteur La Vitrine culturelle (lavitrine.com)5#   Source    : billetterie/calendrier culturel — spectacles, expositions,6#               concerts (Grand Montréal + Québec + tournées).7#   Extraction: sitemap.xml public (~2 000 fiches /fr/evenement/ + 1858#               /fr/exposition/, lastmod par URL) puis pages fiche rendues9#               serveur (h1, bloc .event_calendar : jour/mois/année, HEURE10#               (« 15:00 HNE » dans la slide), salle, ville ; og:image ;11#               description = event_tabs_description-short + about-description).12#               GET directs — pas d'anti-bot.13#               INCRÉMENTAL avec cache disque (data/lavitrine_cache.json) :14#               nouvelles fiches d'abord, re-visite roulante après 7 jours,15#               plafonné à LV_MAX_FETCH fiches/sync.16#   Accès     : robots.txt « User-Agent: * / Allow: / » + Content-Signal17#               « search=yes, use=reference » → usage index + lien vers la18#               fiche originale, conforme aux signaux publiés (CLAUDE.md §15).19#   Prix/GPS  : les pages FICHE ne publient ni tarif, ni JSON-LD, ni adresse.20#               MAIS le sitemap contient aussi ~1 000 pages « représentation »21#               (/fr/evenement/<slug>/<ficheId>/<dateId>) qui publient un22#               JSON-LD Event complet (vérifié live 2026-08-25, 14/14 pages :23#               Place name + PostalAddress + GeoCoordinates 100 %, offers.price24#               ~80 %) → parseur v3 : adresse civique, code postal, lat/lng et25#               prix billetterie attachés à LA représentation correspondante26#               (identifier = dateId de la slide), sans AUCUN GET de plus (ces27#               pages étaient déjà téléchargées et ignorées). Les autres28#               représentations restent au géocodage aval salle+ville29#               (sortika/venues.py + geocode.py). isAccessibleForFree vaut30#               false par défaut chez la source → jamais utilisé seul ;31#               is_free=False seulement si un prix > 0 est publié.32# -----------------------------------------------------------------------------33from __future__ import annotations3435import re36import time3738from ..normalize import clean_text39from ..schema import Event40from .base import BaseConnector4142SITEMAP = "https://www.lavitrine.com/sitemap.xml"43LV_MAX_FETCH = int(__import__("os").environ.get("LV_MAX_FETCH", "400"))  # fiches/sync (GET directs polis)44REFRESH_AFTER = 7 * 86400     # re-visite roulante d'une fiche (secondes)45PARSE_V = 3   # v2 : heure + description ; v3 : JSON-LD des pages représentation46              # (adresse/GPS/prix) → backfill prioritaire des entrées < v34748_LOC_RE = re.compile(r"<loc>([^<]+)</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?")49_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.S)50_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)51_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')5253_MONTHS = {"jan": 1, "fév": 2, "fev": 2, "mar": 3, "avr": 4, "mai": 5,54           "juin": 6, "juil": 7, "aoû": 8, "aou": 8, "août": 8, "sep": 9,55           "oct": 10, "nov": 11, "déc": 12, "dec": 12}5657# une « slide » du calendrier : jour / mois / année, puis salle / ville58_DATE_RE = re.compile(59    r'calender-date-text-large">(\d{1,2})</div>'60    r'<div class="event_calendar_slide-calender-date-text-small">([^<]+)</div>'61    r'<div class="event_calendar_slide-calender-date-text-small">(\d{4})</div>')62_PLACE_RE = re.compile(r'class="text-style-2lines">([^<]*)</div><div>([^<]*)</div>')63# l'heure de la représentation, dans la même slide, entre la date et la salle :64# <div class="text-size-small text-height-1-2">15:00 HNE</div>65_TIME_RE = re.compile(r'class="text-size-small[^"]*">\s*(\d{1,2}[:h]\d{2})')66# description de la fiche : accroche courte + début du texte long (onglet À propos)67_DESC_SHORT_RE = re.compile(r'event_tabs_description-short">(.*?)</div>', re.S)68_DESC_LONG_RE = re.compile(r'id="about-description">(.*?)</div>', re.S)697071_SLIDE_A_RE = re.compile(72    r'<a[^>]*href="([^"]*)"[^>]*class="event_calendar_slide-content(.*?)</a>', re.S)7374_LD_SCRIPT_RE = re.compile(75    r'<script type="application/ld\+json">(.*?)</script>', re.S)767778def _ld_representations(html: str) -> dict[str, dict]:79    """JSON-LD Event des pages « représentation » du sitemap80    (…/<ficheId>/<dateId>) : {dateId → salle, adresse civique, code postal,81    lat/lng, prix billetterie}. `identifier` = le dateId de la slide82    correspondante du carrousel. Les pages fiche n'en publient pas (vérifié83    live 2026-08-25) → dict vide, aucun impact."""84    import json85    out: dict[str, dict] = {}86    for m in _LD_SCRIPT_RE.finditer(html):87        raw = m.group(1)88        try:89            data = json.loads(raw)90        except Exception:91            try:    # caractères de contrôle bruts dans les descriptions92                data = json.loads(re.sub(r"[\x00-\x1f]", " ", raw))93            except Exception:94                continue95        for d in (data if isinstance(data, list) else [data]):96            if not isinstance(d, dict) or d.get("@type") != "Event":97                continue98            ident = str(d.get("identifier") or "")99            if not ident:100                continue101            loc = d.get("location") or {}102            adr = loc.get("address") or {}103            geo = loc.get("geo") or {}104            off = d.get("offers") or {}105            if isinstance(off, list):106                off = off[0] if off else {}107            try:108                price = float(off.get("price"))109            except (TypeError, ValueError):110                price = None111            out[ident] = {112                "venue": clean_text(loc.get("name") or ""),113                "address": clean_text(adr.get("streetAddress") or ""),114                "city": clean_text(adr.get("addressLocality") or ""),115                "postal_code": clean_text(adr.get("postalCode") or ""),116                "lat": geo.get("latitude"), "lng": geo.get("longitude"),117                # prix publié par la billetterie elle-même (souvent null tant118                # que la vente n'est pas ouverte : jamais inventé)119                "price": price if price and price > 0 else None,120            }121    return out122123124# date + heure LOCALES de la représentation courante, publiées dans le fil125# d'Ariane JSON-LD (« 17 sept 2026 | 20:00 HAE ») — le startDate de l'Event126# est en UTC et peut donc changer de jour, on ne l'utilise JAMAIS.127_CRUMB_DATE_RE = re.compile(128    r'"name":\s*"(\d{1,2})\s+([a-zA-Zûéà]+)\.?\s+(\d{4})'129    r'(?:\s*\|\s*(\d{1,2}:\d{2}))?[^"]*"')130131132def _month_num(month_txt: str) -> int | None:133    t = clean_text(month_txt).lower()134    return _MONTHS.get(t[:4].rstrip(".")) or _MONTHS.get(t[:3])135136137def _slides(html: str) -> list[tuple]:138    """Slides du calendrier : (id de fiche de la slide, id de représentation139    « dateId », url canonique, jour, mois, année, salle, ville, heure).140141    Le carrousel d'une fiche de tournée liste TOUTES les dates de la tournée,142    chacune avec l'id de SA fiche (/fr/evenement/<slug>/<ficheId>/<dateId>).143    On garde tout mais on identifie chaque représentation par l'id de sa144    slide : la même date vue depuis plusieurs fiches → même identifiant →145    aucun doublon à l'ingestion, aucune date perdue. Le dateId (dernier146    segment) sert de clé de jointure vers le JSON-LD Event des pages147    représentation (identifier = dateId — v3, jamais utilisé dans148    external_id : zéro churn).149    """150    out = []151    for m in _SLIDE_A_RE.finditer(html):152        href, block = m.group(1), m.group(2)153        parts = href.strip("/").split("/")154        # …/evenement/<slug>/<ficheId>/<dateId>155        slide_id = parts[-2] if len(parts) >= 2 and parts[-2].isdigit() else ""156        date_id = parts[-1] if slide_id and parts[-1].isdigit() else ""157        canon = "https://www.lavitrine.com/" + "/".join(parts[:-1]) if slide_id else ""158        d = _DATE_RE.search(block)159        if not d:160            continue161        p = _PLACE_RE.search(block)162        t = _TIME_RE.search(block)163        out.append((slide_id, date_id, canon, d.group(1), d.group(2), d.group(3),164                    p.group(1) if p else "", p.group(2) if p else "",165                    t.group(1).replace("h", ":") if t else None))166    return out167168169class LaVitrineConnector(BaseConnector):170    source_id = "lavitrine"171    request_delay = 0.8172173    def _fiches(self) -> list[str]:174        xml = self.get(SITEMAP).text175        urls = []176        for block in _URL_BLOCK_RE.findall(xml):177            m = _LOC_RE.search(block)178            if not m:179                continue180            url = m.group(1)181            if "/fr/evenement/" in url or "/fr/exposition/" in url:182                urls.append(url)183        return urls184185    def _parse_fiche(self, url: str, html: str) -> list[dict]:186        h1 = _H1_RE.search(html)187        title = clean_text(h1.group(1)) if h1 else ""188        if not title:189            return []190        img = _OG_IMG_RE.search(html)191        image = img.group(1) if img else ""192        # description : accroche courte de la fiche + début du texte long193        # (déjà dans le HTML téléchargé — Phase 2, gisement n° 1 de l'audit)194        d_short = _DESC_SHORT_RE.search(html)195        d_long = _DESC_LONG_RE.search(html)196        description = " ".join(p for p in (197            clean_text(d_short.group(1)) if d_short else "",198            clean_text(d_long.group(1)) if d_long else "") if p)[:2000]199        ext = url.rstrip("/").rsplit("/", 1)[-1]     # id numérique stable200        parts = url.rstrip("/").split("/")201        # dernier segment non numérique = slug lisible (les URLs représentation202        # …/<slug>/<ficheId>/<dateId> donnaient « 22040 » en catégorie brute)203        slug = next((p for p in reversed(parts) if not p.isdigit()), "")204        rows = []205        for slide_id, date_id, canon, day, month_txt, year, venue, city, hhmm \206                in _slides(html)[:60]:207            mo = _month_num(month_txt)208            if not mo:209                continue210            iso = f"{int(year):04d}-{mo:02d}-{int(day):02d}"211            rows.append({212                # id de la slide (fiche propre à cette représentation) : la213                # même date vue depuis plusieurs fiches → même uid, zéro doublon214                "external_id": f"{slide_id or ext}-{iso}",215                "url": canon or url, "title": title, "image": image,216                "description": description,217                "raw_categories": [title, slug.replace("-", " ")],218                "venue": clean_text(venue or ""), "city": clean_text(city or ""),219                "start_date": iso, "end_date": iso,220                "start_time": hhmm,        # heure de la slide (« 15:00 HNE »)221            })222        # page « représentation » du sitemap (…/<ficheId>/<dateId>) : son223        # JSON-LD Event décrit la représentation COURANTE — jamais listée224        # dans son propre carrousel (vérifié live 2026-08-25) → on émet son225        # occurrence à part : adresse/GPS/prix du JSON-LD, date+heure LOCALES226        # du fil d'Ariane. À l'émission, cette version enrichie remplace la227        # slide nue vue depuis les autres fiches (même external_id).228        info = (_ld_representations(html).get(parts[-1])229                if len(parts) >= 2 and parts[-1].isdigit()230                and parts[-2].isdigit() else None)231        crumb = _CRUMB_DATE_RE.search(html) if info else None232        mo = _month_num(crumb.group(2)) if crumb else None233        if info and crumb and mo:234            iso = f"{int(crumb.group(3)):04d}-{mo:02d}-{int(crumb.group(1)):02d}"235            price = info.get("price")236            rows.append({237                "external_id": f"{parts[-2]}-{iso}",   # même schéma que les slides238                "url": "/".join(parts[:-1]),           # fiche canonique239                "title": title, "image": image,240                "description": description,241                "raw_categories": [title, slug.replace("-", " ")],242                "venue": info.get("venue", ""), "city": info.get("city", ""),243                "address": info.get("address", ""),244                "postal_code": info.get("postal_code", ""),245                "lat": info.get("lat"), "lng": info.get("lng"),246                "start_date": iso, "end_date": iso,247                "start_time": crumb.group(4),248                "price_min": price,249                "price_label": (f"{price:.2f} $ (billetterie lavitrine.com)"250                                if price else ""),251            })252        return rows253254    def fetch(self) -> list[Event]:255        cache = self.load_cache()256        urls = self._fiches()257        current = set(urls)258        cache = {u: c for u, c in cache.items() if u in current}259260        now = time.time()261        # nouvelles fiches d'abord, puis celles parsées avant PARSE_V262        # (v2 sans adresse/GPS/prix — montée progressive), puis les263        # plus anciennes visites (roulant)264        candidates = sorted(265            (u for u in urls266             if u not in cache or cache[u].get("v", 1) < PARSE_V267             or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),268            key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,269                           # pages représentation d'abord : seules à livrer270                           # adresse/GPS/prix (JSON-LD v3)271                           0 if re.search(r"/\d+/\d+/?$", u) else 1,272                           cache.get(u, {}).get("fetched_at", 0)))[:LV_MAX_FETCH]273274        def worker(url, session):275            return self._parse_fiche(url, session.get(url, timeout=20).text)276277        for url, rows in self.fetch_many(candidates, worker, max_workers=4).items():278            if rows is None:      # fiche cassée : on ne bloque pas la source279                print(f"[sorti-ka] lavitrine : fiche ignorée {url}")280                continue281            cache[url] = {"fetched_at": now, "rows": rows, "v": PARSE_V}282        self.save_cache(cache)283284        # même slide vue depuis plusieurs pages (fiche de tournée + page285        # représentation) : la version enrichie JSON-LD (adresse) l'emporte286        best: dict[str, dict] = {}287        for entry in cache.values():288            for row in entry.get("rows", []):289                cur = best.get(row["external_id"])290                if cur is None or (not cur.get("address") and row.get("address")):291                    best[row["external_id"]] = row292        events: list[Event] = []293        for row in best.values():294            events.append(Event(295                source=self.source_id,296                external_id=row["external_id"],297                url=row["url"], title=row["title"],298                description=row.get("description", ""),299                raw_categories=row.get("raw_categories") or [],300                venue=row.get("venue", ""), city=row.get("city", ""),301                address=row.get("address", ""),302                postal_code=row.get("postal_code", ""),303                lat=row.get("lat"), lng=row.get("lng"),304                start_date=row.get("start_date"),305                start_time=row.get("start_time"),306                end_date=row.get("end_date"),307                price_min=row.get("price_min"),308                price_label=row.get("price_label", ""),309                is_free=False if row.get("price_min") else None,310                image=row.get("image", ""),311            ))312        return events313