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%

Enrichissement des connecteurs + géocodage des salles

- geocode.py (nouveau) : géocodeur Nominatim des salles — cache disque
  data/geocode_cache.json, throttle 1,1 s, UA SortiKaBot identifié, plafond
  SORTIKA_GEOCODE_MAX=120 req/cycle, garde-fous bbox Québec + ville dans la
  display_name, repli sur segment de nom composé ; branché dans ingest.run()
- lepointdevente : enrichissement incrémental par fiches /billets/<id>
  (cache, LPDV_DETAIL_MAX=150/sync, re-visite 7 j, throttle 0,8 s) —
  description og:description (boilerplate filtré), heure précise (cache),
  tarifs réels via /plugins/rates/<id> → price_min/price_label/is_free
- sitq : contacts Tourinsoft (TelephonePrincipals/Courriels) → description
  « Renseignements : … » (le flux ne publie ni Descriptions* ni tarifs)
- montreal : venue = emplacement (si vrai nom de lieu — aujourdhui la ville
  ny publie que le mode En salle/À lextérieur/En ligne) ou titre_adresse ;
  address complétée par adresse_secondaire
- evenko : champ free sondé null sur 100 % des hits — détection gratuité
  par étiquettes explicites event_tag/promotional_tag en complément
- sources.json : registre des champs extraits à jour

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 2bc01ff

7 changed files +336 −14

