Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/rouynnoranda.py : connecteur Ville de Rouyn-Noranda — événements5# Source : https://www.rouyn-noranda.ca/evenements (OctoberCMS, rendu6# serveur — même famille que sorel-tracy/joliette).7# Extraction: page liste /evenements (pagination /evenements/N → 404 à la8# fin) → cartes « event-thumb » : titre, date FR SANS année9# (« 26 août » — la source n'affiche que des événements à10# venir : l'année est résolue à la prochaine occurrence,11# aujourd'hui ou plus tard, jamais au passé), catégorie, image,12# url fiche. Enrichissement par fiche /evenement/<slug>13# (heures « de 19 h 30 à 22 h 30 », lieu, description) —14# CACHE INCRÉMENTAL disque, plafond RN_FICHES par sync.15# Accès : site municipal public, GET directs throttlés, identification16# honnête ; robots.txt permissif.17# Prix : non publié par la source → jamais inventé.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import os23import re24from datetime import date2526from ..normalize import _FR_MONTHS, clean_text, parse_time # type: ignore27from ..schema import Event28from .base import BaseConnector2930BASE = "https://www.rouyn-noranda.ca"31LIST_URL = BASE + "/evenements"32PAGES_MAX = int(os.environ.get("RN_PAGES", "8"))33FICHES_MAX = int(os.environ.get("RN_FICHES", "40"))3435_CARD_RE = re.compile(36 r'<a href="(https://www\.rouyn-noranda\.ca/evenement/[^"]+)"[^>]*class="events-page__list-item event-thumb[^"]*"[^>]*>(.*?)</a>',37 re.S)38_TAG_RE = re.compile(r'taglist__tag(?:\s+taglist__tag--inverse)?">(.*?)</li>',39 re.S)40_TITLE_RE = re.compile(r'event-thumb__title">\s*(.*?)\s*</span>', re.S)41_IMG_RE = re.compile(r'data-src="([^"]+)"')42_PARTIAL_RE = re.compile(r"(\d{1,2})(?:er)?\s+([a-zû]+)", re.I)434445def _resolve(txt: str, today: "date") -> tuple[str | None, str | None]:46 """« 26 août » / « du 26 août au 2 septembre » → plage ISO. La source47 n'affiche que l'à-venir : chaque jour/mois est daté de sa PROCHAINE48 occurrence (dérivation déterministe, pas d'invention d'année)."""49 from ..normalize import strip_accents50 dates: list[str] = []51 for m in _PARTIAL_RE.finditer(strip_accents(txt).lower()):52 d, mo = int(m.group(1)), _FR_MONTHS.get(m.group(2))53 if not mo or not (1 <= d <= 31):54 continue55 y = today.year56 if (mo, d) < (today.month, today.day):57 y += 158 try:59 dates.append(date(y, mo, d).isoformat())60 except ValueError:61 continue62 if not dates:63 return None, None64 return min(dates), max(dates)656667class RouynNorandaConnector(BaseConnector):68 source_id = "rouynnoranda"69 request_delay = 0.87071 def _parse_page(self, html: str, today: "date | None" = None) -> list[dict]:72 today = today or date.today()73 rows: list[dict] = []74 for url, block in _CARD_RE.findall(html):75 t = _TITLE_RE.search(block)76 if not t:77 continue78 tags = [clean_text(_html.unescape(re.sub(r"<[^>]+>", " ", x)))79 for x in _TAG_RE.findall(block)]80 start = end = None81 category = ""82 for tag in tags:83 if start is None:84 start, end = _resolve(tag, today)85 if start:86 continue87 if not category and not any(c.isdigit() for c in tag):88 category = tag89 if not start:90 continue91 img = _IMG_RE.search(block)92 image = img.group(1) if img else ""93 if image.startswith("/"):94 image = BASE + image95 rows.append({96 "url": url,97 "title": clean_text(_html.unescape(98 re.sub(r"<[^>]+>", " ", t.group(1)))),99 "start": start,100 "end": end or start,101 "category": category,102 "image": image,103 })104 return rows105106 def _fiche(self, url: str) -> dict:107 """Heures et lieu depuis l'entête de la fiche (blocs event-info)."""108 html = self.get(url).text109 out: dict = {}110 for m in re.finditer(111 r'__event-info-text">([^<]+)<|__event-info-link[^"]*"[^>]*>([^<]+)</a>',112 html):113 txt = clean_text(_html.unescape(m.group(1) or m.group(2) or ""))114 if not txt:115 continue116 low = txt.lower()117 if "hours" not in out and re.search(r"\d{1,2}\s*h(\s|$|\d)", low):118 out["hours"] = txt119 elif "venue" not in out and m.group(2):120 out["venue"] = txt121 elif "venue" not in out and not _PARTIAL_RE.search(low):122 out["venue"] = txt123 return out124125 def fetch(self) -> list[Event]:126 rows: list[dict] = []127 for page in range(1, PAGES_MAX + 1):128 url = LIST_URL if page == 1 else f"{LIST_URL}/{page}"129 try:130 batch = self._parse_page(self.get(url).text)131 except Exception:132 if page > 1: # fin de pagination (404)133 break134 raise135 if not batch:136 break137 rows += batch138 cache = self.load_cache()139 fetched = 0140 for r in rows:141 slug = r["url"].rstrip("/").rsplit("/", 1)[-1]142 r["slug"] = slug143 if slug not in cache and fetched < FICHES_MAX:144 try:145 cache[slug] = self._fiche(r["url"])146 fetched += 1147 except Exception as exc: # fiche cassée ≠ source cassée148 print(f"[sorti-ka] rouynnoranda : fiche {slug} "149 f"ignorée : {exc}")150 if fetched:151 self.save_cache(cache)152 events: list[Event] = []153 seen: set[str] = set()154 for r in rows:155 ext = f"{r['slug']}-{r['start']}"156 if ext in seen:157 continue158 seen.add(ext)159 extra = cache.get(r["slug"]) or {}160 hours = extra.get("hours", "")161 start_time = parse_time(hours)162 end_time = None163 m = re.search(r"\bà\s+(\d{1,2}\s*h(?:\s*\d{2})?)", hours)164 if m:165 end_time = parse_time(m.group(1))166 events.append(Event(167 source=self.source_id,168 external_id=ext,169 url=r["url"],170 title=r["title"],171 raw_categories=[r["category"]] if r["category"] else [r["title"]],172 venue=extra.get("venue", ""),173 city="Rouyn-Noranda",174 region="Abitibi-Témiscamingue",175 start_date=r["start"],176 start_time=start_time,177 end_date=r["end"],178 end_time=end_time,179 organizer="Ville de Rouyn-Noranda",180 image=r["image"],181 ))182 return events183