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%
9.0 KB · 197 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/brossard.py : connecteur Ville de Brossard — calendrier des5#                          événements municipaux (brossard.ca, Montérégie)6#   Source    : API REST WordPress publique — type de contenu `city-event`7#               (~400 événements : bibliothèque, conférences, ateliers,8#               spectacles familiaux). Fraîcheur vérifiée 2026-08 (fiches9#               publiées en août 2026 pour des dates jusqu'en décembre 2026).10#   Extraction: 1) liste JSON /wp-json/wp/v2/city-event (paginée, champs11#               title/link/content/modified) ; 2) dates + heures par fiche :12#               bannière « Cet événement aura lieu le 6 décembre 2026, de13#               15 h 00 à 16 h 00 » (fiches ville) ou JSON-LD Event14#               (fiches biblio.brossard.ca, redirection suivie) — cache disque15#               incrémental data/brossard_cache.json, plafond16#               BROSSARD_DETAIL_MAX fiches/sync, re-visite 7 jours.17#   Accès     : API REST WordPress publique (aucune authentification), GET18#               throttlés, identification honnête. robots.txt : Allow.19#   Prix      : non publié par la source → jamais inventé.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import os25import re26import time2728from ..normalize import clean_text, parse_date_fr, parse_time29from ..schema import Event30from .base import BaseConnector3132API = "https://brossard.ca/wp-json/wp/v2/city-event"33PAGE_SIZE = 10034DETAIL_MAX = int(os.environ.get("BROSSARD_DETAIL_MAX", "80"))  # fiches/sync35REFRESH_AFTER = 7 * 864003637_LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',38                    re.S)39_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')40# « Cet événement aura lieu le 6 décembre 2026, de 15 h 00 à 16 h 00 »41# « … aura lieu le 6 décembre 2026, à 15 h 00 »42# « … aura lieu du 3 juillet 2026 au 5 juillet 2026 »43_BANNER_RE = re.compile(44    r"aura lieu\s+(?:le\s+(?P<jour>\d{1,2}(?:er)?\s+\S+\s+\d{4})"45    r"(?:,?\s*de\s+(?P<heure>\d{1,2}\s*h\s*(?:\d{2})?)"46    r"\s*(?:à|-|–)\s*(?P<fin>\d{1,2}\s*h\s*(?:\d{2})?)"47    r"|,?\s*à\s+(?P<heure2>\d{1,2}\s*h\s*(?:\d{2})?))?"48    r"|du\s+(?P<du>\d{1,2}(?:er)?\s+\S+\s+\d{4})\s+au\s+"49    r"(?P<au>\d{1,2}(?:er)?\s+\S+\s+\d{4}))", re.I)505152class BrossardConnector(BaseConnector):53    source_id = "brossard"54    request_delay = 0.85556    # -- liste REST ---------------------------------------------------------57    def _list(self) -> list[dict]:58        out, page = [], 159        while True:60            try:61                batch = self.get_json(API, params={62                    "per_page": PAGE_SIZE, "page": page, "orderby": "modified",63                    "order": "desc",64                    "_fields": "id,link,title,content,modified,type-event"})65            except Exception:66                if page > 1:      # « rest_post_invalid_page_number » = fin67                    break68                raise69            if not isinstance(batch, list) or not batch:70                break71            out += batch72            if len(batch) < PAGE_SIZE:73                break74            page += 175        return out7677    # -- taxonomie type-event : id → nom (1 requête, ~11 termes) ---------------78    def _type_event_map(self) -> dict[int, str]:79        """Vraies catégories WordPress (« Bibliothèque — adultes »,80        « Événements de la Ville »…) — remplace l'heuristique par titre."""81        try:82            terms = self.get_json(83                "https://brossard.ca/wp-json/wp/v2/type-event",84                params={"per_page": 100, "_fields": "id,name"})85            return {int(t["id"]): clean_text(t.get("name") or "")86                    for t in terms if t.get("id")}87        except Exception as exc:   # taxonomie indisponible ≠ source cassée88            print(f"[sorti-ka] brossard : taxonomie type-event ignorée : {exc}")89            return {}9091    # -- fiche : dates/heure/lieu (bannière ville OU JSON-LD biblio) ----------92    def _parse_fiche(self, html: str) -> dict:93        info: dict = {}94        for block in _LD_RE.findall(html):95            if '"Event"' not in block:96                continue97            try:98                ld = json.loads(block)99            except ValueError:100                continue101            if isinstance(ld, list):102                ld = ld[0] if ld else {}103            if ld.get("@type") != "Event":104                continue105            start, end = str(ld.get("startDate") or ""), str(ld.get("endDate") or "")106            info["start_date"] = start[:10] or None107            info["end_date"] = end[:10] or None108            info["time"] = parse_time(start)109            info["end_time"] = parse_time(end)110            loc = ld.get("location") or []111            loc = loc[0] if isinstance(loc, list) and loc else loc112            if isinstance(loc, dict):113                info["venue"] = clean_text(str(loc.get("name") or ""))114                addr = loc.get("address") or {}115                if isinstance(addr, dict):116                    info["address"] = clean_text(str(addr.get("streetAddress") or ""))117                    info["postal_code"] = clean_text(str(addr.get("postalCode") or ""))118            if ld.get("image"):119                info["image"] = str(ld["image"])120            return info121        m = _BANNER_RE.search(clean_text(html[:200000]))122        if m:123            if m.group("jour"):124                info["start_date"] = parse_date_fr(m.group("jour"))125                info["end_date"] = info["start_date"]126                info["time"] = parse_time(m.group("heure")127                                          or m.group("heure2") or "")128                info["end_time"] = parse_time(m.group("fin") or "")129            else:130                info["start_date"] = parse_date_fr(m.group("du"))131                info["end_date"] = parse_date_fr(m.group("au"))132        img = _OG_IMG_RE.search(html)133        if img:134            info.setdefault("image", img.group(1))135        return info136137    def _enrich(self, records: list[dict]) -> dict:138        """Cache url → {fetched_at, start_date, …}, incrémental et plafonné.139        Fiches jamais vues d'abord (les plus récemment modifiées en premier —140        la liste REST est déjà triée), puis re-visite roulante après 7 jours."""141        cache = self.load_cache()142        current = {r["link"] for r in records}143        cache = {u: c for u, c in cache.items() if u in current}144        now = time.time()145        candidates = [r["link"] for r in records146                      if r["link"] not in cache147                      or now - cache[r["link"]].get("fetched_at", 0) > REFRESH_AFTER]148        for url in candidates[:DETAIL_MAX]:149            try:150                info = self._parse_fiche(self.get(url).text)151            except Exception:      # fiche cassée/404 : on ne bloque pas la source152                print(f"[sorti-ka] brossard : fiche ignorée {url}")153                info = {}154            info["fetched_at"] = now155            cache[url] = info156        self.save_cache(cache)157        return cache158159    def fetch(self) -> list[Event]:160        records = [r for r in self._list()161                   if r.get("link") and (r.get("title") or {}).get("rendered")]162        cache = self._enrich(records)163        type_names = self._type_event_map()164        events: list[Event] = []165        for rec in records:166            info = cache.get(rec["link"]) or {}167            title = clean_text(rec["title"]["rendered"])168            # repli : beaucoup de titres portent la date « (27 novembre 2026) »169            start = info.get("start_date") or parse_date_fr(title)170            if not start:171                continue          # fiche pas encore visitée et titre sans date172            cats = [type_names[t] for t in rec.get("type-event") or []173                    if t in type_names]174            events.append(Event(175                source=self.source_id,176                external_id=str(rec["id"]),177                url=rec["link"],178                title=title,179                # description intégrale (la coupe à 600 caractères de la180                # vague 1 amputait 90 % des fiches — Phase 2)181                description=clean_text(182                    (rec.get("content") or {}).get("rendered") or ""),183                raw_categories=cats or [title],184                venue=info.get("venue", ""),185                address=info.get("address", ""),186                postal_code=info.get("postal_code", ""),187                city="Brossard",188                region="Montérégie",189                start_date=start,190                start_time=info.get("time"),191                end_date=info.get("end_date") or start,192                end_time=info.get("end_time"),193                organizer="Ville de Brossard",194                image=info.get("image", ""),195            ))196        return events197