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.3 KB · 183 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/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