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.0 KB · 161 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/monteregie.py : Tourisme Montérégie — événements (ATR, Phase 4)5#   Source    : https://www.tourisme-monteregie.qc.ca — WordPress dont l'API6#               REST publique expose le CPT `cpt_event` (78 fiches publiées,7#               X-WP-TotalPages=1, revérifié 2026-08-25). Gisement Tourinsoft8#               (acf.syndic_object_id EVENMTOV… — même famille que le SIT9#               Québec ; recoupement géré par dedup_key, mais l'ATR ajoute10#               GPS, image, site web et description que le flux SITQ ne11#               publie pas).12#   Extraction: API JSON /wp-json/wp/v2/cpt_event (paginée per_page=100) →13#               titre, description (content, fallback excerpt), plage de14#               dates FR (« Du 18 septembre au 20 septembre 2026 »), lat/long,15#               site web, image. ENRICHI (2026-08-25) : taxonomie16#               event-categorie (1 GET/sync) → catégories brutes (« Marchés17#               de Noël », « Festival et autres événements »). Fiches détail18#               incrémentales (cache data/monteregie_cache.json,19#               MTG_FICHE_MAX/sync, re-visite 7 j) : bloc JSON-LD Event →20#               adresse civique, ville, code postal. Ni heure ni tarif21#               structurés publiés (vérifié sur fiches) → jamais inventés.22#   Accès     : API REST WordPress publique (aucune auth), robots.txt sans23#               interdiction sur /wp-json/ — GET throttlés, UA honnête.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import json28import os29import re30import time3132from ..normalize import clean_text, parse_date_range_fr33from ..schema import Event34from .base import BaseConnector3536BASE = "https://www.tourisme-monteregie.qc.ca"37API = BASE + "/wp-json/wp/v2/cpt_event"38TAX_API = BASE + "/wp-json/wp/v2/event-categorie"39PER_PAGE = 10040PAGE_MAX = 5                                  # garde-fou (78 fiches en 2026-08)41FICHE_MAX = int(os.environ.get("MTG_FICHE_MAX", "15"))42REFRESH_AFTER = 7 * 864004344_LDJSON_RE = re.compile(45    r'<script type="application/ld\+json"[^>]*>(.*?)</script>', re.S)464748class MonteregieConnector(BaseConnector):49    source_id = "monteregie"50    request_delay = 0.85152    def _list_events(self) -> list[dict]:53        rows: list[dict] = []54        for page in range(1, PAGE_MAX + 1):55            batch = self.get_json(API, params={56                "per_page": PER_PAGE, "page": page})57            if not isinstance(batch, list) or not batch:58                break59            rows.extend(batch)60            if len(batch) < PER_PAGE:61                break62        return rows6364    def _categories(self) -> dict[int, str]:65        """Taxonomie event-categorie : {id: libellé} (1 GET, jamais bloquant)."""66        try:67            terms = self.get_json(TAX_API, params={"per_page": 100})68            return {t["id"]: clean_text(t.get("name", ""))69                    for t in terms if isinstance(t, dict) and t.get("id")}70        except Exception:71            return {}7273    def _parse_fiche(self, html: str) -> dict:74        """Adresse/ville/salle du bloc JSON-LD Event de la fiche (thème ATR)."""75        for m in _LDJSON_RE.findall(html):76            try:77                data = json.loads(m)78            except ValueError:79                continue80            items = data if isinstance(data, list) else [data]81            for item in items:82                if not isinstance(item, dict) or item.get("@type") != "Event":83                    continue84                loc = item.get("location") or {}85                addr = loc.get("address") or {}86                venue = clean_text(loc.get("name", ""))87                # le thème recopie le nom de l'événement comme nom de lieu —88                # on ne garde le lieu que s'il diffère du titre (jamais inventé)89                if venue and venue.lower() == clean_text(90                        item.get("name", "")).lower():91                    venue = ""92                return {93                    "venue": venue,94                    "address": clean_text(addr.get("streetAddress", "")),95                    "city": clean_text(addr.get("addressLocality", "")),96                    "postal_code": clean_text(addr.get("postalCode", "")),97                }98        return {}99100    def fetch(self) -> list[Event]:101        rows = self._list_events()102        categories = self._categories()103        cache = self.load_cache()104        now = time.time()105        current = {str(r.get("id")) for r in rows}106        cache = {k: v for k, v in cache.items() if k in current}107        to_fetch = sorted(108            (r for r in rows109             if str(r.get("id")) not in cache110             or now - cache[str(r.get("id"))].get("ts", 0) > REFRESH_AFTER),111            key=lambda r: cache.get(str(r.get("id")), {}).get("ts", 0))[:FICHE_MAX]112        for row in to_fetch:113            eid, link = str(row.get("id")), row.get("link", "")114            detail: dict = {}115            try:116                if link:117                    detail = self._parse_fiche(self.get(link).text)118            except Exception:119                detail = {}120            cache[eid] = {"ts": now, "detail": detail}121        self.save_cache(cache)122123        events: list[Event] = []124        for row in rows:125            eid = str(row.get("id") or "")126            acf = row.get("acf") or {}127            if not eid or not isinstance(acf, dict):128                continue129            start, end = parse_date_range_fr(acf.get("date", ""))130            images = acf.get("images") or []131            image = ""132            if isinstance(images, list) and images and isinstance(images[0], dict):133                image = images[0].get("url", "")134            detail = cache.get(eid, {}).get("detail") or {}135            title = clean_text((row.get("title") or {}).get("rendered", ""))136            description = clean_text(137                (row.get("content") or {}).get("rendered", "")) or clean_text(138                (row.get("excerpt") or {}).get("rendered", ""))139            raw_cats = [categories[t] for t in (row.get("event-categorie") or [])140                        if t in categories]141            events.append(Event(142                source=self.source_id,143                external_id=eid,144                url=row.get("link", ""),145                title=title,146                description=description,147                raw_categories=raw_cats + [title],148                venue=detail.get("venue", ""),149                address=detail.get("address", ""),150                city=detail.get("city", ""),151                postal_code=detail.get("postal_code", ""),152                tourist_region="Montérégie",153                lat=acf.get("lat") or None,154                lng=acf.get("long") or None,155                start_date=start,156                end_date=end,157                website=acf.get("website", "") or "",158                image=image,159            ))160        return events161