# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/rouynnoranda.py : connecteur Ville de Rouyn-Noranda — événements # Source : https://www.rouyn-noranda.ca/evenements (OctoberCMS, rendu # serveur — même famille que sorel-tracy/joliette). # Extraction: page liste /evenements (pagination /evenements/N → 404 à la # fin) → cartes « event-thumb » : titre, date FR SANS année # (« 26 août » — la source n'affiche que des événements à # venir : l'année est résolue à la prochaine occurrence, # aujourd'hui ou plus tard, jamais au passé), catégorie, image, # url fiche. Enrichissement par fiche /evenement/ # (heures « de 19 h 30 à 22 h 30 », lieu, description) — # CACHE INCRÉMENTAL disque, plafond RN_FICHES par sync. # Accès : site municipal public, GET directs throttlés, identification # honnête ; robots.txt permissif. # Prix : non publié par la source → jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from datetime import date from ..normalize import _FR_MONTHS, clean_text, parse_time # type: ignore from ..schema import Event from .base import BaseConnector BASE = "https://www.rouyn-noranda.ca" LIST_URL = BASE + "/evenements" PAGES_MAX = int(os.environ.get("RN_PAGES", "8")) FICHES_MAX = int(os.environ.get("RN_FICHES", "40")) _CARD_RE = re.compile( r']*class="events-page__list-item event-thumb[^"]*"[^>]*>(.*?)', re.S) _TAG_RE = re.compile(r'taglist__tag(?:\s+taglist__tag--inverse)?">(.*?)', re.S) _TITLE_RE = re.compile(r'event-thumb__title">\s*(.*?)\s*', re.S) _IMG_RE = re.compile(r'data-src="([^"]+)"') _PARTIAL_RE = re.compile(r"(\d{1,2})(?:er)?\s+([a-zû]+)", re.I) def _resolve(txt: str, today: "date") -> tuple[str | None, str | None]: """« 26 août » / « du 26 août au 2 septembre » → plage ISO. La source n'affiche que l'à-venir : chaque jour/mois est daté de sa PROCHAINE occurrence (dérivation déterministe, pas d'invention d'année).""" from ..normalize import strip_accents dates: list[str] = [] for m in _PARTIAL_RE.finditer(strip_accents(txt).lower()): d, mo = int(m.group(1)), _FR_MONTHS.get(m.group(2)) if not mo or not (1 <= d <= 31): continue y = today.year if (mo, d) < (today.month, today.day): y += 1 try: dates.append(date(y, mo, d).isoformat()) except ValueError: continue if not dates: return None, None return min(dates), max(dates) class RouynNorandaConnector(BaseConnector): source_id = "rouynnoranda" request_delay = 0.8 def _parse_page(self, html: str, today: "date | None" = None) -> list[dict]: today = today or date.today() rows: list[dict] = [] for url, block in _CARD_RE.findall(html): t = _TITLE_RE.search(block) if not t: continue tags = [clean_text(_html.unescape(re.sub(r"<[^>]+>", " ", x))) for x in _TAG_RE.findall(block)] start = end = None category = "" for tag in tags: if start is None: start, end = _resolve(tag, today) if start: continue if not category and not any(c.isdigit() for c in tag): category = tag if not start: continue img = _IMG_RE.search(block) image = img.group(1) if img else "" if image.startswith("/"): image = BASE + image rows.append({ "url": url, "title": clean_text(_html.unescape( re.sub(r"<[^>]+>", " ", t.group(1)))), "start": start, "end": end or start, "category": category, "image": image, }) return rows def _fiche(self, url: str) -> dict: """Heures et lieu depuis l'entête de la fiche (blocs event-info).""" html = self.get(url).text out: dict = {} for m in re.finditer( r'__event-info-text">([^<]+)<|__event-info-link[^"]*"[^>]*>([^<]+)', html): txt = clean_text(_html.unescape(m.group(1) or m.group(2) or "")) if not txt: continue low = txt.lower() if "hours" not in out and re.search(r"\d{1,2}\s*h(\s|$|\d)", low): out["hours"] = txt elif "venue" not in out and m.group(2): out["venue"] = txt elif "venue" not in out and not _PARTIAL_RE.search(low): out["venue"] = txt return out def fetch(self) -> list[Event]: rows: list[dict] = [] for page in range(1, PAGES_MAX + 1): url = LIST_URL if page == 1 else f"{LIST_URL}/{page}" try: batch = self._parse_page(self.get(url).text) except Exception: if page > 1: # fin de pagination (404) break raise if not batch: break rows += batch cache = self.load_cache() fetched = 0 for r in rows: slug = r["url"].rstrip("/").rsplit("/", 1)[-1] r["slug"] = slug if slug not in cache and fetched < FICHES_MAX: try: cache[slug] = self._fiche(r["url"]) fetched += 1 except Exception as exc: # fiche cassée ≠ source cassée print(f"[sorti-ka] rouynnoranda : fiche {slug} " f"ignorée : {exc}") if fetched: self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for r in rows: ext = f"{r['slug']}-{r['start']}" if ext in seen: continue seen.add(ext) extra = cache.get(r["slug"]) or {} hours = extra.get("hours", "") start_time = parse_time(hours) end_time = None m = re.search(r"\bà\s+(\d{1,2}\s*h(?:\s*\d{2})?)", hours) if m: end_time = parse_time(m.group(1)) events.append(Event( source=self.source_id, external_id=ext, url=r["url"], title=r["title"], raw_categories=[r["category"]] if r["category"] else [r["title"]], venue=extra.get("venue", ""), city="Rouyn-Noranda", region="Abitibi-Témiscamingue", start_date=r["start"], start_time=start_time, end_date=r["end"], end_time=end_time, organizer="Ville de Rouyn-Noranda", image=r["image"], )) return events