# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/seo.py # Desc: Référencement — SSR léger par-dessus le shell prérendu (frontend/dist) # # Principe (pattern lou-ka adapté) : le frontend est un index.html unique généré # par scripts/build_frontend.py. Ce module sert le MÊME shell pour les routes # publiques, mais remplace le bloc … du # (title, description, canonical, og:, twitter:, JSON-LD) et injecte le contenu # essentiel en HTML dans
— les moteurs voient une page complète # sans exécuter JavaScript ; le SPA, en se montant, remplace ce contenu. # Fournit aussi robots.txt et les sitemaps chunkés (accueil + 12 000+ fiches). # ============================================================================== from __future__ import annotations import html import json import re import threading from datetime import date from pathlib import Path from urllib.parse import quote from xml.sax.saxutils import escape as xml_escape from fastapi import APIRouter from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" BASE_URL = "https://www.crea-ka.com" SITE_NAME = "Créa-Ka" SITEMAP_CHUNK = 10000 # étiquettes lisibles + source d'avatar unavatar (aligné sur PLAT du frontend) PLAT_LABEL = { "instagram": "Instagram", "tiktok": "TikTok", "youtube": "YouTube", "twitch": "Twitch", "kick": "Kick", "x": "X (Twitter)", "facebook": "Facebook", "snapchat": "Snapchat", "substack": "Substack", "patreon": "Patreon", "onlyfans": "OnlyFans", "mym": "MYM", "fansly": "Fansly", "linkedin": "LinkedIn", "threads": "Threads", "spotify": "Spotify", "discord": "Discord", "podcast": "Balado", "site-web": "Site web", "autre": "Autre", } UNAVATAR = {"instagram": "instagram", "tiktok": "tiktok", "youtube": "youtube", "twitch": "twitch", "x": "twitter"} # connexion SQLite PAR THREAD (même doctrine que web.py, §18) _local = threading.local() def _db(): con = getattr(_local, "con", None) if con is None: con = _local.con = db.connect() return con def _e(t) -> str: return html.escape(str(t or ""), quote=True) # --- Gabarit (shell prérendu par scripts/build_frontend.py) ------------------- _shell_cache: dict = {"mtime": 0.0, "html": ""} def _shell() -> str: f = FRONTEND_DIST / "index.html" mtime = f.stat().st_mtime if mtime != _shell_cache["mtime"]: _shell_cache["html"] = f.read_text(encoding="utf-8") _shell_cache["mtime"] = mtime return _shell_cache["html"] def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None, body: str = "", og_image: str | None = None, og_type: str = "website", status: int = 200, noindex: bool = False) -> HTMLResponse: """Shell du build + bloc SEO unique + contenu HTML dans #app.""" canonical = BASE_URL + path img = og_image or (BASE_URL + "/og.png") lines = [ f"{html.escape(title)}", f'', ] if noindex: lines.append('') else: lines.append(f'') lines += [ f'', '', f'', f'', f'', f'', f'', ] if not og_image: lines.append('') lines.append('') lines += [ '', f'', f'', f'', ] for obj in (jsonld or []): blob = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).replace("{blob}') block = "\n" + "\n".join(lines) + "\n" page = re.sub(r".*?", lambda _m: block, _shell(), count=1, flags=re.S) if body: seo_div = ('
' + body + '

Créa-Ka — Un service ' 'Groupe KA

