# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quoifaire.py : connecteur Quoi faire (quoifaire.com) — agrégateur # Source : agrégateur de sorties québécois (sections villes : Québec, # Montréal, Saguenay, Outaouais, Mauricie — les autres sections # renvoyaient 502 à la sonde du 2026-08-25, ignorées proprement). # Extraction: listes HTML rendues serveur //evenements (+ ?page=N) # → URLs de fiches À VENIR seulement (le sitemap remonte 15 ans # d'archives, on ne s'en sert pas). Fiches rendues serveur : # h1, résumé, plage de dates FR (next-date__date), prix publié # (next-date__price « min. X$ | max. Y$ »), heure (data-meta-time, # « À confirmer » → None), Région touristique / Lieu / adresse, # lat/lng du lien Google Maps, auditoire, catégorie, og:image. # INCRÉMENTAL avec cache disque (data/quoifaire_cache.json) : # nouvelles fiches d'abord, re-visite après 7 jours, plafonné à # QUOIFAIRE_DETAIL_MAX fiches/sync. # Accès : robots.txt « Allow: / » (seuls /search et l'admin sont exclus, # on n'y touche pas) ; GET throttlés 0,8 s, identification honnête. # Prix : price_label = texte publié (« min. 40.00$ | max. 150.00$ ») → # parse_price en aval (price_min = plus bas montant). Jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from ..normalize import clean_text, parse_date_range_fr, parse_time from ..schema import Event from .base import BaseConnector BASE = "https://quoifaire.com" CITIES = [c.strip() for c in os.environ.get( "QUOIFAIRE_CITIES", "quebec,montreal,saguenay,outaouais,mauricie" ).split(",") if c.strip()] DETAIL_MAX = int(os.environ.get("QUOIFAIRE_DETAIL_MAX", "150")) REFRESH_AFTER = 7 * 86400 MAX_PAGES = 40 # garde-fou pagination par ville _LINK_RE = re.compile(r'href="(https://quoifaire\.com/[a-z-]+/evenements/' r'(?!categories)[^"#?]+)"') _PAGE_RE = re.compile(r'\?page=(\d+)') _H1_RE = re.compile(r"]*>(.*?)", re.S) _SUMMARY_RE = re.compile(r'class="summary">\s*

(.*?)

', re.S) _DATE_RE = re.compile(r'class="next-date__date">\s*([^<]+?)\s*

') _PRICE_RE = re.compile(r'class="next-date__price">([^<]*)<') _TIME_RE = re.compile(r'data-meta-time>([^<]*)<') _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') _MAPS_RE = re.compile(r'maps\.google\.[a-z.]+/\?q=(-?[\d.]+),(-?[\d.]+)') # blocs « info-item » : Région / Lieu (nom en , adresse en

) _REGION_RE = re.compile(r'info-item__label">Région\s*' r'

([^<]*)<', re.S) _REGION2_RE = re.compile(r'info-item__label">Région
\s*' r'
([^<]*)<', re.S) _LIEU_RE = re.compile(r'info-item__label">Lieu
\s*' r'
\s*' r'([^<]*)\s*(?:

([^<]*)

)?', re.S) _META_ITEM_RE = re.compile(r'([^<]+)\s*' r'\s*(?:]*>)?([^<]+)', re.S) class QuoiFaireConnector(BaseConnector): source_id = "quoifaire" request_delay = 0.8 # -- listes (événements à venir seulement) -------------------------------- def _city_links(self, city: str) -> list[str]: links: list[str] = [] last = 1 page = 1 while page <= min(last, MAX_PAGES): url = f"{BASE}/{city}/evenements" + (f"?page={page}" if page > 1 else "") html = self.get(url).text batch = [u for u in _LINK_RE.findall(html) if u not in links] if page > 1 and not batch: break # fin réelle de la pagination links += batch pages = [int(p) for p in _PAGE_RE.findall(html)] if pages: last = max(last, max(pages)) page += 1 return links # -- fiche ----------------------------------------------------------------- def _parse_fiche(self, url: str, html: str) -> dict | None: h1 = _H1_RE.search(html) title = clean_text(h1.group(1)) if h1 else "" if not title: return None date_m = _DATE_RE.search(html) start, end = parse_date_range_fr(date_m.group(1) if date_m else None) if not start: return None # fiche sans date sûre : inutilisable price_m = _PRICE_RE.search(html) time_m = _TIME_RE.search(html) region_m = _REGION_RE.search(html) or _REGION2_RE.search(html) lieu_m = _LIEU_RE.search(html) img_m = _OG_IMG_RE.search(html) maps_m = _MAPS_RE.search(html) summary_m = _SUMMARY_RE.search(html) venue, address, city = "", "", "" if lieu_m: venue = clean_text(lieu_m.group(1)) address = clean_text(lieu_m.group(2) or "") # « 250 Boul. …, Lairet, La Cité-Limoilou, Québec, Capitale- # Nationale, G1L 5A7, Canada » → la ville précède la région adm. parts = [p.strip() for p in address.split(",")] if len(parts) >= 4: city = parts[-4] audience, category = "", "" for label, value in _META_ITEM_RE.findall(html): k, v = clean_text(label), clean_text(value) if k == "Auditoire" and v.lower() != "non spécifié": audience = v elif k == "Catégorie": category = v return { "title": title, "description": clean_text(summary_m.group(1)) if summary_m else "", "raw_categories": [c for c in (category, title) if c], "venue": venue, "address": address, "city": city, "tourist_region": clean_text(region_m.group(1)) if region_m else "", "lat": maps_m.group(1) if maps_m else None, "lng": maps_m.group(2) if maps_m else None, "start_date": start, "end_date": end, "start_time": parse_time(time_m.group(1)) if time_m else None, "price_label": clean_text(price_m.group(1)) if price_m else "", "audience": audience, "image": img_m.group(1) if img_m else "", } # -- pipeline --------------------------------------------------------------- def fetch(self) -> list[Event]: urls: list[str] = [] for city in CITIES: try: urls += [u for u in self._city_links(city) if u not in urls] except Exception as exc: # section 502 : on ne bloque pas la source print(f"[sorti-ka] quoifaire : section '{city}' ignorée : {exc}") cache = self.load_cache() current = set(urls) cache = {u: c for u, c in cache.items() if u in current} now = time.time() candidates = sorted( (u for u in urls if u not in cache or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX] def worker(url, session): return self._parse_fiche(url, session.get(url, timeout=20).text) for url, row in self.fetch_many(candidates, worker, max_workers=4).items(): cache[url] = {"fetched_at": now, "row": row} self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for url, entry in cache.items(): row = entry.get("row") if not row: continue slug = url.rstrip("/").rsplit("/", 1)[-1] if slug in seen: # même fiche listée dans deux sections villes continue seen.add(slug) events.append(Event( source=self.source_id, external_id=slug, url=url, title=row["title"], description=row.get("description", ""), raw_categories=row.get("raw_categories") or [], audience=row.get("audience", ""), venue=row.get("venue", ""), address=row.get("address", ""), city=row.get("city", ""), tourist_region=row.get("tourist_region", ""), lat=row.get("lat"), lng=row.get("lng"), start_date=row.get("start_date"), start_time=row.get("start_time"), end_date=row.get("end_date"), price_label=row.get("price_label", ""), image=row.get("image", ""), )) return events