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/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é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