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%
4.7 KB · 115 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/ethics.py4# Desc:   Vie privée & éthique — garde anti-données-privées, opt-out persistant,5#         régime restreint des mineurs (CLAUDE.md §15 — PRIME SUR TOUT)6# ==============================================================================7"""Contrôles éthiques appliqués à CHAQUE fiche avant stockage.89- `scrub()` : purge toute donnée d'apparence privée (téléphones, adresses10  civiques, courriels non déclarés comme professionnels publics).11- Opt-out : liste de suppression persistante (data/optout.json). Une fiche12  opted_out est masquée et EXCLUE des ré-agrégations futures.13- Mineurs : régime restreint (pas de contact, pas de localisation précise).14"""15from __future__ import annotations1617import json18import re19import threading20from pathlib import Path2122from .dedup import name_key23from .schema import Creator2425ROOT = Path(__file__).resolve().parent.parent26OPTOUT_PATH = ROOT / "data" / "optout.json"27OPTOUT_REQUESTS_PATH = ROOT / "data" / "optout_requests.jsonl"2829_lock = threading.Lock()3031# téléphone nord-américain / adresse civique — n'ont RIEN à faire dans une bio32_PHONE_RE = re.compile(r"\(?\b\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}\b")33_CIVIC_RE = re.compile(34    r"\b\d{1,5},?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|app\.?|apt\.?)\s", re.I)35_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")36# domaines de courriel personnel : jamais conservés même si trouvés en clair37_PERSONAL_EMAIL_DOMAINS = ("gmail.", "hotmail.", "outlook.", "yahoo.", "icloud.",38                           "videotron.", "live.", "protonmail.", "me.com")394041def scrub(creator: Creator) -> Creator:42    """Purge les données privées d'une fiche (bio, notes, contact). Idempotent."""43    for field_ in ("bio", "notes"):44        text = getattr(creator, field_) or ""45        text = _PHONE_RE.sub("[retiré]", text)46        text = _CIVIC_RE.sub("[retiré] ", text)47        text = _EMAIL_RE.sub("[retiré]", text)48        setattr(creator, field_, text)49    # business_contact : uniquement un courriel PRO affiché publiquement.50    # Un domaine grand public (gmail, hotmail…) est traité comme personnel → retiré.51    bc = creator.business_contact52    if bc:53        bc = bc.strip().lower()54        if not _EMAIL_RE.fullmatch(bc) or any(d in bc for d in _PERSONAL_EMAIL_DOMAINS):55            creator.business_contact = None56    # régime restreint des mineurs (§15)57    if creator.is_minor:58        creator.business_contact = None59        creator.city = None60        creator.region = None61        creator.legal_name = None62    return creator636465# --- opt-out -------------------------------------------------------------------6667def _load_optout() -> dict:68    if OPTOUT_PATH.exists():69        try:70            return json.loads(OPTOUT_PATH.read_text(encoding="utf-8"))71        except Exception:72            pass73    return {"names": [], "accounts": []}747576def optout_keys() -> tuple[set[str], set[str]]:77    """(clés de noms, clés de comptes plateforme:handle) de la liste de suppression."""78    data = _load_optout()79    names = {name_key(n) for n in data.get("names", []) if n}80    accounts = {a.strip().lower() for a in data.get("accounts", []) if a}81    return names, accounts828384def is_opted_out(creator: Creator) -> bool:85    """Vrai si la fiche correspond à la liste de suppression persistante.8687    Correspondance par COMPTE (plateforme:handle — fiable) ou par nom exact88    (filet de sécurité : on préfère masquer trop que pas assez, §15).89    """90    names, accounts = optout_keys()91    if name_key(creator.display_name) in names:92        return True93    return any(acc.key in accounts for acc in creator.platforms)949596def add_optout(name: str | None = None, account: str | None = None,97               reason: str = "", contact: str = "") -> None:98    """Ajoute une entrée à la liste de suppression + journalise la demande."""99    with _lock:100        data = _load_optout()101        if name and name_key(name) not in {name_key(n) for n in data["names"]}:102            data["names"].append(name)103        if account:104            account = account.strip().lower()105            if account not in data["accounts"]:106                data["accounts"].append(account)107        OPTOUT_PATH.parent.mkdir(parents=True, exist_ok=True)108        OPTOUT_PATH.write_text(109            json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")110        from .schema import now_iso111        with OPTOUT_REQUESTS_PATH.open("a", encoding="utf-8") as fh:112            fh.write(json.dumps({"ts": now_iso(), "name": name, "account": account,113                                 "reason": reason[:500], "contact": contact[:200]},114                                ensure_ascii=False) + "\n")115