') page = page.replace('
', '
' + seo_div + "
", 1) return HTMLResponse(page, status_code=status, headers={"Cache-Control": "no-cache"}) def _breadcrumb(items: list[tuple[str, str]]) -> dict: return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE_URL + path} for i, (name, path) in enumerate(items)]} def _fmt_int(n) -> str: return f"{int(n):,}".replace(",", " ") if n else "0" # --- Fiches créateur ----------------------------------------------------------- def _avatar(doc: dict, accounts: list) -> str | None: """og:image STABLE : unavatar d'abord (les URL CDN captées — Instagram notamment — expirent au bout de quelques semaines), avatar capté en repli.""" for plat in ("instagram", "tiktok", "youtube", "twitch", "x"): for a in accounts: if a["platform"] == plat and plat in UNAVATAR: return (f"https://unavatar.io/{UNAVATAR[plat]}/" f"{quote(a['handle'])}") return doc.get("avatar_url") or None def _description(name: str, bio: str, accounts: list) -> str: bio = re.sub(r"\s+", " ", bio or "").strip() if len(bio) >= 60: return bio[:157] + "…" if len(bio) > 160 else bio plats = [] for a in accounts: lbl = PLAT_LABEL.get(a["platform"], a["platform"]) if lbl not in plats: plats.append(lbl) reach = sum(a["followers"] or 0 for a in accounts) parts = [f"Tous les comptes publics de {name} au même endroit"] if plats: parts[0] += f" ({', '.join(plats[:5])})" if reach: parts.append(f"{_fmt_int(reach)} abonnés cumulés") txt = " — ".join(parts) + ". Annuaire Créa-Ka des créateurs québécois." if bio: txt = bio + " " + txt return txt[:157] + "…" if len(txt) > 160 else txt def _gone(path: str) -> HTMLResponse: return _render(title="Fiche retirée | Créa-Ka", description="Cette fiche a été retirée de l'annuaire Créa-Ka.", path=path, status=410, noindex=True, body="

Fiche retirée de l'annuaire

" '

Ce créateur a demandé son retrait (opt-out) ou sa ' 'fiche n\'est plus publiée. ' 'Voir l\'annuaire des créateurs québécois

') def _not_found(path: str) -> HTMLResponse: return _render(title="Page introuvable | Créa-Ka", description="Cette page n'existe pas sur Créa-Ka.", path=path, status=404, noindex=True, body="

Fiche introuvable

" '

Voir l\'annuaire des créateurs ' 'québécois

