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.7 KB · 168 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/lanaudiere.py : Tourisme Lanaudière — événements (ATR, Phase 3)5#   Source    : https://lanaudiere.ca/fr/evenements-lanaudiere — CMS Umbraco,6#               médias Tourinsoft (mto.media.tourinsoft.eu — même gisement que7#               le SIT Québec, recoupement géré par dedup_key). La liste rendue8#               serveur ne montre que ~10 cartes (le reste charge en JS) → on9#               passe par le SITEMAP officiel (robots.txt), qui publie TOUTES10#               les fiches /fr/evenements-lanaudiere/<slug>/ (~85 URLs,11#               revérifié 2026-08-25).12#   Extraction: sitemap.xml (1 GET) → fiches détail incrémentales (cache13#               data/lanaudiere_cache.json, LAN_FICHE_MAX/sync, re-visite 7 j,14#               version v=2) : en-tête structuré member-title-1-1 (titre,15#               plage de dates FR « Du 17 au 26 juillet 2026 », municipalité)16#               + og:image. ENRICHI (2026-08-25) : description longue17#               (expandable-intro, fallback og:description), adresse civique +18#               code postal (member-coords-1-1__address), organisateur19#               (member-coords-1-1__name), site web (member-website-1-1) et20#               audience (« Clientèle admise »). Pas d'heure ni tarif21#               structurés → jamais inventés.22#   Accès     : robots.txt : « Disallow: /*? » (aucune URL à paramètres23#               requêtée) + « Crawl-delay: 2 » → GET séquentiels 2,1 s.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import time3031from ..normalize import clean_text, parse_date_range_fr32from ..schema import Event33from .base import BaseConnector3435BASE = "https://lanaudiere.ca"36SITEMAP_URL = BASE + "/sitemap.xml"37FICHE_MAX = int(os.environ.get("LAN_FICHE_MAX", "10"))38REFRESH_AFTER = 7 * 8640039CACHE_V = 2                 # v2 : + adresse/CP, organisateur, site web,40#                             audience, description longue (2026-08-25)4142_SITEMAP_RE = re.compile(43    r"<loc>\s*(https://lanaudiere\.ca/fr/evenements-lanaudiere/"44    r"[a-z0-9-]+/?)\s*</loc>")45_TITLE_RE = re.compile(r'member-title-1-1__title">\s*([^<]+?)\s*<')46_DATE_RE = re.compile(r'member-title-1-1__subtitle">\s*([^<]+?)\s*<')47_CITY_RE = re.compile(r'member-title-1-1__location"><span>([^<]+)<')48_OG_IMG_RE = re.compile(r'property="og:image"\s+content="([^"]*)"')49_OG_DESC_RE = re.compile(r'property="og:description"\s+content="([^"]*)"')50# description longue de la fiche (bloc « expandable-intro »)51_INTRO_RE = re.compile(52    r'expandable-intro-1-1__text[^>]*>(.*?)</div>', re.S)53# coordonnées du membre : nom (organisateur), adresse « 1655, boul.54# Base-de-Roc<br />Joliette, QC J6E 0L1 »55_COORD_NAME_RE = re.compile(56    r'member-coords-1-1__name">\s*(.*?)\s*</div>', re.S)57_COORD_ADDR_RE = re.compile(58    r'member-coords-1-1__address">\s*(.*?)\s*</div>', re.S)59_ADDR_SPLIT_RE = re.compile(60    r"^(?P<addr>.*?)<br\s*/?>\s*(?P<city>[^,<]+),\s*QC"61    r"(?:\s*(?P<pc>[A-Z]\d[A-Z]\s?\d[A-Z]\d))?", re.S)62_WEBSITE_RE = re.compile(r'member-website-1-1__btn"\s+href="([^"]+)"')63# « Clientèle admise » : liste de publics (Adultes, Adolescents, Familles…)64_AUDIENCE_RE = re.compile(65    r"Client\S*le admise.*?<ul>(.*?)</ul>", re.S)66_LI_RE = re.compile(r"<li[^>]*>\s*([^<]+?)\s*</li>")676869class LanaudiereConnector(BaseConnector):70    source_id = "lanaudiere"71    request_delay = 2.1                       # Crawl-delay: 2 (robots.txt)7273    def _parse_fiche(self, url: str, html: str) -> dict | None:74        title = _TITLE_RE.search(html)75        if not title:76            return None77        date_raw = _DATE_RE.search(html)78        start, end = parse_date_range_fr(79            date_raw.group(1) if date_raw else "")80        city = _CITY_RE.search(html)81        img = _OG_IMG_RE.search(html)82        intro = _INTRO_RE.search(html)83        og_desc = _OG_DESC_RE.search(html)84        desc = clean_text(intro.group(1))[:2000] if intro else (85            clean_text(og_desc.group(1)) if og_desc else "")86        address, postal = "", ""87        coord = _COORD_ADDR_RE.search(html)88        if coord:89            m = _ADDR_SPLIT_RE.match(coord.group(1).strip())90            if m:91                address = clean_text(m.group("addr"))92                postal = (m.group("pc") or "").strip()93            else:                             # adresse sur une seule ligne94                address = clean_text(coord.group(1))95        name = _COORD_NAME_RE.search(html)96        website = _WEBSITE_RE.search(html)97        aud = _AUDIENCE_RE.search(html)98        audience = ", ".join(clean_text(x) for x in _LI_RE.findall(99            aud.group(1))) if aud else ""100        return {101            "external_id": url.rstrip("/").split("/")[-1],102            "url": url,103            "title": clean_text(title.group(1)),104            "description": desc,105            "city": clean_text(city.group(1)) if city else "",106            "address": address, "postal_code": postal,107            "organizer": clean_text(name.group(1)) if name else "",108            "website": website.group(1).strip() if website else "",109            "audience": audience,110            "start_date": start, "end_date": end,111            "image": clean_text(img.group(1)) if img else "",112        }113114    def fetch(self) -> list[Event]:115        xml = self.get(SITEMAP_URL).text116        urls = sorted({u.rstrip("/") + "/" for u in _SITEMAP_RE.findall(xml)117                       if not u.rstrip("/").endswith("evenements-lanaudiere")})118        cache = self.load_cache()119        now = time.time()120        current = set(urls)121        cache = {u: c for u, c in cache.items() if u in current}122        # nouvelles fiches d'abord, puis celles parsées avant v2 (sans123        # adresse/site/audience), puis re-visite roulante124        to_fetch = sorted(125            (u for u in urls126             if u not in cache or cache[u].get("v", 1) < CACHE_V127             or now - cache[u].get("ts", 0) > REFRESH_AFTER),128            key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0,129                           cache.get(u, {}).get("ts", 0)))[:FICHE_MAX]130        for url in to_fetch:                  # séquentiel : Crawl-delay respecté131            try:132                row = self._parse_fiche(url, self.get(url).text)133            except Exception:134                row = None135            if row is None:136                print(f"[sorti-ka] lanaudiere : fiche ignorée {url}")137                cache[url] = {"ts": now, "row": None, "v": CACHE_V}138                continue139            cache[url] = {"ts": now, "row": row, "v": CACHE_V}140        self.save_cache(cache)141142        events: list[Event] = []143        seen: set[str] = set()144        for entry in cache.values():145            r = entry.get("row")146            if not r or r["external_id"] in seen:147                continue148            seen.add(r["external_id"])149            events.append(Event(150                source=self.source_id,151                external_id=r["external_id"],152                url=r["url"],153                title=r["title"],154                description=r.get("description", ""),155                raw_categories=[r["title"]],156                audience=r.get("audience", ""),157                address=r.get("address", ""),158                city=r["city"],159                postal_code=r.get("postal_code", ""),160                tourist_region="Lanaudière",161                start_date=r["start_date"],162                end_date=r["end_date"],163                organizer=r.get("organizer", ""),164                website=r.get("website", ""),165                image=r["image"],166            ))167        return events168