modified data/sources.json +4 −4
@@ -6,7 +6,7 @@
6 6 "name": "SIT Québec — Ministère du Tourisme",
7 7 "url": "https://www.donneesquebec.ca/recherche/dataset/sit-quebec-evenements",
8 8 "type": "donnees-ouvertes",
9 − "extraction": "API JSON (Tourinsoft, syndication publique)",
9 + "extraction": "API JSON (Tourinsoft, syndication publique) — dates, géoloc, thèmes, contacts (TelephonePrincipals/Courriels → description « Renseignements : … ») ; le flux ne publie ni Descriptions* ni tarifs (vérifié 2026-08)",
10 10 "acces_legal": "Licence Creative Commons 4.0 (Données Québec) — API officielle publiée par le Ministère du Tourisme",
11 11 "couverture": "Événements touristiques, toutes les régions du Québec",
12 12 "cadence": "horaire (watcher PM2)",
@@ -17,7 +17,7 @@
17 17 "name": "Ville de Montréal — Événements publics",
18 18 "url": "https://donnees.montreal.ca/dataset/evenements-publics",
19 19 "type": "donnees-ouvertes",
20 − "extraction": "API CKAN datastore_search (JSON, paginée)",
20 + "extraction": "API CKAN datastore_search (JSON, paginée) — venue = emplacement (si vrai nom de lieu ; aujourd'hui la ville n'y publie que le mode En salle/À l'extérieur/En ligne) ou titre_adresse ; address = adresse_principale + adresse_secondaire",
21 21 "acces_legal": "Licence CC BY 4.0 (donnees.montreal.ca) — attribution : Ville de Montréal",
22 22 "couverture": "Événements municipaux, île de Montréal",
23 23 "cadence": "horaire (watcher PM2)",
@@ -28,7 +28,7 @@
28 28 "name": "Le point de vente — billetterie québécoise",
29 29 "url": "https://lepointdevente.com/billets",
30 30 "type": "billetterie",
31 − "extraction": "pages de liste HTML rendues serveur (paginées), parse des cartes",
31 + "extraction": "pages de liste HTML rendues serveur (paginées), parse des cartes + fiches détail /billets/<id> incrémentales (cache data/lepointdevente_cache.json, 150 fiches/sync, re-visite 7 j) : description og:description, heure précise, tarifs JSON réels via /plugins/rates/<id> → price_min/price_label/is_free",
32 32 "acces_legal": "robots.txt : « User-Agent: * / Allow: * » — GET throttlés, identification honnête, lien direct vers chaque billetterie",
33 33 "couverture": "Concerts, humour, théâtre, festivals — partout au Québec (~2 500 événements à venir)",
34 34 "cadence": "horaire (watcher PM2)",
@@ -97,7 +97,7 @@
97 97 "name": "evenko — grands concerts et festivals",
98 98 "url": "https://evenko.ca/fr/evenements",
99 99 "type": "promoteur",
100 − "extraction": "API /api/search (proxy Algolia) — format percé : GET ?body=base64({params:[query,options],lang:'fr-CA'}), filters entity_type:evenko_show, ~5 requêtes/sync",
100 + "extraction": "API /api/search (proxy Algolia) — format percé : GET ?body=base64({params:[query,options],lang:'fr-CA'}), filters entity_type:evenko_show, ~5 requêtes/sync ; gratuité : champ free (sondé null sur 100 % des hits, 2026-08) + étiquettes « gratuit » (event_tag/promotional_tag) ; aucun montant exposé par l'index",
101 101 "acces_legal": "robots.txt : Allow /api/search* (explicite) — lien billets = URL Ticketmaster officielle",
102 102 "couverture": "Grands concerts (Centre Bell, Place Bell, MTELUS…), festivals (Osheaga, ÎleSoniq…)",
103 103 "cadence": "horaire (watcher PM2)",
modified sortika/connectors/evenko.py +15 −3
@@ -9,7 +9,12 @@
9 9 # options], lang:"fr-CA"}). ~14 requêtes/sync (100 hits/page).
10 10 # Accès : robots.txt Allow /api/search* ; GET throttlés, identification
11 11 # honnête ; lien billets = URL Ticketmaster officielle du hit.
12 −# Prix : champ `free` de la source → is_free ; montants non exposés.
12 +# Prix : champ `free` de la source → is_free, MAIS sondé null sur
13 +# 100 % des hits (462/462, vérifié 2026-08) et l'index ne porte
14 +# aucun montant (additional_information.regular_ticket = libellés
15 +# + URLs Ticketmaster seulement, filtre free:true → 0 hit).
16 +# Complément honnête : étiquettes explicites « gratuit » dans
17 +# event_tag / promotional_tag → is_free=True. On n'invente rien.
13 18 # -----------------------------------------------------------------------------
14 19 from __future__ import annotations
15 20
@@ -26,7 +31,7 @@ PAGE_SIZE = 100
26 31 ATTRS = ["objectID", "title", "subtitle", "headliners", "venue", "show_date",
27 32 "show_time", "hide_show_time", "category", "genre", "description",
28 33 "free", "age", "thumbnail", "status", "additional_information",
29 − "presented_by"]
34 + "presented_by", "event_tag", "promotional_tag"]
30 35
31 36 # les salles evenko hors Québec sont exclues du périmètre (Sorti-Ka = QC)
32 37 _EXCLUDED_REGIONS = ("ottawa", "ontario", "nouveau-brunswick", "nouvelle-écosse",
@@ -91,6 +96,13 @@ class EvenkoConnector(BaseConnector):
91 96 if thumb.startswith("//"):
92 97 thumb = "https:" + thumb
93 98 day = _unix_to_iso(hit.get("show_date")) or _unix_to_iso(hit.get("show_time"))
99 + # gratuité : champ `free` (aujourd'hui toujours null côté
100 + # source, gardé au cas où il serait re-rempli) + étiquettes
101 + # explicites « gratuit » (event_tag / promotional_tag).
102 + is_free = bool(hit["free"]) if hit.get("free") is not None else None
103 + tags = f"{hit.get('event_tag') or ''},{hit.get('promotional_tag') or ''}"
104 + if is_free is None and "gratuit" in tags.lower():
105 + is_free = True
94 106 events.append(Event(
95 107 source=self.source_id,
96 108 external_id=ext_id,
@@ -105,7 +117,7 @@ class EvenkoConnector(BaseConnector):
105 117 city=venue.get("city") or "",
106 118 start_date=day,
107 119 end_date=day,
108 − is_free=bool(hit["free"]) if hit.get("free") is not None else None,
120 + is_free=is_free,
109 121 organizer="evenko",
110 122 image=thumb,
111 123 ))
modified sortika/connectors/lepointdevente.py +105 −4
@@ -9,20 +9,38 @@
9 9 # Aucun rendu JS nécessaire.
10 10 # Accès : robots.txt = « User-Agent: * / Allow: * » (scraping permis) ;
11 11 # GET throttlés 0,6 s, identification honnête, ~65 requêtes/sync.
12 −# Prix : non affichés sur les listes → is_free reste inconnu (on
13 −# n'invente JAMAIS un prix) ; chaque fiche pointe vers la
14 −# billetterie officielle pour le prix réel.
12 +# Détail : enrichissement incrémental par fiches /billets/<id> (modèle
13 +# lavitrine) — cache disque data/lepointdevente_cache.json,
14 +# plafond LPDV_DETAIL_MAX fiches/sync (défaut 150), re-visite
15 +# après 7 jours, throttle poli 0,8 s. Chaque fiche donne :
16 +# description (og:description), heure précise (data-event-name)
17 +# et l'id interne du widget billetterie (data-reference) →
18 +# tarifs JSON réels via /plugins/rates/<id> (prix TTC affichés
19 +# par la billetterie elle-même : price_min / price_label /
20 +# is_free si tous les tarifs sont à 0 $). Jamais de prix inventé.
15 21 # -----------------------------------------------------------------------------
16 22 from __future__ import annotations
17 23
24 +import os
18 25 import re
26 +import time
19 27
20 −from ..normalize import parse_date_fr
28 +from ..normalize import clean_text, parse_date_fr
21 29 from ..schema import Event
22 30 from .base import BaseConnector
23 31
24 32 BASE = "https://lepointdevente.com"
25 33
34 +DETAIL_MAX = int(os.environ.get("LPDV_DETAIL_MAX", "150")) # fiches/sync
35 +REFRESH_AFTER = 7 * 86400 # re-visite d'une fiche (les tarifs évoluent)
36 +
37 +_OG_DESC_RE = re.compile(r'property="og:description" content="([^"]*)"')
38 +_META_DESC_RE = re.compile(r'<meta name="description" content="([^"]*)"')
39 +_REFERENCE_RE = re.compile(r'data-model="event" data-reference="(\d+)"')
40 +_REFERENCE_ALT_RE = re.compile(r'data-event="(\d+)"')
41 +_EVENT_TIME_RE = re.compile(r'data-event-name="[^"]*\((\d{1,2}\s+\S+\s+\d{4})'
42 + r'\s+(\d{1,2}h\d{2})\)"')
43 +
26 44 _CARD_RE = re.compile(
27 45 r'<a href="(/billets/[^"]+)"[^>]*class="feature-link[^"]*".*?'
28 46 r'(?:<img[^>]*src="([^"]+)"[^>]*>.*?)?'
@@ -70,6 +88,88 @@ class LePointDeVenteConnector(BaseConnector):
70 88 ))
71 89 return events
72 90
91 + # -- enrichissement par fiches détail (prix réels, description, heure) ----
92 + def _parse_detail(self, html: str) -> dict:
93 + """Extrait d'une fiche /billets/<id> : description, heure précise et
94 + id interne du widget billetterie (pour les tarifs)."""
95 + info: dict = {}
96 + m = _OG_DESC_RE.search(html) or _META_DESC_RE.search(html)
97 + if m:
98 + desc = clean_text(m.group(1))
99 + # meta générique du site (fiche sans description propre) : ignorer
100 + if not desc.startswith("Découvrez une multitude d'événements"):
101 + info["description"] = desc
102 + m = _EVENT_TIME_RE.search(html)
103 + if m: # ex. « (29 août 2026 19h30) » → "19h30"
104 + info["time"] = m.group(2)
105 + m = _REFERENCE_RE.search(html) or _REFERENCE_ALT_RE.search(html)
106 + if m:
107 + info["reference"] = m.group(1)
108 + return info
109 +
110 + def _fetch_rates(self, reference: str) -> dict:
111 + """Tarifs réels affichés par la billetterie (/plugins/rates/<id>).
112 +
113 + price_min = plus bas tarif payant ; is_free=True seulement si TOUS
114 + les tarifs sont à 0 $ ; les extras (addon: stationnement, etc.) sont
115 + exclus. price_label conserve le détail pour la traçabilité.
116 + """
117 + data = self.get_json(f"{BASE}/plugins/rates/{reference}")
118 + rates = [r for r in data.get("rates") or [] if not r.get("addon")]
119 + prices = [float(r.get("price") or 0) for r in rates]
120 + if not prices:
121 + return {}
122 + labels = [f"{clean_text(r.get('name') or '')} "
123 + f"{float(r.get('price') or 0):g} $" for r in rates]
124 + label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "")
125 + if max(prices) == 0:
126 + return {"is_free": True, "price_min": None, "price_label": label}
127 + return {"is_free": False, "price_min": min(p for p in prices if p > 0),
128 + "price_label": label}
129 +
130 + def _enrich_details(self, events: list[Event]) -> None:
131 + """Fiches détail — incrémental (modèle lavitrine) : nouvelles fiches
132 + d'abord puis re-visite roulante après 7 jours, plafonné à
133 + LPDV_DETAIL_MAX fiches/sync, séquentiel throttlé 0,8 s (2 GET par
134 + fiche : HTML + tarifs JSON). Événements à venir seulement."""
135 + from datetime import date
136 + today = date.today().isoformat()
137 + cache = self.load_cache()
138 + current = {ev.url for ev in events}
139 + cache = {u: c for u, c in cache.items() if u in current}
140 +
141 + now = time.time()
142 + candidates = sorted(
143 + (ev.url for ev in events
144 + if (ev.start_date or today) >= today
145 + and (ev.url not in cache
146 + or now - cache[ev.url].get("fetched_at", 0) > REFRESH_AFTER)),
147 + key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:DETAIL_MAX]
148 +
149 + for url in candidates:
150 + try:
151 + info = self._parse_detail(self.get(url).text)
152 + if info.get("reference"):
153 + try:
154 + info.update(self._fetch_rates(info["reference"]))
155 + except Exception:
156 + pass # tarifs indisponibles : la fiche reste utile
157 + except Exception: # fiche cassée : on ne bloque pas la source
158 + print(f"[sorti-ka] lepointdevente : fiche ignorée {url}")
159 + info = {}
160 + info["fetched_at"] = now
161 + cache[url] = info
162 + self.save_cache(cache)
163 +
164 + for ev in events:
165 + info = cache.get(ev.url) or {}
166 + if info.get("description") and not ev.description:
167 + ev.description = info["description"]
168 + if "is_free" in info: # tarifs réels de la billetterie
169 + ev.is_free = info["is_free"]
170 + ev.price_min = info.get("price_min")
171 + ev.price_label = info.get("price_label", "")
172 +
73 173 def fetch(self) -> list[Event]:
74 174 events: list[Event] = []
75 175 html = self.get(f"{BASE}/billets").text
@@ -93,4 +193,5 @@ class LePointDeVenteConnector(BaseConnector):
93 193 continue
94 194 seen.add(ev.external_id)
95 195 unique.append(ev)
196 + self._enrich_details(unique)
96 197 return unique
modified sortika/connectors/montreal.py +18 −2
@@ -27,6 +27,13 @@ IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300"))
27 27
28 28 _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"')
29 29
30 +# Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom
31 +# de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières
32 +# valeurs existent). On ne les met jamais dans venue ; si la ville publie un
33 +# jour de vrais noms de lieux dans ce champ, ils seront captés d'office.
34 +_EMPLACEMENT_GENERIQUE = {"en salle", "en ligne", "a l'exterieur",
35 + "à l'extérieur", "en salle et en ligne", "hybride"}
36 +
30 37
31 38 def _s(v) -> str:
32 39 """Le datastore renvoie parfois la chaîne 'None' — la traiter comme vide."""
@@ -86,6 +93,15 @@ class MontrealConnector(BaseConnector):
86 93 ext_id = url.rstrip("/").rsplit("/", 1)[-1]
87 94 cost = _s(rec.get("cout"))
88 95 inscription = _s(rec.get("inscription"))
96 + # venue : emplacement (si c'est un vrai nom de lieu et non un
97 + # mode « En salle »/« En ligne ») sinon titre_adresse ;
98 + # address : adresse principale + complément (adresse_secondaire).
99 + emplacement = _s(rec.get("emplacement"))
100 + if emplacement.lower() in _EMPLACEMENT_GENERIQUE:
101 + emplacement = ""
102 + address = ", ".join(p for p in (
103 + _s(rec.get("adresse_principale")),
104 + _s(rec.get("adresse_secondaire"))) if p)
89 105 events.append(Event(
90 106 source=self.source_id,
91 107 external_id=ext_id,
@@ -94,8 +110,8 @@ class MontrealConnector(BaseConnector):
94 110 description=_s(rec.get("description")),
95 111 raw_categories=[_s(rec.get("type_evenement"))],
96 112 audience=_s(rec.get("public_cible")),
97 − venue=_s(rec.get("titre_adresse")),
98 − address=_s(rec.get("adresse_principale")),
113 + venue=emplacement or _s(rec.get("titre_adresse")),
114 + address=address,
99 115 city="Montréal",
100 116 region="Montréal",
101 117 postal_code=_s(rec.get("code_postal")),
modified sortika/connectors/sitq.py +15 −0
@@ -9,6 +9,11 @@
9 9 # Couverture: ~1 600 événements touristiques, TOUTES les régions du Québec
10 10 # (festivals, salons, manifestations sportives et culturelles),
11 11 # avec région touristique, thèmes, dates, géolocalisation, site web.
12 +# Contacts : TelephonePrincipals / Courriels (moyens de communication) →
13 +# ligne « Renseignements : … » en description (le flux syndiqué
14 +# ne publie AUCUN groupe Descriptions* ni bloc tarifs — vérifié
15 +# sur l'union des clés des 1 598 fiches, 2026-08 ; is_free et
16 +# price_* restent donc inconnus, jamais inventés).
12 17 # https://www.donneesquebec.ca/recherche/dataset/sit-quebec-evenements
13 18 # -----------------------------------------------------------------------------
14 19 from __future__ import annotations
@@ -129,11 +134,21 @@ class SitqConnector(BaseConnector):
129 134 rec.get("RegionsDesserviess"),
130 135 "Regionstouristiques", "ThesLibelle", default="") or ""
131 136
137 + # moyens de communication (téléphone principal, courriel) : le
138 + # flux n'offre aucun texte descriptif → on publie au moins les
139 + # coordonnées de renseignements, toujours factuelles.
140 + phone = _first(rec.get("TelephonePrincipals"),
141 + "Coordonnees", default="") or ""
142 + email = _first(rec.get("Courriels"), "Coordonnees", default="") or ""
143 + contacts = " · ".join(c for c in (phone.strip(), email.strip()) if c)
144 + description = f"Renseignements : {contacts}" if contacts else ""
145 +
132 146 events.append(Event(
133 147 source=self.source_id,
134 148 external_id=ext_id,
135 149 url=website or "https://www.quebecoriginal.com/",
136 150 title=title,
151 + description=description,
137 152 raw_categories=raw_cats,
138 153 audience=audience,
139 154 venue=addr.get("Nomdupointdeservice", "") or "",
added sortika/geocode.py +170 −0
@@ -0,0 +1,170 @@
1 +# -----------------------------------------------------------------------------
2 +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# geocode.py : géocodage des salles — Nominatim (OpenStreetMap) avec cache
5 +# persistant. Le plus gros gain GPS du pipeline : lavitrine,
6 +# lepointdevente, evenko, atuvu, laval… publient salle + ville
7 +# (~100 %) mais aucune coordonnée. ~200 salles récurrentes →
8 +# le cache disque converge vite, puis plus aucune requête réseau.
9 +# Politique Nominatim (https://operations.osmfoundation.org/policies/nominatim/) :
10 +# 1 requête/s max (throttle 1,1 s), User-Agent identifié, cache obligatoire.
11 +# Garde-fous : on n'invente JAMAIS un GPS — résultat rejeté hors du
12 +# rectangle englobant du Québec ; échecs mémorisés (re-tentés après 30 j) ;
13 +# plafond de requêtes réseau par cycle (SORTIKA_GEOCODE_MAX, défaut 120).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import json
18 +import os
19 +import sqlite3
20 +import time
21 +from pathlib import Path
22 +
23 +import requests
24 +
25 +from .normalize import clean_text, strip_accents
26 +
27 +NOMINATIM = "https://nominatim.openstreetmap.org/search"
28 +USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com)"
29 +CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "geocode_cache.json"
30 +
31 +REQUEST_DELAY = 1.1 # politesse Nominatim : max ~1 req/s
32 +GEOCODE_MAX = int(os.environ.get("SORTIKA_GEOCODE_MAX", "120")) # req réseau/cycle
33 +RETRY_FAIL_AFTER = 30 * 86400 # re-tenter un échec après 30 jours
34 +
35 +# Rectangle englobant du Québec (avec marge) : tout résultat hors de cette
36 +# boîte est rejeté (homonymes en France, aux É.-U., etc.).
37 +QC_BBOX = {"lat_min": 44.5, "lat_max": 63.5, "lng_min": -80.5, "lng_max": -56.5}
38 +
39 +_last_request = 0.0
40 +
41 +
42 +def _cache_key(venue: str, city: str) -> str:
43 + """Clé normalisée « venue|ville » : minuscules, sans accents, espaces réduits."""
44 + v = " ".join(strip_accents(clean_text(venue)).lower().split())
45 + c = " ".join(strip_accents(clean_text(city)).lower().split())
46 + return f"{v}|{c}"
47 +
48 +
49 +def load_cache() -> dict:
50 + try:
51 + return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
52 + except Exception:
53 + return {}
54 +
55 +
56 +def save_cache(cache: dict) -> None:
57 + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
58 + CACHE_PATH.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
59 +
60 +
61 +def _in_quebec(lat: float, lng: float) -> bool:
62 + return (QC_BBOX["lat_min"] <= lat <= QC_BBOX["lat_max"]
63 + and QC_BBOX["lng_min"] <= lng <= QC_BBOX["lng_max"])
64 +
65 +
66 +def _nominatim(session: requests.Session, query: str,
67 + city: str) -> tuple[float, float] | None:
68 + """Une requête Nominatim throttlée ; None si aucun résultat valide.
69 +
70 + Double garde-fou anti-invention : résultat rejeté hors du rectangle
71 + Québec ET si sa display_name ne mentionne pas la ville attendue
72 + (homonymes d'une autre municipalité).
73 + """
74 + global _last_request
75 + wait = REQUEST_DELAY - (time.time() - _last_request)
76 + if wait > 0:
77 + time.sleep(wait)
78 + resp = session.get(NOMINATIM, params={
79 + "q": query, "format": "jsonv2", "limit": 1,
80 + "countrycodes": "ca", "accept-language": "fr",
81 + }, timeout=20)
82 + _last_request = time.time()
83 + resp.raise_for_status()
84 + results = resp.json()
85 + if not results:
86 + return None
87 + lat, lng = float(results[0]["lat"]), float(results[0]["lon"])
88 + if not _in_quebec(lat, lng):
89 + return None
90 + city_key = strip_accents(city).lower()
91 + display = strip_accents(results[0].get("display_name", "")).lower()
92 + if city_key and city_key not in display:
93 + return None
94 + return lat, lng
95 +
96 +
97 +# séparateurs de doubles noms de salle (« Salle X | Centre des arts Y ») :
98 +# en cas d'échec sur le nom complet, on re-tente avec le segment le plus long
99 +_VENUE_SPLIT = ("|", " - ", " – ", " — ")
100 +
101 +
102 +def _lookup(session: requests.Session, venue: str,
103 + city: str) -> tuple[tuple[float, float] | None, int]:
104 + """Résout une salle : nom complet, puis segment le plus long en repli.
105 + Retourne (coordonnées ou None, nombre de requêtes réseau consommées)."""
106 + found = _nominatim(session, f"{venue}, {city}, Québec, Canada", city)
107 + requests_used = 1
108 + if found:
109 + return found, requests_used
110 + for sep in _VENUE_SPLIT:
111 + if sep in venue:
112 + segment = max((s.strip() for s in venue.split(sep)), key=len)
113 + if segment and segment != venue:
114 + found = _nominatim(
115 + session, f"{segment}, {city}, Québec, Canada", city)
116 + requests_used += 1
117 + break
118 + return found, requests_used
119 +
120 +
121 +def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:
122 + """Remplit lat/lng des événements actifs sans GPS (salle + ville connues).
123 +
124 + Appelé par le pipeline d'ingestion après les syncs. Les salles déjà
125 + résolues viennent du cache (0 requête) — seules les nouvelles salles
126 + consomment le plafond de requêtes réseau.
127 + """
128 + cache = load_cache()
129 + session = requests.Session()
130 + session.headers["User-Agent"] = USER_AGENT
131 + now = time.time()
132 + stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0,
133 + "cached_places": len(cache)}
134 +
135 + rows = con.execute(
136 + "SELECT DISTINCT venue, city FROM events WHERE active=1 "
137 + "AND lat IS NULL AND venue != '' AND city != '' "
138 + "ORDER BY venue, city").fetchall()
139 +
140 + for row in rows:
141 + venue, city = row["venue"], row["city"]
142 + key = _cache_key(venue, city)
143 + entry = cache.get(key)
144 + if entry is None or (not entry.get("ok")
145 + and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):
146 + if stats["lookups"] >= max_requests:
147 + continue # plafond atteint : salle re-tentée au prochain cycle
148 + try:
149 + found, used = _lookup(session, venue, city)
150 + except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé
151 + stats["lookups"] += 1
152 + continue
153 + stats["lookups"] += used
154 + if found:
155 + entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}
156 + stats["hits"] += 1
157 + else: # échec mémorisé (re-tenté après 30 jours)
158 + entry = {"ok": False, "ts": now}
159 + stats["misses"] += 1
160 + cache[key] = entry
161 + if entry and entry.get("ok"):
162 + cur = con.execute(
163 + "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "
164 + "AND venue=? AND city=?", (entry["lat"], entry["lng"], venue, city))
165 + stats["events_filled"] += cur.rowcount
166 +
167 + save_cache(cache)
168 + con.commit()
169 + stats["cached_places"] = len(cache)
170 + return stats
modified sortika/ingest.py +9 −1
@@ -11,7 +11,7 @@ import sys
11 11 import time
12 12 import traceback
13 13
14 −from . import db
14 +from . import db, geocode
15 15 from .connectors import CONNECTORS
16 16
17 17
@@ -47,6 +47,14 @@ def run(sources: list[str] | None = None) -> list[dict]:
47 47 db.log_failure(con, sid, f"{exc}")
48 48 traceback.print_exc()
49 49 results.append({"source": sid, "error": str(exc)})
50 + # Géocodage des salles (transversal) : remplit lat/lng des événements
51 + # actifs sans GPS (salle + ville connues) — Nominatim + cache disque,
52 + # plafond de requêtes réseau par cycle (voir sortika/geocode.py).
53 + try:
54 + gstats = geocode.run(con)
55 + print(f"[sorti-ka] geocode {gstats}")
56 + except Exception: # le géocodage ne bloque jamais l'ingestion
57 + traceback.print_exc()
50 58 try:
51 59 con.execute("PRAGMA optimize")
52 60 except Exception:
53 61