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