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.0 KB · 192 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# geocode.py : géocodage des salles — Nominatim (OpenStreetMap) avec cache5#              persistant. Le plus gros gain GPS du pipeline : lavitrine,6#              lepointdevente, evenko, atuvu, laval… publient salle + ville7#              (~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 du12#   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# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import os19import sqlite320import time21from pathlib import Path2223import requests2425from .normalize import clean_text, strip_accents2627NOMINATIM = "https://nominatim.openstreetmap.org/search"28USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com)"29CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "geocode_cache.json"3031REQUEST_DELAY = 1.1                      # politesse Nominatim : max ~1 req/s32GEOCODE_MAX = int(os.environ.get("SORTIKA_GEOCODE_MAX", "120"))  # req réseau/cycle33RETRY_FAIL_AFTER = 30 * 86400            # re-tenter un échec après 30 jours3435# Rectangle englobant du Québec (avec marge) : tout résultat hors de cette36# boîte est rejeté (homonymes en France, aux É.-U., etc.).37QC_BBOX = {"lat_min": 44.5, "lat_max": 63.5, "lng_min": -80.5, "lng_max": -56.5}3839_last_request = 0.0404142def _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}"474849def load_cache() -> dict:50    try:51        return json.loads(CACHE_PATH.read_text(encoding="utf-8"))52    except Exception:53        return {}545556def 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")596061def _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"])646566def _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.6970    Double garde-fou anti-invention : résultat rejeté hors du rectangle71    Québec ET si sa display_name ne mentionne pas la ville attendue72    (homonymes d'une autre municipalité).73    """74    global _last_request75    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 None87    lat, lng = float(results[0]["lat"]), float(results[0]["lon"])88    if not _in_quebec(lat, lng):89        return None90    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 None94    return lat, lng959697# 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 long99_VENUE_SPLIT = ("|", " - ", " – ", " — ")100101102def _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 = 1108    if found:109        return found, requests_used110    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 += 1117            break118    return found, requests_used119120121def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:122    """Remplit lat/lng des événements actifs sans GPS (salle + ville connues).123124    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 salles126    consomment le plafond de requêtes réseau.127    """128    cache = load_cache()129    session = requests.Session()130    session.headers["User-Agent"] = USER_AGENT131    now = time.time()132    stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0,133             "cached_places": len(cache)}134135    # Phase 2 : l'ADRESSE CIVIQUE prime quand la source la publie (Brossard :136    # 94 % d'adresses mais 0,7 % de GPS via salle+ville ; Longueuil : 100 %137    # d'adresses). Repli : salle + ville (comportement historique).138    rows = con.execute(139        "SELECT DISTINCT venue, address, city FROM events WHERE active=1 "140        "AND lat IS NULL AND city != '' AND (venue != '' OR address != '') "141        "ORDER BY address DESC, venue, city").fetchall()142143    for row in rows:144        venue, address, city = row["venue"], row["address"], row["city"]145        # candidats dans l'ordre de précision : adresse civique, puis salle146        candidates = []147        if address:148            candidates.append(("addr:" + _cache_key(address, city),149                               "addr", address))150        if venue:151            candidates.append((_cache_key(venue, city), "venue", venue))152        entry = None153        for key, kind, query in candidates:154            entry = cache.get(key)155            if entry is None or (not entry.get("ok")156                                 and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):157                if stats["lookups"] >= max_requests:158                    entry = None159                    break     # plafond atteint : lieu re-tenté au prochain cycle160                try:161                    if kind == "addr":162                        found = _nominatim(163                            session, f"{query}, {city}, Québec, Canada", city)164                        used = 1165                    else:166                        found, used = _lookup(session, query, city)167                except Exception:  # réseau/HTTP : ne bloque pas le cycle, pas mémorisé168                    stats["lookups"] += 1169                    entry = None170                    continue171                stats["lookups"] += used172                if found:173                    entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}174                    stats["hits"] += 1175                else:              # échec mémorisé (re-tenté après 30 jours)176                    entry = {"ok": False, "ts": now}177                    stats["misses"] += 1178                cache[key] = entry179            if entry and entry.get("ok"):180                break              # premier candidat résolu = le plus précis181        if entry and entry.get("ok"):182            cur = con.execute(183                "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "184                "AND venue=? AND address=? AND city=?",185                (entry["lat"], entry["lng"], venue, address, city))186            stats["events_filled"] += cur.rowcount187188    save_cache(cache)189    con.commit()190    stats["cached_places"] = len(cache)191    return stats192