Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/web.py4# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —5# annuaire cherchable par niche, région, langue, plateforme, taille6# ==============================================================================7from __future__ import annotations89import json10import threading11from pathlib import Path1213from fastapi import Body, FastAPI, HTTPException, Query, Request14from fastapi.middleware.cors import CORSMiddleware15from fastapi.middleware.gzip import GZipMiddleware16from fastapi.responses import FileResponse, RedirectResponse, Response17from pydantic import BaseModel, Field1819from . import auth, db, ethics, hubfav, kaid, kapdf, seo20from . import stats as stats_mod21from .normalize import NICHES, PLATFORMS, REGIONS2223ROOT = Path(__file__).resolve().parent.parent24FRONTEND_DIST = ROOT / "frontend" / "dist"25SOURCES_PATH = ROOT / "data" / "sources.json"2627app = FastAPI(title="Créa-Ka API", version="1.0",28 description="Annuaire public des créateurs de contenu québécois "29 "— tous leurs comptes, toutes leurs plateformes.")30app.add_middleware(CORSMiddleware, allow_origins=["*"],31 allow_methods=["*"], allow_headers=["*"])32app.add_middleware(GZipMiddleware, minimum_size=1000)3334# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans35# un pool de threads — une connexion partagée (check_same_thread=False) fait36# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'37# object is not subscriptable »). threading.local = une connexion par thread.38_local = threading.local()394041def _db():42 con = getattr(_local, "con", None)43 if con is None:44 con = _local.con = db.connect()45 return con4647# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py48app.include_router(auth.router)4950# personnalisation KA ID v2 (creaka/kaid.py, canonique ka-ui.git/kaid)51kaid.init("crea-ka")52app.include_router(kaid.build_router(auth.current_user))535455def _kaid_features(d: dict) -> dict:56 """Caractéristiques d'un créateur pour le profil de préférences KA ID."""57 return {k: v for k, v in {58 "niche": d.get("niches") or None,59 "region": d.get("region"), "city": d.get("city"),60 "langue": d.get("languages") or None,61 "tier": d.get("audience_tier"),62 "platform": d.get("platforms") or d.get("primary_platform") or None,63 "creator_type": d.get("creator_type"),64 }.items() if v not in (None, "", [])}656667@app.get("/healthz")68def healthz():69 return {"ok": True}707172@app.get("/api/health")73def health():74 s = db.stats(_db())75 return {"ok": True, "creators": s["creators"], "accounts": s["accounts"],76 "last_sync": s["last_sync"]}777879@app.get("/api/creators")80def list_creators(request: Request,81 q: str = "", niche: str = "", region: str = "",82 langue: str = "", plateforme: str = "", tier: str = "",83 sort: str = "reach",84 limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):85 out = db.search(_db(), q=q.strip(), niche=niche, region=region,86 langue=langue, plateforme=plateforme, tier=tier,87 sort=sort, limit=limit, offset=offset)88 # personnalisation KA ID : journal + reclassement (tri par défaut « reach »89 # seulement ; intention de session > préférences long terme)90 out["personalized"] = False91 user = auth.current_user(request)92 if user:93 filters = {k: v for k, v in {94 "niche": niche, "region": region, "langue": langue,95 "platform": plateforme, "tier": tier, "q": q.strip()}.items() if v}96 if filters and offset == 0:97 kaid.track(user, "search", query=q.strip() or None, filters=filters)98 if sort == "reach":99 active = {k for k in ("niche", "region", "langue", "platform",100 "tier") if filters.get(k)}101 out["items"], out["personalized"] = kaid.rerank(102 out["items"], user, features_of=_kaid_features,103 uid_of=lambda it: it.get("id"), active_dims=active)104 return out105106107@app.get("/api/creators/{cid}")108def get_creator(cid: str, request: Request):109 creator = db.get_creator(_db(), cid)110 if creator is None:111 raise HTTPException(404, "créateur introuvable")112 # insights « légendaires » : croissance (snapshots), engagement pondéré,113 # rythme de publication, Ka Score /100 + historique pour la sparkline114 creator["insights"] = stats_mod.creator_insights(_db(), creator)115 kaid.track(auth.current_user(request), "detail_view", entity_type="creator",116 entity_id=cid, features=_kaid_features(creator))117 return creator118119120@app.get("/api/stats")121def get_stats():122 return db.stats(_db())123124125_PERIODS_OK = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}126127128@app.get("/api/stats/dashboard")129def stats_dashboard(period: str = "30j",130 date_from: str = Query("", alias="from"),131 date_to: str = Query("", alias="to")):132 """Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min."""133 if period not in _PERIODS_OK and not (date_from and date_to):134 raise HTTPException(400, "période inconnue "135 "(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)")136 return stats_mod.dashboard(_db(), period=period,137 date_from=date_from, date_to=date_to)138139140@app.get("/api/stats/report")141def stats_report(period: str = "30j", mode: str = "complet",142 date_from: str = Query("", alias="from"),143 date_to: str = Query("", alias="to")):144 """Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :145 complet | synthese | tendances | repartitions | donnees.146 Un mode inconnu retombe sur `complet` (rétrocompatible v1)."""147 if mode not in kapdf.REPORT_MODES:148 mode = "complet"149 if period not in _PERIODS_OK and not (date_from and date_to):150 raise HTTPException(400, "période inconnue")151 dash = stats_mod.dashboard(_db(), period=period,152 date_from=date_from, date_to=date_to)153 pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),154 dashboard=dash, mode=mode).build()155 fname = kapdf.filename("crea-ka", period, mode)156 return Response(content=pdf, media_type="application/pdf",157 headers={"Content-Disposition":158 f'attachment; filename="{fname}"'})159160161@app.get("/api/stats/catalog")162def stats_catalog(period: str = "30j",163 date_from: str = Query("", alias="from"),164 date_to: str = Query("", alias="to")):165 """v3 — blocs composables pour le constructeur de rapports personnalisés."""166 if period not in _PERIODS_OK and not (date_from and date_to):167 raise HTTPException(400, "période inconnue")168 dash = stats_mod.dashboard(_db(), period=period,169 date_from=date_from, date_to=date_to)170 return {"updated": dash.get("updated"), "period": dash.get("period"),171 "blocks": kapdf.catalog(dash)}172173174@app.post("/api/stats/report/custom")175def stats_report_custom(spec: dict = Body(...)):176 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",177 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""178 period = str(spec.get("period") or "30j")179 date_from = str(spec.get("from") or "")180 date_to = str(spec.get("to") or "")181 if period not in _PERIODS_OK and not (date_from and date_to):182 period = "30j"183 dash = stats_mod.dashboard(_db(), period=period,184 date_from=date_from, date_to=date_to)185 known = {b["key"] for b in kapdf.catalog(dash)}186 blocks = [b for b in (spec.get("blocks") or [])187 if isinstance(b, dict) and b.get("key") in known][:40]188 if not blocks:189 raise HTTPException(400, "Aucun bloc valide dans la composition")190 pdf = kapdf.GroupeKAReport(191 site=stats_mod.site_info(), dashboard=dash, mode=kapdf.CUSTOM_MODE,192 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},193 ).build()194 fname = kapdf.filename("crea-ka", period, kapdf.CUSTOM_MODE)195 return Response(content=pdf, media_type="application/pdf",196 headers={"Content-Disposition":197 f'attachment; filename="{fname}"'})198199200@app.get("/api/taxonomies")201def taxonomies():202 return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS),203 "regions": sorted(REGIONS),204 "tiers": ["nano", "micro", "macro", "mega"],205 "langues": ["fr", "en", "bilingue"]}206207208@app.get("/api/sources")209def sources():210 if SOURCES_PATH.exists():211 return json.loads(SOURCES_PATH.read_text(encoding="utf-8"))212 return {"sources": []}213214215class OptOutRequest(BaseModel):216 """Demande de retrait (§15 — opt-out obligatoire et respecté)."""217 name: str = Field("", max_length=200)218 account: str = Field("", max_length=200,219 description="plateforme:handle, ex. instagram:monnom")220 reason: str = Field("", max_length=500)221 contact: str = Field("", max_length=200)222223224@app.post("/api/optout")225def optout(req: OptOutRequest):226 """Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.227228 La vie privée prime sur la complétude (§15) : on masque d'abord, la229 demande est journalisée pour vérification humaine ensuite.230 """231 name = req.name.strip() or None232 acc = req.account.strip().lower() or None233 if not name and not acc:234 raise HTTPException(400, "préciser un nom ou un compte (plateforme:handle)")235 ethics.add_optout(name=name, account=acc, reason=req.reason,236 contact=req.contact)237 masked = db.apply_optout(_db(), name=name, account=acc)238 return {"ok": True, "masked": masked,239 "message": "Fiche masquée. La demande sera vérifiée ; merci."}240241242# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----243244@app.get("/api/favorites")245def favorites(request: Request):246 """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""247 user = auth.current_user(request)248 if not user:249 raise HTTPException(401, "Connexion KA ID requise")250 items = hubfav.hub_list(user.get("ka_id") or "")251 if items is None:252 raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")253 return {"ids": [i["item_id"] for i in items if i.get("item_id")],254 "items": items}255256257@app.post("/api/favorites/toggle")258def toggle_favorite(request: Request, body: dict = Body(...)):259 """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub260 Groupe KA de façon synchrone : le hub est la seule source de vérité."""261 user = auth.current_user(request)262 if not user:263 raise HTTPException(401, "Connexion KA ID requise")264 ka_id = user.get("ka_id") or ""265 if not hubfav.linked(ka_id):266 raise HTTPException(403, "Compte non relié au hub Groupe KA")267 on = bool(body.get("on"))268 item = hubfav.clean_item(body.get("item") or {})269 if not item.get("item_id"):270 raise HTTPException(422, "item.item_id requis")271 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):272 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")273 # signal fort du moteur de préférences KA ID274 creator = db.get_creator(_db(), item["item_id"])275 kaid.track(user, "favorite" if on else "unfavorite", entity_type="creator",276 entity_id=item["item_id"],277 features=_kaid_features(creator) if creator else None)278 return {"ok": True, "on": on}279280281# SEO : fiches créateur SSR, robots.txt, sitemaps — déclaré AVANT282# les routes statiques pour prendre la main sur le fallback SPA283app.include_router(seo.router)284285286# --- frontend statique -----------------------------------------------------------287288# Documentation utilisateur (frontend/dist/doc) — routes explicites AVANT289# le fallback SPA pour servir /doc/ comme un index.290@app.get("/doc")291def doc_redirect():292 return RedirectResponse("/doc/", status_code=308)293294295@app.get("/doc/")296def doc_index():297 return FileResponse(FRONTEND_DIST / "doc" / "index.html")298299300@app.get("/")301def index():302 idx = FRONTEND_DIST / "index.html"303 if idx.exists():304 return FileResponse(idx)305 raise HTTPException(404, "frontend non construit")306307308@app.get("/{path:path}")309def static_files(path: str):310 target = (FRONTEND_DIST / path).resolve()311 if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):312 return FileResponse(target)313 idx = FRONTEND_DIST / "index.html"314 if idx.exists():315 return FileResponse(idx) # SPA fallback316 raise HTTPException(404, "introuvable")317