SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
20.3 KB · 473 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/db.py4# Desc:   Stockage SQLite — fiches créateurs canoniques, comptes rattachés,5#         journal des synchronisations (fraîcheur §13-14, opt-out §15)6# ==============================================================================7"""Base de données Créa-Ka (SQLite, WAL).89- `creators` : une ligne = une fiche canonique (document JSON + colonnes10  indexées pour la recherche).11- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est12  l'index qui permet la déduplication inter-sources (§12.2).13- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).1415Politique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`16horodate chaque passage ; un créateur disparu passe `inactive`, un opt-out17passe `opted_out` (masqué, jamais ré-agrégé).18"""19from __future__ import annotations2021import json22import sqlite323import sys24import time25from dataclasses import asdict26from datetime import datetime, timedelta, timezone27from pathlib import Path2829from . import ethics30from .dedup import merge_creators31from .identity import needs_review32from .normalize import slugify33from .schema import Creator, PlatformAccount, now_iso3435ROOT = Path(__file__).resolve().parent.parent36DB_PATH = ROOT / "data" / "creaka.db"3738_SCHEMA = """39CREATE TABLE IF NOT EXISTS creators (40  id TEXT PRIMARY KEY,41  display_name TEXT NOT NULL,42  status TEXT NOT NULL DEFAULT 'active',43  is_minor INTEGER NOT NULL DEFAULT 0,44  region TEXT, city TEXT,45  niches TEXT, languages TEXT,46  creator_type TEXT, primary_platform TEXT,47  audience_tier TEXT, total_reach INTEGER,48  bio TEXT,49  first_seen TEXT, last_seen TEXT, updated_at TEXT,50  doc TEXT NOT NULL51);52CREATE TABLE IF NOT EXISTS accounts (53  platform TEXT NOT NULL,54  handle TEXT NOT NULL,55  creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,56  url TEXT NOT NULL,57  followers INTEGER,58  verified INTEGER,59  confidence REAL NOT NULL,60  signal TEXT,61  needs_review INTEGER NOT NULL DEFAULT 0,62  last_checked TEXT,63  metrics TEXT,64  PRIMARY KEY (platform, handle)65);66CREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);67CREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);68CREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);69CREATE TABLE IF NOT EXISTS snapshots (70  day TEXT NOT NULL,71  platform TEXT NOT NULL,72  handle TEXT NOT NULL,73  creator_id TEXT NOT NULL,74  followers INTEGER,75  engagement REAL,76  PRIMARY KEY (day, platform, handle)77);78CREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);79CREATE TABLE IF NOT EXISTS sync_log (80  id INTEGER PRIMARY KEY AUTOINCREMENT,81  ts TEXT NOT NULL,82  source TEXT NOT NULL,83  creators INTEGER, accounts INTEGER, avg_confidence REAL,84  added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,85  alert TEXT86);87"""888990def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:91    Path(path).parent.mkdir(parents=True, exist_ok=True)92    con = sqlite3.connect(path, check_same_thread=False)93    con.row_factory = sqlite3.Row94    con.execute("PRAGMA journal_mode=WAL")95    con.execute("PRAGMA foreign_keys=ON")96    con.executescript(_SCHEMA)97    # migrations légères (bases existantes)98    try:99        con.execute("ALTER TABLE accounts ADD COLUMN metrics TEXT")100    except sqlite3.OperationalError:101        pass102    return con103104105# --- (dé)sérialisation ----------------------------------------------------------106107def creator_to_doc(cr: Creator) -> str:108    return json.dumps(asdict(cr), ensure_ascii=False)109110111def creator_from_row(row: sqlite3.Row) -> Creator:112    doc = json.loads(row["doc"])113    doc["platforms"] = [PlatformAccount(**a) for a in doc.get("platforms", [])]114    cr = Creator(**doc)115    return cr116117118def _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:119    """Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1)."""120    doc = json.loads(row["doc"])121    doc["id"] = row["id"]122    doc["first_seen"] = row["first_seen"]123    doc["last_seen"] = row["last_seen"]124    doc["updated_at"] = row["updated_at"]125    doc["platforms"] = [126        {"platform": a["platform"], "handle": a["handle"], "url": a["url"],127         "followers": a["followers"],128         "verified": bool(a["verified"]) if a["verified"] is not None else None,129         "confidence": a["confidence"], "last_checked": a["last_checked"],130         "metrics": json.loads(a["metrics"]) if a["metrics"] else {}}131        for a in accounts if not a["needs_review"]]132    # champs internes / sensibles jamais exposés tels quels133    doc.pop("source", None)134    doc.pop("external_id", None)135    doc.pop("legal_name", None)136    return doc137138139# --- écriture -------------------------------------------------------------------140141def _unique_id(con: sqlite3.Connection, base: str) -> str:142    cid, n = base, 2143    while con.execute("SELECT 1 FROM creators WHERE id=?", (cid,)).fetchone():144        cid = f"{base}-{n}"145        n += 1146    return cid147148149def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,150                   first_seen: str, ts: str) -> None:151    # comptes retirés de la fiche : jamais en silence (§13 — historique) ;152    # merge_accounts fait l'union, donc une perte ici est anormale → journalisée153    old_keys = {f"{r['platform']}:{r['handle']}" for r in con.execute(154        "SELECT platform, handle FROM accounts WHERE creator_id=?", (cid,))}155    lost = old_keys - {a.key for a in cr.platforms}156    if lost:157        print(f"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : "158              f"{', '.join(sorted(lost))}", file=sys.stderr)159    con.execute(160        """INSERT INTO creators (id, display_name, status, is_minor, region, city,161             niches, languages, creator_type, primary_platform, audience_tier,162             total_reach, bio, first_seen, last_seen, updated_at, doc)163           VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)164           ON CONFLICT(id) DO UPDATE SET165             display_name=excluded.display_name, status=excluded.status,166             is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,167             niches=excluded.niches, languages=excluded.languages,168             creator_type=excluded.creator_type,169             primary_platform=excluded.primary_platform,170             audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,171             bio=excluded.bio, last_seen=excluded.last_seen,172             updated_at=excluded.updated_at, doc=excluded.doc""",173        (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,174         ",".join(cr.niches), ",".join(cr.languages), cr.creator_type,175         cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,176         first_seen, ts, ts, creator_to_doc(cr)))177    con.execute("DELETE FROM accounts WHERE creator_id=?", (cid,))178    for acc in cr.platforms:179        con.execute(180            """INSERT INTO accounts (platform, handle, creator_id, url, followers,181                 verified, confidence, signal, needs_review, last_checked, metrics)182               VALUES (?,?,?,?,?,?,?,?,?,?,?)183               ON CONFLICT(platform, handle) DO UPDATE SET184                 creator_id=excluded.creator_id, url=excluded.url,185                 followers=excluded.followers, verified=excluded.verified,186                 confidence=excluded.confidence, signal=excluded.signal,187                 needs_review=excluded.needs_review,188                 last_checked=excluded.last_checked, metrics=excluded.metrics""",189            (acc.platform, acc.handle, cid, acc.url, acc.followers,190             None if acc.verified is None else int(acc.verified),191             acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,192             json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))193        # historisation (§13-14) : un point par jour et par compte — matière194        # première des courbes de croissance et des insights (7 j / 30 j)195        if acc.followers:196            con.execute(197                """INSERT INTO snapshots (day, platform, handle, creator_id,198                     followers, engagement)199                   VALUES (?,?,?,?,?,?)200                   ON CONFLICT(day, platform, handle) DO UPDATE SET201                     creator_id=excluded.creator_id,202                     followers=excluded.followers,203                     engagement=COALESCE(excluded.engagement,204                                         snapshots.engagement)""",205                (ts[:10], acc.platform, acc.handle, cid, acc.followers,206                 (acc.metrics or {}).get("engagement_rate_pct")))207208209def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:210    """Fiche existante possédant déjà un des comptes entrants.211212    Même plateforme + même handle = MÊME compte : la table accounts n'admet213    qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se214    fait donc sur l'identité exacte du compte, sans condition de confiance —215    exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le216    2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.217    snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des218    comptes lui réassignait le compte, laissant l'originale orpheline219    (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :220    l'égalité stricte plateforme+handle identifie le compte lui-même.221    """222    for acc in cr.platforms:223        row = con.execute(224            "SELECT creator_id FROM accounts WHERE platform=? AND handle=?",225            (acc.platform, acc.handle)).fetchone()226        if row:227            return row["creator_id"]228    return None229230231def sync_source(con: sqlite3.Connection, source_id: str,232                creators: list[Creator], *, started: float | None = None,233                errors: int = 0) -> dict:234    """Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.235236    `started` (time.time() du début du passage) → durée réelle persistée dans237    sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le238    connecteur (§16 logging structuré).239    """240    ts = now_iso()241    added = updated = skipped_optout = 0242    confs: list[float] = []243    for cr in creators:244        cr = ethics.scrub(cr)245        # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer246        if ethics.is_opted_out(cr):247            skipped_optout += 1248            existing = _find_existing(con, cr)249            if existing:250                con.execute("UPDATE creators SET status='opted_out', updated_at=? "251                            "WHERE id=?", (ts, existing))252            continue253        confs.extend(a.confidence or 0 for a in cr.platforms)254        existing_id = _find_existing(con, cr)255        if existing_id:256            row = con.execute("SELECT * FROM creators WHERE id=?",257                              (existing_id,)).fetchone()258            current = creator_from_row(row)259            if current.status == "opted_out":260                continue  # masqué : on ne ré-agrège pas261            # _find_existing garantit un compte IDENTIQUE (plateforme+handle)262            # partagé : c'est le même compte, la fusion est toujours sûre —263            # pas une fusion sur le nom (§12.2)264            merged = merge_creators(current, cr)265            _write_creator(con, existing_id, merged, row["first_seen"], ts)266            updated += 1267            continue268        cid = _unique_id(con, slugify(cr.display_name))269        _write_creator(con, cid, cr, ts, ts)270        added += 1271    con.commit()272    n_acc = sum(len(c.platforms) for c in creators)273    stats = {"source": source_id, "creators": len(creators), "accounts": n_acc,274             "added": added, "updated": updated, "optout_skipped": skipped_optout,275             "avg_confidence": round(sum(confs) / len(confs), 3) if confs else None}276    alert = None277    if not creators:278        alert = "0 créateur retourné — source possiblement bloquée"279        stats["alert"] = alert280    seconds = round(time.time() - started, 1) if started else 0.0281    stats["seconds"] = seconds282    if errors:283        stats["errors"] = errors284    con.execute(285        "INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,"286        " added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)",287        (ts, source_id, len(creators), n_acc, stats["avg_confidence"],288         added, updated, errors, seconds, alert))289    con.commit()290    return stats291292293def archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:294    """Archivage des disparus (§13, politique de grâce).295296    Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages297    complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.298    Réversible : dès qu'une source le revoit, la fusion (sync_source →299    _write_creator) réécrit son statut actif et son `last_seen`.300    Les opt-out ne sont JAMAIS touchés (status != 'active').301    """302    cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \303        .strftime("%Y-%m-%dT%H:%M:%SZ")304    ts = now_iso()305    cur = con.execute(306        "UPDATE creators SET status='inactive', updated_at=? "307        "WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?",308        (ts, cutoff))309    con.commit()310    if cur.rowcount:311        print(f"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) "312              f"depuis {hours} h → inactive (réversible au retour)")313    return cur.rowcount314315316def log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:317    con.execute(318        "INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)"319        " VALUES (?,?,0,0,1,?)", (now_iso(), source_id, message[:500]))320    con.commit()321322323def apply_optout(con: sqlite3.Connection, *, name: str | None = None,324                 account: str | None = None) -> int:325    """Masque immédiatement les fiches correspondant à une demande de retrait."""326    ids: set[str] = set()327    if account and ":" in account:328        platform, handle = account.lower().split(":", 1)329        for row in con.execute(330                "SELECT creator_id FROM accounts WHERE platform=? AND handle=?",331                (platform, handle.lstrip("@"))):332            ids.add(row["creator_id"])333    if name:334        from .dedup import name_key335        for row in con.execute("SELECT id, display_name FROM creators"):336            if name_key(row["display_name"]) == name_key(name):337                ids.add(row["id"])338    ts = now_iso()339    for cid in ids:340        con.execute("UPDATE creators SET status='opted_out', updated_at=? WHERE id=?",341                    (ts, cid))342    con.commit()343    return len(ids)344345346# --- lecture (API) ----------------------------------------------------------------347348_TIERS = ("nano", "micro", "macro", "mega")349350351def search(con: sqlite3.Connection, *, q: str = "", niche: str = "",352           region: str = "", langue: str = "", plateforme: str = "",353           tier: str = "", sort: str = "reach", limit: int = 60,354           offset: int = 0) -> dict:355    """Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.356357    Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont358    exclues de l'annuaire public (prudence extrême, §15).359    """360    where = ["status='active'", "is_minor=0"]361    params: list = []362    if q:363        where.append("(lower(display_name) LIKE ? OR lower(bio) LIKE ?)")364        needle = f"%{q.lower()}%"365        params += [needle, needle]366    if niche:367        where.append("(','||niches||',') LIKE ?")368        params.append(f"%,{niche},%")369    if region:370        where.append("region=?")371        params.append(region)372    if langue:373        where.append("(','||languages||',') LIKE ?")374        params.append(f"%,{langue},%")375    if tier in _TIERS:376        where.append("audience_tier=?")377        params.append(tier)378    if plateforme:379        where.append("id IN (SELECT creator_id FROM accounts WHERE platform=? "380                     "AND needs_review=0)")381        params.append(plateforme)382    order = {"reach": "total_reach IS NULL, total_reach DESC",383             "nom": "display_name COLLATE NOCASE ASC",384             "recent": "updated_at DESC"}.get(sort, "total_reach IS NULL, total_reach DESC")385    sql_where = " AND ".join(where)386    total = con.execute(f"SELECT COUNT(*) c FROM creators WHERE {sql_where}",387                        params).fetchone()["c"]388    rows = con.execute(389        f"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?",390        params + [max(1, min(200, limit)), max(0, offset)]).fetchall()391    items = []392    for row in rows:393        accounts = con.execute(394            "SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC",395            (row["id"],)).fetchall()396        items.append(_public_dict(row, accounts))397    return {"total": total, "count": len(items), "offset": offset, "items": items}398399400def follower_history(con: sqlite3.Connection, cid: str,401                     days: int = 90) -> dict:402    """Historique d'audience d'une fiche (snapshots quotidiens, §13-14).403404    Retourne les séries par plateforme (jour → abonnés) + la série totale405    (somme des plateformes connues ce jour-là), prêtes pour une sparkline.406    """407    cutoff = (datetime.now(timezone.utc) - timedelta(days=days)) \408        .strftime("%Y-%m-%d")409    rows = con.execute(410        "SELECT day, platform, handle, followers FROM snapshots "411        "WHERE creator_id=? AND day>=? ORDER BY day", (cid, cutoff)).fetchall()412    by_platform: dict[str, dict[str, int]] = {}413    for r in rows:414        by_platform.setdefault(r["platform"], {})[r["day"]] = r["followers"]415    total: dict[str, int] = {}416    for series in by_platform.values():417        # report de la dernière valeur connue pour que la somme quotidienne418        # ne s'effondre pas quand une plateforme n'a pas de point ce jour-là419        last = None420        for day in sorted({d for s in by_platform.values() for d in s}):421            last = series.get(day, last)422            if last is not None:423                total[day] = total.get(day, 0) + last424    return {425        "platforms": {p: [{"day": d, "followers": f}426                          for d, f in sorted(s.items())]427                      for p, s in by_platform.items()},428        "total": [{"day": d, "followers": f} for d, f in sorted(total.items())],429    }430431432def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:433    row = con.execute(434        "SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0",435        (cid,)).fetchone()436    if not row:437        return None438    accounts = con.execute(439        "SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC",440        (row["id"],)).fetchall()441    return _public_dict(row, accounts)442443444def stats(con: sqlite3.Connection) -> dict:445    """Statistiques d'ensemble pour l'accueil de l'annuaire."""446    base = "FROM creators WHERE status='active' AND is_minor=0"447    n = con.execute(f"SELECT COUNT(*) c {base}").fetchone()["c"]448    n_acc = con.execute(449        "SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id"450        " WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0"451    ).fetchone()["c"]452    by_platform = {r["platform"]: r["c"] for r in con.execute(453        "SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 "454        "ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' "455        "AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC")}456    by_tier = {r["audience_tier"]: r["c"] for r in con.execute(457        f"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier")}458    niches: dict[str, int] = {}459    for row in con.execute(f"SELECT niches {base}"):460        for nch in (row["niches"] or "").split(","):461            if nch:462                niches[nch] = niches.get(nch, 0) + 1463    regions: dict[str, int] = {}464    for row in con.execute(f"SELECT region {base} AND region IS NOT NULL"):465        regions[row["region"]] = regions.get(row["region"], 0) + 1466    last_sync = con.execute(467        "SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1").fetchone()468    return {"creators": n, "accounts": n_acc, "by_platform": by_platform,469            "by_tier": by_tier,470            "by_niche": dict(sorted(niches.items(), key=lambda x: -x[1])),471            "by_region": dict(sorted(regions.items(), key=lambda x: -x[1])),472            "last_sync": last_sync["ts"] if last_sync else None}473