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%
8.8 KB · 193 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/quoifaire.py : connecteur Quoi faire (quoifaire.com) — agrégateur5#   Source    : agrégateur de sorties québécois (sections villes : Québec,6#               Montréal, Saguenay, Outaouais, Mauricie — les autres sections7#               renvoyaient 502 à la sonde du 2026-08-25, ignorées proprement).8#   Extraction: listes HTML rendues serveur /<ville>/evenements (+ ?page=N)9#               → URLs de fiches À VENIR seulement (le sitemap remonte 15 ans10#               d'archives, on ne s'en sert pas). Fiches rendues serveur :11#               h1, résumé, plage de dates FR (next-date__date), prix publié12#               (next-date__price « min. X$ | max. Y$ »), heure (data-meta-time,13#               « À confirmer » → None), Région touristique / Lieu / adresse,14#               lat/lng du lien Google Maps, auditoire, catégorie, og:image.15#               INCRÉMENTAL avec cache disque (data/quoifaire_cache.json) :16#               nouvelles fiches d'abord, re-visite après 7 jours, plafonné à17#               QUOIFAIRE_DETAIL_MAX fiches/sync.18#   Accès     : robots.txt « Allow: / » (seuls /search et l'admin sont exclus,19#               on n'y touche pas) ; GET throttlés 0,8 s, identification honnête.20#   Prix      : price_label = texte publié (« min. 40.00$ | max. 150.00$ ») →21#               parse_price en aval (price_min = plus bas montant). Jamais inventé.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import os26import re27import time2829from ..normalize import clean_text, parse_date_range_fr, parse_time30from ..schema import Event31from .base import BaseConnector3233BASE = "https://quoifaire.com"34CITIES = [c.strip() for c in os.environ.get(35    "QUOIFAIRE_CITIES", "quebec,montreal,saguenay,outaouais,mauricie"36).split(",") if c.strip()]3738DETAIL_MAX = int(os.environ.get("QUOIFAIRE_DETAIL_MAX", "150"))39REFRESH_AFTER = 7 * 8640040MAX_PAGES = 40                     # garde-fou pagination par ville4142_LINK_RE = re.compile(r'href="(https://quoifaire\.com/[a-z-]+/evenements/'43                      r'(?!categories)[^"#?]+)"')44_PAGE_RE = re.compile(r'\?page=(\d+)')45_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)46_SUMMARY_RE = re.compile(r'class="summary">\s*<p>(.*?)</p>', re.S)47_DATE_RE = re.compile(r'class="next-date__date">\s*([^<]+?)\s*</p>')48_PRICE_RE = re.compile(r'class="next-date__price">([^<]*)<')49_TIME_RE = re.compile(r'data-meta-time>([^<]*)<')50_OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')51_MAPS_RE = re.compile(r'maps\.google\.[a-z.]+/\?q=(-?[\d.]+),(-?[\d.]+)')52# blocs « info-item » : Région / Lieu (nom en <strong>, adresse en <p>)53_REGION_RE = re.compile(r'info-item__label">R&eacute;gion</div>\s*'54                        r'<div class="info-item__value">([^<]*)<', re.S)55_REGION2_RE = re.compile(r'info-item__label">Région</div>\s*'56                         r'<div class="info-item__value">([^<]*)<', re.S)57_LIEU_RE = re.compile(r'info-item__label">Lieu</div>\s*'58                      r'<div class="info-item__value">\s*'59                      r'<strong>([^<]*)</strong>\s*(?:<p>([^<]*)</p>)?', re.S)60_META_ITEM_RE = re.compile(r'<span class="label">([^<]+)</span>\s*'61                           r'<span class="value">\s*(?:<a[^>]*>)?([^<]+)', re.S)626364class QuoiFaireConnector(BaseConnector):65    source_id = "quoifaire"66    request_delay = 0.86768    # -- listes (événements à venir seulement) --------------------------------69    def _city_links(self, city: str) -> list[str]:70        links: list[str] = []71        last = 172        page = 173        while page <= min(last, MAX_PAGES):74            url = f"{BASE}/{city}/evenements" + (f"?page={page}" if page > 1 else "")75            html = self.get(url).text76            batch = [u for u in _LINK_RE.findall(html) if u not in links]77            if page > 1 and not batch:78                break                     # fin réelle de la pagination79            links += batch80            pages = [int(p) for p in _PAGE_RE.findall(html)]81            if pages:82                last = max(last, max(pages))83            page += 184        return links8586    # -- fiche -----------------------------------------------------------------87    def _parse_fiche(self, url: str, html: str) -> dict | None:88        h1 = _H1_RE.search(html)89        title = clean_text(h1.group(1)) if h1 else ""90        if not title:91            return None92        date_m = _DATE_RE.search(html)93        start, end = parse_date_range_fr(date_m.group(1) if date_m else None)94        if not start:95            return None                   # fiche sans date sûre : inutilisable96        price_m = _PRICE_RE.search(html)97        time_m = _TIME_RE.search(html)98        region_m = _REGION_RE.search(html) or _REGION2_RE.search(html)99        lieu_m = _LIEU_RE.search(html)100        img_m = _OG_IMG_RE.search(html)101        maps_m = _MAPS_RE.search(html)102        summary_m = _SUMMARY_RE.search(html)103104        venue, address, city = "", "", ""105        if lieu_m:106            venue = clean_text(lieu_m.group(1))107            address = clean_text(lieu_m.group(2) or "")108            # « 250 Boul. …, Lairet, La Cité-Limoilou, Québec, Capitale-109            #   Nationale, G1L 5A7, Canada » → la ville précède la région adm.110            parts = [p.strip() for p in address.split(",")]111            if len(parts) >= 4:112                city = parts[-4]113114        audience, category = "", ""115        for label, value in _META_ITEM_RE.findall(html):116            k, v = clean_text(label), clean_text(value)117            if k == "Auditoire" and v.lower() != "non spécifié":118                audience = v119            elif k == "Catégorie":120                category = v121122        return {123            "title": title,124            "description": clean_text(summary_m.group(1)) if summary_m else "",125            "raw_categories": [c for c in (category, title) if c],126            "venue": venue, "address": address, "city": city,127            "tourist_region": clean_text(region_m.group(1)) if region_m else "",128            "lat": maps_m.group(1) if maps_m else None,129            "lng": maps_m.group(2) if maps_m else None,130            "start_date": start, "end_date": end,131            "start_time": parse_time(time_m.group(1)) if time_m else None,132            "price_label": clean_text(price_m.group(1)) if price_m else "",133            "audience": audience,134            "image": img_m.group(1) if img_m else "",135        }136137    # -- pipeline ---------------------------------------------------------------138    def fetch(self) -> list[Event]:139        urls: list[str] = []140        for city in CITIES:141            try:142                urls += [u for u in self._city_links(city) if u not in urls]143            except Exception as exc:  # section 502 : on ne bloque pas la source144                print(f"[sorti-ka] quoifaire : section '{city}' ignorée : {exc}")145146        cache = self.load_cache()147        current = set(urls)148        cache = {u: c for u, c in cache.items() if u in current}149150        now = time.time()151        candidates = sorted(152            (u for u in urls153             if u not in cache154             or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER),155            key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX]156157        def worker(url, session):158            return self._parse_fiche(url, session.get(url, timeout=20).text)159160        for url, row in self.fetch_many(candidates, worker, max_workers=4).items():161            cache[url] = {"fetched_at": now, "row": row}162        self.save_cache(cache)163164        events: list[Event] = []165        seen: set[str] = set()166        for url, entry in cache.items():167            row = entry.get("row")168            if not row:169                continue170            slug = url.rstrip("/").rsplit("/", 1)[-1]171            if slug in seen:      # même fiche listée dans deux sections villes172                continue173            seen.add(slug)174            events.append(Event(175                source=self.source_id,176                external_id=slug,177                url=url, title=row["title"],178                description=row.get("description", ""),179                raw_categories=row.get("raw_categories") or [],180                audience=row.get("audience", ""),181                venue=row.get("venue", ""),182                address=row.get("address", ""),183                city=row.get("city", ""),184                tourist_region=row.get("tourist_region", ""),185                lat=row.get("lat"), lng=row.get("lng"),186                start_date=row.get("start_date"),187                start_time=row.get("start_time"),188                end_date=row.get("end_date"),189                price_label=row.get("price_label", ""),190                image=row.get("image", ""),191            ))192        return events193