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%
15.0 KB · 357 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# venues.py : ANNUAIRE DE SALLES (Phase 3) — table `venues` (nom canonique +5#             ville + GPS) qui résout les fiches sans coordonnées quand le6#             géocodage direct échoue (~3 860 fiches lavitrine/lepointdevente7#             au bilan Phase 2). Trois origines, jamais de GPS inventé :8#             · « appris »   : salles déjà géocodées PAR LES SOURCES en base9#                              (auto-apprentissage, 0 requête réseau) ;10#             · « osm »      : lieux culturels du Québec via Overpass11#                              (amenity=theatre/cinema/arts_centre/events_venue/12#                              concert_hall…, refresh 30 j, 1 requête) ;13#             · « nominatim »: géocodage des salles récurrentes les plus14#                              fréquentes (top volume, plafonné par cycle).15#   Matching : nom normalisé (minuscules, sans accents, alphanumérique) —16#   d'abord (salle, ville), puis alias (segments « | », « - », « , »), puis17#   nom seul UNIQUEMENT si la clé est unique dans tout l'annuaire (≥ 8 car.)18#   et que la ville ne contredit pas. Un nom générique (« salle communautaire »)19#   existe en plusieurs exemplaires → jamais matché sans ville.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import os25import re26import sqlite327import time28from pathlib import Path2930from .normalize import clean_text, strip_accents3132OVERPASS_URL = "https://overpass-api.de/api/interpreter"   # PAS overpass.osm.ch33USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com; contact@spboucher.ai)"3435OSM_REFRESH_AFTER = 30 * 86400          # re-télécharger l'extrait OSM après 30 j36GEOCODE_MAX = int(os.environ.get("VENUES_GEOCODE_MAX", "40"))   # req Nominatim/cycle37MIN_KEY_LEN = 8                          # clé minimale pour un match sans ville3839_SCHEMA = """40CREATE TABLE IF NOT EXISTS venues (41    id          INTEGER PRIMARY KEY AUTOINCREMENT,42    name        TEXT NOT NULL,43    name_key    TEXT NOT NULL,44    city        TEXT DEFAULT '',45    city_key    TEXT DEFAULT '',46    address     TEXT DEFAULT '',47    lat         REAL,48    lng         REAL,49    origin      TEXT,                 -- appris | osm | nominatim50    events_seen INTEGER DEFAULT 0,51    created_at  REAL,52    updated_at  REAL,53    UNIQUE(name_key, city_key)54);55CREATE INDEX IF NOT EXISTS idx_venues_name ON venues(name_key);5657CREATE TABLE IF NOT EXISTS venues_meta (58    key   TEXT PRIMARY KEY,59    value TEXT60);61"""6263# séparateurs de noms composés : « Théâtre Desjardins | Salle Jean Grimaldi »,64# « Salle Wilfrid-Pelletier, Place des Arts », « Le Carré 150 - Cabaret … »65_SPLIT_RE = re.compile(r"\s*(?:\||,| - | – | — )\s*")666768def _key(s: str) -> str:69    """Clé de matching : minuscules, sans accents, alphanumérique + espaces."""70    s = strip_accents(clean_text(s or "")).lower()71    s = re.sub(r"[^a-z0-9]+", " ", s)72    return " ".join(s.split())737475def name_aliases(venue: str) -> list[str]:76    """Clés candidates d'un nom de salle : nom complet puis segments composés77    (du plus long au plus court) — on ne fabrique jamais d'autre variante."""78    full = _key(venue)79    if not full:80        return []81    out = [full]82    parts = [p for p in _SPLIT_RE.split(clean_text(venue)) if p.strip()]83    if len(parts) > 1:84        for p in sorted(parts, key=len, reverse=True):85            k = _key(p)86            # segments courts admis : ils ne servent qu'au match AVEC ville87            # (le match sans ville exige MIN_KEY_LEN, voir _match_row)88            if len(k) >= 4 and k not in out:89                out.append(k)90    return out919293def ensure_schema(con: sqlite3.Connection) -> None:94    con.executescript(_SCHEMA)95    con.commit()969798def _upsert(con: sqlite3.Connection, name: str, city: str, lat: float,99            lng: float, origin: str, address: str = "",100            events_seen: int = 0, _alias_of: bool = False) -> None:101    """Insère une salle ; une salle déjà apprise n'est PAS écrasée par une102    origine moins fiable (appris > nominatim > osm : la donnée source prime).103    Les noms COMPOSÉS (« Maison des Arts de Laval - Théâtre des Muses »)104    génèrent aussi une entrée par segment significatif (mêmes coordonnées,105    même ville) : une source qui ne publie que « Théâtre des Muses » matche."""106    nk, ck = _key(name), _key(city)107    if not nk or lat is None or lng is None:108        return109    if not _alias_of:110        for seg in name_aliases(name)[1:]:111            if len(seg) >= MIN_KEY_LEN:112                _upsert(con, seg, city, lat, lng, origin,113                        address=address, _alias_of=True)114    now = time.time()115    rank = {"appris": 3, "nominatim": 2, "osm": 1}116    row = con.execute("SELECT origin, events_seen FROM venues "117                      "WHERE name_key=? AND city_key=?", (nk, ck)).fetchone()118    if row is None:119        con.execute(120            "INSERT OR IGNORE INTO venues (name, name_key, city, city_key, "121            "address, lat, lng, origin, events_seen, created_at, updated_at) "122            "VALUES (?,?,?,?,?,?,?,?,?,?,?)",123            (clean_text(name), nk, clean_text(city), ck, clean_text(address),124             lat, lng, origin, events_seen, now, now))125    elif rank.get(origin, 0) >= rank.get(row["origin"] or "", 0):126        con.execute(127            "UPDATE venues SET name=?, city=?, address=?, lat=?, lng=?, "128            "origin=?, events_seen=MAX(events_seen, ?), updated_at=? "129            "WHERE name_key=? AND city_key=?",130            (clean_text(name), clean_text(city), clean_text(address),131             lat, lng, origin, events_seen, now, nk, ck))132133134# ---------------------------------------------------------------------------135# 1) Auto-apprentissage : salles géocodées PAR LES SOURCES (0 réseau)136# ---------------------------------------------------------------------------137138def learn(con: sqlite3.Connection) -> int:139    """Apprend les salles des événements qui ont déjà un GPS de la source.140    La coordonnée retenue est celle du groupe (salle, ville) le plus vu."""141    ensure_schema(con)142    n = 0143    for r in con.execute(144            "SELECT venue, city, lat, lng, COUNT(*) AS n FROM events "145            "WHERE lat IS NOT NULL AND lng IS NOT NULL AND venue != '' "146            "GROUP BY venue, city, round(lat, 3), round(lng, 3) "147            "ORDER BY n"):          # le plus fréquent écrit en dernier = gagne148        _upsert(con, r["venue"], r["city"], r["lat"], r["lng"],149                "appris", events_seen=r["n"])150        n += 1151    con.commit()152    return n153154155# ---------------------------------------------------------------------------156# 2) OSM / Overpass : lieux culturels du Québec (1 requête, cache 30 j)157# ---------------------------------------------------------------------------158159_OVERPASS_QUERY = """160[out:json][timeout:180];161area["ISO3166-2"="CA-QC"][admin_level=4]->.qc;162(163  nwr["amenity"~"^(theatre|cinema|arts_centre|events_venue|concert_hall|conference_centre|exhibition_centre|planetarium|community_centre|music_venue)$"]["name"](area.qc);164  nwr["leisure"="stadium"]["name"](area.qc);165  nwr["tourism"="museum"]["name"](area.qc);166);167out center tags;168"""169170171OSM_RETRY_AFTER = 3 * 86400      # après un échec réseau, re-tenter dans 3 jours172173174def refresh_osm(con: sqlite3.Connection, force: bool = False,175                path: str | None = None) -> int:176    """Importe l'extrait Overpass des lieux culturels du Québec.177178    · `path` : fichier JSON Overpass déjà téléchargé (utilisé quand le nœud179      n'atteint pas overpass-api.de — constaté 2026-08-19 : « no route to180      host » depuis M3U96a alors que le reste d'Internet répond) ;181    · sinon téléchargement direct (overpass-api.de, UA de bot honnête) si le182      dernier import remonte à plus de 30 jours. Un ÉCHEC réseau est mémorisé183      (re-tentative après 3 jours) pour ne pas ralentir chaque cycle horaire.184    Retourne le nombre de lieux importés.185    """186    import json as _json187188    import requests189    ensure_schema(con)190    now = time.time()191    if path is None:192        row = con.execute(193            "SELECT value FROM venues_meta WHERE key='osm_fetched_at'").fetchone()194        if not force and row and now - float(row["value"]) < OSM_REFRESH_AFTER:195            return 0196        attempt = con.execute(197            "SELECT value FROM venues_meta WHERE key='osm_attempt_at'").fetchone()198        if not force and attempt and now - float(attempt["value"]) < OSM_RETRY_AFTER:199            return 0200        con.execute("INSERT OR REPLACE INTO venues_meta (key, value) "201                    "VALUES ('osm_attempt_at', ?)", (str(now),))202        con.commit()203        resp = requests.post(OVERPASS_URL, data={"data": _OVERPASS_QUERY},204                             headers={"User-Agent": USER_AGENT}, timeout=300)205        resp.raise_for_status()206        payload = resp.json()207    else:208        payload = _json.loads(Path(path).read_text(encoding="utf-8"))209    n = 0210    for el in payload.get("elements", []):211        tags = el.get("tags") or {}212        name = tags.get("name", "")213        lat = el.get("lat") or (el.get("center") or {}).get("lat")214        lng = el.get("lon") or (el.get("center") or {}).get("lon")215        if not name or lat is None or lng is None:216            continue217        _upsert(con, name, tags.get("addr:city", ""), float(lat), float(lng),218                "osm", address=tags.get("addr:street", "") and219                f"{tags.get('addr:housenumber', '')} {tags.get('addr:street', '')}".strip())220        n += 1221    con.execute("INSERT OR REPLACE INTO venues_meta (key, value) "222                "VALUES ('osm_fetched_at', ?)", (str(time.time()),))223    con.commit()224    return n225226227# ---------------------------------------------------------------------------228# 3) Nominatim : salles récurrentes encore sans fiche (plafonné, poli)229# ---------------------------------------------------------------------------230231def geocode_top(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:232    """Géocode les salles SANS entrée d'annuaire les plus fréquentes (volume233    d'événements à venir), via le géocodeur central (throttle 1,1 s, boîte234    englobante Québec, échecs mémorisés dans geocode_cache). 0 invention."""235    import requests as _rq236237    from . import geocode as G238    ensure_schema(con)239    stats = {"lookups": 0, "added": 0}240    session = _rq.Session()241    session.headers["User-Agent"] = G.USER_AGENT242    cache = G.load_cache()243    now = time.time()244    rows = con.execute(245        "SELECT venue, city, COUNT(*) AS n FROM events "246        "WHERE active=1 AND lat IS NULL AND venue != '' AND city != '' "247        "GROUP BY venue, city ORDER BY n DESC LIMIT 400").fetchall()248    for r in rows:249        if stats["lookups"] >= max_requests:250            break251        venue, city = r["venue"], r["city"]252        if _match_row(con, venue, city):253            continue                     # déjà résoluble par l'annuaire254        ck = G._cache_key(venue, city)255        entry = cache.get(ck)256        if entry is not None and (entry.get("ok")257                                  or now - entry.get("ts", 0) <= G.RETRY_FAIL_AFTER):258            if entry.get("ok"):259                _upsert(con, venue, city, entry["lat"], entry["lng"], "nominatim")260                stats["added"] += 1261            continue262        try:263            found, used = G._lookup(session, venue, city)264        except Exception:265            stats["lookups"] += 1266            continue267        stats["lookups"] += used268        if found:269            cache[ck] = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}270            _upsert(con, venue, city, found[0], found[1], "nominatim")271            stats["added"] += 1272        else:273            cache[ck] = {"ok": False, "ts": now}274    G.save_cache(cache)275    con.commit()276    return stats277278279# ---------------------------------------------------------------------------280# Matching & application aux événements sans GPS281# ---------------------------------------------------------------------------282283def _match_row(con: sqlite3.Connection, venue: str, city: str):284    """Retourne la ligne d'annuaire pour (salle, ville), ou None.285286    Ordre : (alias, ville) exact → alias seul si la clé est UNIQUE dans tout287    l'annuaire (≥ 8 car.) et que sa ville ne contredit pas celle de l'événement288    (fiche sans ville acceptée). Jamais de match flou.289    """290    ck = _key(city)291    for alias in name_aliases(venue):292        if ck:293            # avec ville : alias exact, puis alias sans le mot générique de294            # tête (« salle odyssee » → « odyssee ») — la ville, obligatoire295            # ici, écarte les homonymes296            candidates = [alias]297            stripped = re.sub(r"^(salle|theatre|cinema|centre|espace|le|la|les|l)\s+",298                              "", alias)299            if stripped != alias and len(stripped) >= 4:300                candidates.append(stripped)301            for cand in candidates:302                row = con.execute(303                    "SELECT * FROM venues WHERE name_key=? AND city_key=? "304                    "AND lat IS NOT NULL", (cand, ck)).fetchone()305                if row:306                    return row307        if len(alias) < MIN_KEY_LEN:308            continue309        rows = con.execute(310            "SELECT * FROM venues WHERE name_key=? AND lat IS NOT NULL",311            (alias,)).fetchall()312        if len(rows) == 1:313            vck = rows[0]["city_key"]314            if not vck or not ck or vck == ck:315                return rows[0]316    return None317318319def apply(con: sqlite3.Connection) -> dict:320    """Remplit lat/lng des événements actifs sans GPS via l'annuaire.321    Ne touche jamais un GPS existant. Retourne les compteurs."""322    ensure_schema(con)323    stats = {"groups": 0, "events_filled": 0}324    rows = con.execute(325        "SELECT DISTINCT venue, city FROM events WHERE active=1 "326        "AND lat IS NULL AND venue != ''").fetchall()327    for r in rows:328        hit = _match_row(con, r["venue"], r["city"])329        if hit is None:330            continue331        cur = con.execute(332            "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "333            "AND venue=? AND city=?",334            (hit["lat"], hit["lng"], r["venue"], r["city"]))335        if cur.rowcount:336            stats["groups"] += 1337            stats["events_filled"] += cur.rowcount338    con.commit()339    return stats340341342def run(con: sqlite3.Connection) -> dict:343    """Cycle annuaire complet appelé par le pipeline : apprendre (0 réseau),344    rafraîchir OSM (au plus 1 requête/30 j), géocoder le top récurrent345    (plafonné), appliquer aux événements sans GPS."""346    out = {"learned": learn(con)}347    try:348        out["osm"] = refresh_osm(con)349    except Exception as exc:            # Overpass indisponible ≠ cycle cassé350        out["osm_error"] = str(exc)[:120]351    try:352        out["nominatim"] = geocode_top(con)353    except Exception as exc:354        out["nominatim_error"] = str(exc)[:120]355    out.update(apply(con))356    return out357