') @router.get("/createur/{cid}", include_in_schema=False) def creator_page(cid: str) -> HTMLResponse: path = f"/createur/{quote(cid)}" con = _db() row = con.execute("SELECT * FROM creators WHERE id=?", (cid,)).fetchone() if row is None: return _not_found(path) if row["status"] != "active": return _gone(path) # retiré (opt-out) ou désactivé → 410 if row["is_minor"]: return _not_found(path) # jamais publié (§15) accounts = con.execute( "SELECT * FROM accounts WHERE creator_id=? AND needs_review=0 " "ORDER BY followers DESC", (cid,)).fetchall() doc = json.loads(row["doc"]) name = row["display_name"] primary = next((a for a in accounts if a["platform"] == row["primary_platform"]), accounts[0] if accounts else None) handle = primary["handle"] if primary else "" title = (f"{name} (@{handle}) — profils et liens | Créa-Ka" if handle else f"{name} — profils et liens | Créa-Ka") description = _description(name, doc.get("bio", ""), accounts) avatar = _avatar(doc, accounts) canonical = BASE_URL + path same_as = [a["url"] for a in accounts if a["url"]] person: dict = {"@type": "Person", "name": name, "url": canonical} if handle: person["alternateName"] = f"@{handle}" if doc.get("bio"): person["description"] = re.sub(r"\s+", " ", doc["bio"]).strip()[:500] if avatar: person["image"] = avatar if same_as: person["sameAs"] = same_as profile: dict = {"@context": "https://schema.org", "@type": "ProfilePage", "@id": canonical, "url": canonical, "name": title, "inLanguage": "fr-CA", "isPartOf": {"@id": BASE_URL + "/#website"}, "mainEntity": person} if row["updated_at"]: profile["dateModified"] = row["updated_at"] if row["first_seen"]: profile["dateCreated"] = row["first_seen"] jsonld = [profile, _breadcrumb([("Accueil", "/"), (name, path)])] items = [] for a in accounts: lbl = PLAT_LABEL.get(a["platform"], a["platform"]) extra = (f" — {_fmt_int(a['followers'])} abonnés" if a["followers"] else "") items.append(f'
  • ' f"{_e(lbl)} — @{_e(a['handle'])}{extra}
  • ") bio_html = "" if doc.get("bio"): clean_bio = re.sub(r"\s+", " ", doc["bio"]).strip() bio_html = f"

    {_e(clean_bio)}

    " body = (f"

    {_e(name)}{' (@' + _e(handle) + ')' if handle else ''}

    " + bio_html + (f"" if items else "") + '

    Annuaire des créateurs de contenu québécois

    ') return _render(title=title, description=description, path=path, jsonld=jsonld, body=body, og_image=avatar, og_type="profile") # --- Pages statiques du SPA (canonical/title corrects, sinon fallback = accueil) _STATIC_PAGES = { "/stats": ("Statistiques de l'annuaire | Créa-Ka", "Statistiques publiques de Créa-Ka : créateurs québécois " "recensés, comptes reliés, répartition par plateforme, niche, " "région et taille d'audience.", False), "/retrait": ("Retrait de fiche (opt-out) | Créa-Ka", "Demander le retrait de votre fiche de l'annuaire Créa-Ka : " "masquage immédiat et respecté (Loi 25).", False), "/contact": ("Contact | Créa-Ka", "Contacter l'équipe de Créa-Ka et du Groupe KA : projets, " "partenariats, médias, vie privée et Loi 25.", False), "/compte": ("Mon compte | Créa-Ka", "Espace compte KA ID sur Créa-Ka.", True), } for _path, (_t, _d, _noidx) in _STATIC_PAGES.items(): def _mk(p=_path, t=_t, d=_d, noidx=_noidx): def handler() -> HTMLResponse: return _render(title=t, description=d, path=p, noindex=noidx, jsonld=None if noidx else [_breadcrumb([("Accueil", "/"), (t.split(" | ")[0], p)])]) return handler router.get(_path, include_in_schema=False)(_mk()) # --- Pages programmatiques plateformes : /plateformes + /plateforme/{p} --------- # seuil d'inclusion d'une page plateforme (pages trop maigres exclues du sitemap) MIN_CREATORS_PAGE = 3 def _platform_rows() -> list[dict]: """[{platform, label, n (créateurs actifs)}] trié par volume décroissant.""" rows = _db().execute( """SELECT a.platform, COUNT(DISTINCT a.creator_id) n FROM accounts a JOIN creators c ON c.id=a.creator_id WHERE c.status='active' AND c.is_minor=0 GROUP BY a.platform ORDER BY n DESC""").fetchall() return [{"platform": r["platform"], "label": PLAT_LABEL.get(r["platform"], r["platform"]), "n": r["n"]} for r in rows if r["platform"] in PLAT_LABEL] @router.get("/plateformes", include_in_schema=False) def plateformes_page() -> HTMLResponse: plats = _platform_rows() total = sum(p["n"] for p in plats) title = (f"Créateurs de contenu québécois par plateforme " f"({len(plats)} plateformes) | Créa-Ka") description = ( f"L'annuaire Créa-Ka par plateforme : {_fmt_int(total)} présences de " f"créateurs québécois sur {len(plats)} plateformes — YouTube, " "Instagram, TikTok, Twitch, Facebook, X et plus.") body = (f"

    Créateurs québécois par plateforme — {len(plats)} " "plateformes

    Annuaire des créateurs de contenu ' "québécois

    ") jsonld = [_breadcrumb([("Accueil", "/"), ("Plateformes", "/plateformes")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Créateurs de contenu québécois par plateforme", "numberOfItems": len(plats), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": f"Créateurs québécois sur {p['label']}", "url": f"{BASE_URL}/plateforme/{p['platform']}"} for i, p in enumerate(plats)]}] return _render(title=title, description=description, path="/plateformes", jsonld=jsonld, body=body) @router.get("/plateforme/{plat}", include_in_schema=False) def plateforme_page(plat: str) -> HTMLResponse: if plat not in PLAT_LABEL: return _not_found(f"/plateforme/{plat}") label = PLAT_LABEL[plat] rows = _db().execute( """SELECT c.id, c.display_name, a.handle, a.followers FROM accounts a JOIN creators c ON c.id=a.creator_id WHERE a.platform=? AND c.status='active' AND c.is_minor=0 ORDER BY a.followers IS NULL, a.followers DESC LIMIT 100""", (plat,)).fetchall() n = _db().execute( """SELECT COUNT(DISTINCT a.creator_id) n FROM accounts a JOIN creators c ON c.id=a.creator_id WHERE a.platform=? AND c.status='active' AND c.is_minor=0""", (plat,)).fetchone()["n"] path = f"/plateforme/{plat}" if n == 0: return _not_found(path) title = f"Créateurs québécois sur {label} — {_fmt_int(n)} profils | Créa-Ka" description = ( f"{_fmt_int(n)} créateurs de contenu québécois actifs sur {label}, " "classés par audience : profils, niches, autres plateformes et " "statistiques dans l'annuaire Créa-Ka.") lis = [] for r in rows: extra = (f" — {_fmt_int(r['followers'])} abonnés" if r["followers"] else "") handle = f" (@{_e(r['handle'])})" if r["handle"] else "" lis.append(f'
  • ' f'{_e(r["display_name"])}{handle}{extra}
  • ') others = [p for p in _platform_rows() if p["platform"] != plat][:12] body = (f"

    Créateurs québécois sur {_e(label)} — {_fmt_int(n)} " "profils

    " f"

    Les créateurs de contenu québécois présents sur {_e(label)}, " "classés par taille d'audience. Chaque fiche relie tous les " "comptes du créateur sur les autres plateformes.

    " "

    Profils

    " + ("

    Autres plateformes

    " if others else "") + '

    Toutes les plateformes · ' 'Annuaire des créateurs québécois

    ') crumbs = [("Accueil", "/"), ("Plateformes", "/plateformes"), (label, path)] jsonld = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": f"Créateurs québécois sur {label}", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["display_name"], "url": f"{BASE_URL}/createur/{quote(r['id'])}"} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body=body) # --- robots.txt & sitemaps ------------------------------------------------------- @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Disallow: /api/\n" "Disallow: /compte\n" "Allow: /\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(body: str) -> Response: return Response('\n' + body, media_type="application/xml", headers={"Cache-Control": "max-age=3600"}) def _urlset(urls: list[tuple[str, str | None]]) -> Response: rows = [] for loc, lastmod in urls: lm = f"{lastmod}" if lastmod else "" rows.append(f"{xml_escape(loc)}{lm}") return _xml('\n' + "\n".join(rows) + "\n") def _active_count() -> int: return _db().execute("SELECT COUNT(*) c FROM creators " "WHERE status='active' AND is_minor=0").fetchone()["c"] @router.get("/sitemap.xml", include_in_schema=False) def sitemap_index() -> Response: chunks = max(1, -(-_active_count() // SITEMAP_CHUNK)) names = ["sitemap-pages.xml"] + [f"sitemap-createurs-{i + 1}.xml" for i in range(chunks)] today = date.today().isoformat() rows = [f"{BASE_URL}/{n}{today}" "" for n in names] return _xml('\n' + "\n".join(rows) + "\n") @router.get("/sitemap-pages.xml", include_in_schema=False) def sitemap_pages() -> Response: last = _db().execute("SELECT MAX(updated_at) m FROM creators " "WHERE status='active'").fetchone()["m"] lm = (last or "")[:10] or None urls = [(BASE_URL + "/", lm), (BASE_URL + "/plateformes", lm), (BASE_URL + "/stats", lm), (BASE_URL + "/retrait", None), (BASE_URL + "/contact", None)] urls += [(f"{BASE_URL}/plateforme/{p['platform']}", lm) for p in _platform_rows() if p["n"] >= MIN_CREATORS_PAGE] return _urlset(urls) @router.get("/sitemap-createurs-{n}.xml", include_in_schema=False) def sitemap_creators(n: int) -> Response: if n < 1: return _xml('' "") rows = _db().execute( "SELECT id, updated_at FROM creators WHERE status='active' AND " "is_minor=0 ORDER BY id LIMIT ? OFFSET ?", (SITEMAP_CHUNK, (n - 1) * SITEMAP_CHUNK)).fetchall() return _urlset([(f"{BASE_URL}/createur/{quote(r['id'])}", (r["updated_at"] or "")[:10] or None) for r in rows])