SPB Git

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.

8.8 KB · 217 lines python
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# db.py : persistance SQLite — upsert avec détection de changements,5#         cycle de vie avec délai de grâce (2 syncs), détection de dérive,6#         déduplication inter-sources par empreinte (patron Lou-Ka : louka/db.py)7# -----------------------------------------------------------------------------8from __future__ import annotations910import json11import sqlite312import statistics13import time14from pathlib import Path1516from .schema import Event1718DB_PATH = Path(__file__).resolve().parent.parent / "data" / "sortika.db"1920# Nombre d'exécutions consécutives où un événement doit être absent de la21# source avant d'être désactivé (délai de grâce contre les ratés ponctuels).22MISS_GRACE = 22324# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique25# (médiane >= DRIFT_MIN_BASE événements), on alerte et on suspend les retraits.26DRIFT_RATIO = 0.2527DRIFT_MIN_BASE = 828DRIFT_HISTORY = 52930_SCHEMA = """31CREATE TABLE IF NOT EXISTS events (32    uid           TEXT PRIMARY KEY,33    source        TEXT NOT NULL,34    external_id   TEXT NOT NULL,35    url           TEXT,36    title         TEXT,37    description   TEXT,38    categories    TEXT,   -- JSON (taxonomie canonique)39    raw_categories TEXT,  -- JSON (libellés source)40    audience      TEXT,41    venue         TEXT,42    address       TEXT,43    city          TEXT,44    region        TEXT,45    tourist_region TEXT,46    postal_code   TEXT,47    lat           REAL,48    lng           REAL,49    start_date    TEXT,50    end_date      TEXT,51    is_free       INTEGER,          -- 1 gratuit / 0 payant / NULL inconnu52    price_min     REAL,53    price_label   TEXT,54    organizer     TEXT,55    website       TEXT,56    image         TEXT,57    dedup_key     TEXT,58    content_hash  TEXT,59    first_seen    REAL,60    last_seen     REAL,61    updated_at    REAL,62    miss_count    INTEGER DEFAULT 0,63    active        INTEGER DEFAULT 164);65CREATE INDEX IF NOT EXISTS idx_events_source  ON events(source);66CREATE INDEX IF NOT EXISTS idx_events_active  ON events(active);67CREATE INDEX IF NOT EXISTS idx_events_region  ON events(region);68CREATE INDEX IF NOT EXISTS idx_events_start   ON events(start_date);69CREATE INDEX IF NOT EXISTS idx_events_dedup   ON events(dedup_key);7071CREATE TABLE IF NOT EXISTS users (72    id          INTEGER PRIMARY KEY AUTOINCREMENT,73    hub_sub     TEXT UNIQUE,          -- clé stable du hub KA ("ka:<sub>")74    ka_id       TEXT UNIQUE,          -- identifiant membre du groupe (hub)75    email       TEXT,76    name        TEXT,77    picture     TEXT,78    created_at  REAL,79    last_login  REAL80);8182CREATE TABLE IF NOT EXISTS sync_log (83    id        INTEGER PRIMARY KEY AUTOINCREMENT,84    source    TEXT NOT NULL,85    ts        REAL NOT NULL,86    fetched   INTEGER,87    added     INTEGER,88    updated   INTEGER,89    removed   INTEGER,90    error     TEXT91);92"""939495def connect(path: Path | str | None = None) -> sqlite3.Connection:96    p = Path(path) if path else DB_PATH97    p.parent.mkdir(parents=True, exist_ok=True)98    con = sqlite3.connect(p)99    con.row_factory = sqlite3.Row100    # plusieurs connecteurs peuvent synchroniser en parallèle (backfill) :101    # attendre le verrou plutôt que d'échouer, et journal WAL pour les lecteurs102    con.execute("PRAGMA busy_timeout=60000")103    try:104        con.execute("PRAGMA journal_mode=WAL")105    except sqlite3.OperationalError:106        pass107    con.executescript(_SCHEMA)108    return con109110111def _event_row(ev: Event, now: float) -> dict:112    return {113        "uid": ev.uid, "source": ev.source, "external_id": ev.external_id,114        "url": ev.url, "title": ev.title, "description": ev.description,115        "categories": json.dumps(ev.categories, ensure_ascii=False),116        "raw_categories": json.dumps(ev.raw_categories, ensure_ascii=False),117        "audience": ev.audience, "venue": ev.venue, "address": ev.address,118        "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region,119        "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng,120        "start_date": ev.start_date, "end_date": ev.end_date,121        "is_free": (None if ev.is_free is None else int(ev.is_free)),122        "price_min": ev.price_min, "price_label": ev.price_label,123        "organizer": ev.organizer, "website": ev.website, "image": ev.image,124        "dedup_key": ev.dedup_key(), "content_hash": ev.content_hash(),125        "now": now,126    }127128129def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> dict:130    """Synchronise une source : ajouts / mises à jour / retraits avec grâce."""131    now = time.time()132    stats = {"source": source, "fetched": len(events),133             "added": 0, "updated": 0, "unchanged": 0, "removed": 0}134    alert = None135136    # Dérive : volume anormalement bas → on suspend les retraits.137    hist = [r["fetched"] for r in con.execute(138        "SELECT fetched FROM sync_log WHERE source=? AND error IS NULL "139        "ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY))]140    if hist:141        med = statistics.median(hist)142        if med >= DRIFT_MIN_BASE and len(events) <= med * DRIFT_RATIO:143            alert = (f"dérive : {len(events)} événements reçus vs médiane {med:.0f} "144                     f"— retraits suspendus (connecteur probablement cassé)")145146    seen: set[str] = set()147    for ev in events:148        row = _event_row(ev, now)149        seen.add(ev.uid)150        cur = con.execute("SELECT content_hash FROM events WHERE uid=?", (ev.uid,))151        old = cur.fetchone()152        if old is None:153            con.execute(154                """INSERT INTO events (uid, source, external_id, url, title,155                   description, categories, raw_categories, audience, venue,156                   address, city, region, tourist_region, postal_code, lat, lng,157                   start_date, end_date, is_free, price_min, price_label,158                   organizer, website, image, dedup_key, content_hash,159                   first_seen, last_seen, updated_at, miss_count, active)160                   VALUES (:uid,:source,:external_id,:url,:title,:description,161                   :categories,:raw_categories,:audience,:venue,:address,:city,162                   :region,:tourist_region,:postal_code,:lat,:lng,:start_date,163                   :end_date,:is_free,:price_min,:price_label,:organizer,164                   :website,:image,:dedup_key,:content_hash,165                   :now,:now,:now,0,1)""", row)166            stats["added"] += 1167        elif old["content_hash"] != row["content_hash"]:168            con.execute(169                """UPDATE events SET url=:url, title=:title,170                   description=:description, categories=:categories,171                   raw_categories=:raw_categories, audience=:audience,172                   venue=:venue, address=:address, city=:city, region=:region,173                   tourist_region=:tourist_region, postal_code=:postal_code,174                   lat=:lat, lng=:lng, start_date=:start_date,175                   end_date=:end_date, is_free=:is_free, price_min=:price_min,176                   price_label=:price_label, organizer=:organizer,177                   website=:website, image=:image, dedup_key=:dedup_key,178                   content_hash=:content_hash, last_seen=:now, updated_at=:now,179                   miss_count=0, active=1 WHERE uid=:uid""", row)180            stats["updated"] += 1181        else:182            con.execute("UPDATE events SET last_seen=?, miss_count=0, active=1 "183                        "WHERE uid=?", (now, ev.uid))184            stats["unchanged"] += 1185186    # Retraits (avec délai de grâce), sauf en cas de dérive détectée.187    if not alert:188        for r in con.execute(189                "SELECT uid, miss_count FROM events WHERE source=? AND active=1",190                (source,)):191            if r["uid"] in seen:192                continue193            miss = r["miss_count"] + 1194            if miss >= MISS_GRACE:195                con.execute("UPDATE events SET active=0, miss_count=?, "196                            "updated_at=? WHERE uid=?", (miss, now, r["uid"]))197                stats["removed"] += 1198            else:199                con.execute("UPDATE events SET miss_count=? WHERE uid=?",200                            (miss, r["uid"]))201202    con.execute("INSERT INTO sync_log (source, ts, fetched, added, updated, "203                "removed, error) VALUES (?,?,?,?,?,?,NULL)",204                (source, now, stats["fetched"], stats["added"],205                 stats["updated"], stats["removed"]))206    con.commit()207    if alert:208        stats["alert"] = alert209    return stats210211212def log_failure(con: sqlite3.Connection, source: str, error: str) -> None:213    con.execute("INSERT INTO sync_log (source, ts, fetched, added, updated, "214                "removed, error) VALUES (?,?,0,0,0,0,?)",215                (source, time.time(), error[:500]))216    con.commit()217
Files · 1ca59aef2013d5ac6c8c0aaff0e8e32e697d8059
Menu

Theme