# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/ethics.py # Desc: Vie privée & éthique — garde anti-données-privées, opt-out persistant, # régime restreint des mineurs (CLAUDE.md §15 — PRIME SUR TOUT) # ============================================================================== """Contrôles éthiques appliqués à CHAQUE fiche avant stockage. - `scrub()` : purge toute donnée d'apparence privée (téléphones, adresses civiques, courriels non déclarés comme professionnels publics). - Opt-out : liste de suppression persistante (data/optout.json). Une fiche opted_out est masquée et EXCLUE des ré-agrégations futures. - Mineurs : régime restreint (pas de contact, pas de localisation précise). """ from __future__ import annotations import json import re import threading from pathlib import Path from .dedup import name_key from .schema import Creator ROOT = Path(__file__).resolve().parent.parent OPTOUT_PATH = ROOT / "data" / "optout.json" OPTOUT_REQUESTS_PATH = ROOT / "data" / "optout_requests.jsonl" _lock = threading.Lock() # téléphone nord-américain / adresse civique — n'ont RIEN à faire dans une bio _PHONE_RE = re.compile(r"\(?\b\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}\b") _CIVIC_RE = re.compile( r"\b\d{1,5},?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|app\.?|apt\.?)\s", re.I) _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+") # domaines de courriel personnel : jamais conservés même si trouvés en clair _PERSONAL_EMAIL_DOMAINS = ("gmail.", "hotmail.", "outlook.", "yahoo.", "icloud.", "videotron.", "live.", "protonmail.", "me.com") def scrub(creator: Creator) -> Creator: """Purge les données privées d'une fiche (bio, notes, contact). Idempotent.""" for field_ in ("bio", "notes"): text = getattr(creator, field_) or "" text = _PHONE_RE.sub("[retiré]", text) text = _CIVIC_RE.sub("[retiré] ", text) text = _EMAIL_RE.sub("[retiré]", text) setattr(creator, field_, text) # business_contact : uniquement un courriel PRO affiché publiquement. # Un domaine grand public (gmail, hotmail…) est traité comme personnel → retiré. bc = creator.business_contact if bc: bc = bc.strip().lower() if not _EMAIL_RE.fullmatch(bc) or any(d in bc for d in _PERSONAL_EMAIL_DOMAINS): creator.business_contact = None # régime restreint des mineurs (§15) if creator.is_minor: creator.business_contact = None creator.city = None creator.region = None creator.legal_name = None return creator # --- opt-out ------------------------------------------------------------------- def _load_optout() -> dict: if OPTOUT_PATH.exists(): try: return json.loads(OPTOUT_PATH.read_text(encoding="utf-8")) except Exception: pass return {"names": [], "accounts": []} def optout_keys() -> tuple[set[str], set[str]]: """(clés de noms, clés de comptes plateforme:handle) de la liste de suppression.""" data = _load_optout() names = {name_key(n) for n in data.get("names", []) if n} accounts = {a.strip().lower() for a in data.get("accounts", []) if a} return names, accounts def is_opted_out(creator: Creator) -> bool: """Vrai si la fiche correspond à la liste de suppression persistante. Correspondance par COMPTE (plateforme:handle — fiable) ou par nom exact (filet de sécurité : on préfère masquer trop que pas assez, §15). """ names, accounts = optout_keys() if name_key(creator.display_name) in names: return True return any(acc.key in accounts for acc in creator.platforms) def add_optout(name: str | None = None, account: str | None = None, reason: str = "", contact: str = "") -> None: """Ajoute une entrée à la liste de suppression + journalise la demande.""" with _lock: data = _load_optout() if name and name_key(name) not in {name_key(n) for n in data["names"]}: data["names"].append(name) if account: account = account.strip().lower() if account not in data["accounts"]: data["accounts"].append(account) OPTOUT_PATH.parent.mkdir(parents=True, exist_ok=True) OPTOUT_PATH.write_text( json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") from .schema import now_iso with OPTOUT_REQUESTS_PATH.open("a", encoding="utf-8") as fh: fh.write(json.dumps({"ts": now_iso(), "name": name, "account": account, "reason": reason[:500], "contact": contact[:200]}, ensure_ascii=False) + "\n")