Commit
SEO : SSR léger des fiches créateur + head complet + robots/sitemaps
- creaka/seo.py (nouveau, pattern lou-ka) : routes /createur/{id} servies
avec head unique (title « {nom} (@{pseudo}) — profils et liens | Créa-Ka »,
description dérivée de la bio, canonical, OG complet dont og:type profile
et og:image = avatar unavatar stable) + JSON-LD ProfilePage/Person avec
sameAs = toutes les URL de plateformes du créateur + BreadcrumbList, et
contenu essentiel injecté dans #app pour les bots ; 404 fiches inconnues,
410 fiches retirées/opt-out ; pages /stats /retrait /contact avec canonical
propre, /compte en noindex ; robots.txt (Disallow /api/ et /compte,
ligne Sitemap) ; sitemap index + chunks de 10 000 (12 827 fiches, lastmod)
- gabarit : lang fr-CA, bloc <head> SEO entre marqueurs KA:SEO (canonical,
og:url/locale/type/site_name, twitter:title/description, JSON-LD WebSite
avec SearchAction + Organization), titre descriptif, lecture de ?q= par le
routeur pour rendre la SearchAction réelle
- web.py : app.include_router(seo.router) avant le fallback SPA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
added
creaka/seo.py
+358 −0
@@ -0,0 +1,358 @@ | |||
| 1 | +# ============================================================================== | ||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||
| 3 | +# File: creaka/seo.py | ||
| 4 | +# Desc: Référencement — SSR léger par-dessus le shell prérendu (frontend/dist) | ||
| 5 | +# | ||
| 6 | +# Principe (pattern lou-ka adapté) : le frontend est un index.html unique généré | ||
| 7 | +# par scripts/build_frontend.py. Ce module sert le MÊME shell pour les routes | ||
| 8 | +# publiques, mais remplace le bloc <!--KA:SEO--> … <!--/KA:SEO--> du <head> | ||
| 9 | +# (title, description, canonical, og:, twitter:, JSON-LD) et injecte le contenu | ||
| 10 | +# essentiel en HTML dans <div id="app"> — les moteurs voient une page complète | ||
| 11 | +# sans exécuter JavaScript ; le SPA, en se montant, remplace ce contenu. | ||
| 12 | +# Fournit aussi robots.txt et les sitemaps chunkés (accueil + 12 000+ fiches). | ||
| 13 | +# ============================================================================== | ||
| 14 | +from __future__ import annotations | ||
| 15 | + | ||
| 16 | +import html | ||
| 17 | +import json | ||
| 18 | +import re | ||
| 19 | +import threading | ||
| 20 | +from datetime import date | ||
| 21 | +from pathlib import Path | ||
| 22 | +from urllib.parse import quote | ||
| 23 | +from xml.sax.saxutils import escape as xml_escape | ||
| 24 | + | ||
| 25 | +from fastapi import APIRouter | ||
| 26 | +from fastapi.responses import HTMLResponse, PlainTextResponse, Response | ||
| 27 | + | ||
| 28 | +from . import db | ||
| 29 | + | ||
| 30 | +router = APIRouter() | ||
| 31 | + | ||
| 32 | +ROOT = Path(__file__).resolve().parent.parent | ||
| 33 | +FRONTEND_DIST = ROOT / "frontend" / "dist" | ||
| 34 | + | ||
| 35 | +BASE_URL = "https://www.crea-ka.com" | ||
| 36 | +SITE_NAME = "Créa-Ka" | ||
| 37 | +SITEMAP_CHUNK = 10000 | ||
| 38 | + | ||
| 39 | +# étiquettes lisibles + source d'avatar unavatar (aligné sur PLAT du frontend) | ||
| 40 | +PLAT_LABEL = { | ||
| 41 | + "instagram": "Instagram", "tiktok": "TikTok", "youtube": "YouTube", | ||
| 42 | + "twitch": "Twitch", "kick": "Kick", "x": "X (Twitter)", | ||
| 43 | + "facebook": "Facebook", "snapchat": "Snapchat", "substack": "Substack", | ||
| 44 | + "patreon": "Patreon", "onlyfans": "OnlyFans", "mym": "MYM", | ||
| 45 | + "fansly": "Fansly", "linkedin": "LinkedIn", "threads": "Threads", | ||
| 46 | + "spotify": "Spotify", "discord": "Discord", "podcast": "Balado", | ||
| 47 | + "site-web": "Site web", "autre": "Autre", | ||
| 48 | +} | ||
| 49 | +UNAVATAR = {"instagram": "instagram", "tiktok": "tiktok", | ||
| 50 | + "youtube": "youtube", "twitch": "twitch", "x": "twitter"} | ||
| 51 | + | ||
| 52 | +# connexion SQLite PAR THREAD (même doctrine que web.py, §18) | ||
| 53 | +_local = threading.local() | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def _db(): | ||
| 57 | + con = getattr(_local, "con", None) | ||
| 58 | + if con is None: | ||
| 59 | + con = _local.con = db.connect() | ||
| 60 | + return con | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def _e(t) -> str: | ||
| 64 | + return html.escape(str(t or ""), quote=True) | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +# --- Gabarit (shell prérendu par scripts/build_frontend.py) ------------------- | ||
| 68 | + | ||
| 69 | +_shell_cache: dict = {"mtime": 0.0, "html": ""} | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def _shell() -> str: | ||
| 73 | + f = FRONTEND_DIST / "index.html" | ||
| 74 | + mtime = f.stat().st_mtime | ||
| 75 | + if mtime != _shell_cache["mtime"]: | ||
| 76 | + _shell_cache["html"] = f.read_text(encoding="utf-8") | ||
| 77 | + _shell_cache["mtime"] = mtime | ||
| 78 | + return _shell_cache["html"] | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def _render(*, title: str, description: str, path: str, | ||
| 82 | + jsonld: list[dict] | None = None, body: str = "", | ||
| 83 | + og_image: str | None = None, og_type: str = "website", | ||
| 84 | + status: int = 200, noindex: bool = False) -> HTMLResponse: | ||
| 85 | + """Shell du build + bloc <head> SEO unique + contenu HTML dans #app.""" | ||
| 86 | + canonical = BASE_URL + path | ||
| 87 | + img = og_image or (BASE_URL + "/og.png") | ||
| 88 | + lines = [ | ||
| 89 | + f"<title>{html.escape(title)}</title>", | ||
| 90 | + f'<meta name="description" content="{_e(description)}">', | ||
| 91 | + ] | ||
| 92 | + if noindex: | ||
| 93 | + lines.append('<meta name="robots" content="noindex">') | ||
| 94 | + else: | ||
| 95 | + lines.append(f'<link rel="canonical" href="{_e(canonical)}">') | ||
| 96 | + lines += [ | ||
| 97 | + f'<meta property="og:site_name" content="{SITE_NAME}">', | ||
| 98 | + '<meta property="og:locale" content="fr_CA">', | ||
| 99 | + f'<meta property="og:type" content="{og_type}">', | ||
| 100 | + f'<meta property="og:title" content="{_e(title)}">', | ||
| 101 | + f'<meta property="og:description" content="{_e(description)}">', | ||
| 102 | + f'<meta property="og:url" content="{_e(canonical)}">', | ||
| 103 | + f'<meta property="og:image" content="{_e(img)}">', | ||
| 104 | + ] | ||
| 105 | + if not og_image: | ||
| 106 | + lines.append('<meta property="og:image:width" content="1200">') | ||
| 107 | + lines.append('<meta property="og:image:height" content="630">') | ||
| 108 | + lines += [ | ||
| 109 | + '<meta name="twitter:card" content="summary_large_image">', | ||
| 110 | + f'<meta name="twitter:title" content="{_e(title)}">', | ||
| 111 | + f'<meta name="twitter:description" content="{_e(description)}">', | ||
| 112 | + f'<meta name="twitter:image" content="{_e(img)}">', | ||
| 113 | + ] | ||
| 114 | + for obj in (jsonld or []): | ||
| 115 | + blob = json.dumps(obj, ensure_ascii=False, | ||
| 116 | + separators=(",", ":")).replace("</", "<\\/") | ||
| 117 | + lines.append(f'<script type="application/ld+json">{blob}</script>') | ||
| 118 | + block = "<!--KA:SEO-->\n" + "\n".join(lines) + "\n<!--/KA:SEO-->" | ||
| 119 | + page = re.sub(r"<!--KA:SEO-->.*?<!--/KA:SEO-->", lambda _m: block, | ||
| 120 | + _shell(), count=1, flags=re.S) | ||
| 121 | + if body: | ||
| 122 | + seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;' | ||
| 123 | + 'font-family:system-ui,sans-serif;color:#141814">' + body | ||
| 124 | + + '<p>Créa-Ka — Un service <a href="https://www.groupe-ka.com">' | ||
| 125 | + 'Groupe KA</a></p></div>') | ||
| 126 | + page = page.replace('<div id="app"></div>', | ||
| 127 | + '<div id="app">' + seo_div + "</div>", 1) | ||
| 128 | + return HTMLResponse(page, status_code=status, | ||
| 129 | + headers={"Cache-Control": "no-cache"}) | ||
| 130 | + | ||
| 131 | + | ||
| 132 | +def _breadcrumb(items: list[tuple[str, str]]) -> dict: | ||
| 133 | + return {"@context": "https://schema.org", "@type": "BreadcrumbList", | ||
| 134 | + "itemListElement": [ | ||
| 135 | + {"@type": "ListItem", "position": i + 1, "name": name, | ||
| 136 | + "item": BASE_URL + path} | ||
| 137 | + for i, (name, path) in enumerate(items)]} | ||
| 138 | + | ||
| 139 | + | ||
| 140 | +def _fmt_int(n) -> str: | ||
| 141 | + return f"{int(n):,}".replace(",", " ") if n else "0" | ||
| 142 | + | ||
| 143 | + | ||
| 144 | +# --- Fiches créateur ----------------------------------------------------------- | ||
| 145 | + | ||
| 146 | +def _avatar(doc: dict, accounts: list) -> str | None: | ||
| 147 | + """og:image STABLE : unavatar d'abord (les URL CDN captées — Instagram | ||
| 148 | + notamment — expirent au bout de quelques semaines), avatar capté en repli.""" | ||
| 149 | + for plat in ("instagram", "tiktok", "youtube", "twitch", "x"): | ||
| 150 | + for a in accounts: | ||
| 151 | + if a["platform"] == plat and plat in UNAVATAR: | ||
| 152 | + return (f"https://unavatar.io/{UNAVATAR[plat]}/" | ||
| 153 | + f"{quote(a['handle'])}") | ||
| 154 | + return doc.get("avatar_url") or None | ||
| 155 | + | ||
| 156 | + | ||
| 157 | +def _description(name: str, bio: str, accounts: list) -> str: | ||
| 158 | + bio = re.sub(r"\s+", " ", bio or "").strip() | ||
| 159 | + if len(bio) >= 60: | ||
| 160 | + return bio[:157] + "…" if len(bio) > 160 else bio | ||
| 161 | + plats = [] | ||
| 162 | + for a in accounts: | ||
| 163 | + lbl = PLAT_LABEL.get(a["platform"], a["platform"]) | ||
| 164 | + if lbl not in plats: | ||
| 165 | + plats.append(lbl) | ||
| 166 | + reach = sum(a["followers"] or 0 for a in accounts) | ||
| 167 | + parts = [f"Tous les comptes publics de {name} au même endroit"] | ||
| 168 | + if plats: | ||
| 169 | + parts[0] += f" ({', '.join(plats[:5])})" | ||
| 170 | + if reach: | ||
| 171 | + parts.append(f"{_fmt_int(reach)} abonnés cumulés") | ||
| 172 | + txt = " — ".join(parts) + ". Annuaire Créa-Ka des créateurs québécois." | ||
| 173 | + if bio: | ||
| 174 | + txt = bio + " " + txt | ||
| 175 | + return txt[:157] + "…" if len(txt) > 160 else txt | ||
| 176 | + | ||
| 177 | + | ||
| 178 | +def _gone(path: str) -> HTMLResponse: | ||
| 179 | + return _render(title="Fiche retirée | Créa-Ka", | ||
| 180 | + description="Cette fiche a été retirée de l'annuaire Créa-Ka.", | ||
| 181 | + path=path, status=410, noindex=True, | ||
| 182 | + body="<h1>Fiche retirée de l'annuaire</h1>" | ||
| 183 | + '<p>Ce créateur a demandé son retrait (opt-out) ou sa ' | ||
| 184 | + 'fiche n\'est plus publiée. ' | ||
| 185 | + '<a href="/">Voir l\'annuaire des créateurs québécois</a></p>') | ||
| 186 | + | ||
| 187 | + | ||
| 188 | +def _not_found(path: str) -> HTMLResponse: | ||
| 189 | + return _render(title="Page introuvable | Créa-Ka", | ||
| 190 | + description="Cette page n'existe pas sur Créa-Ka.", | ||
| 191 | + path=path, status=404, noindex=True, | ||
| 192 | + body="<h1>Fiche introuvable</h1>" | ||
| 193 | + '<p><a href="/">Voir l\'annuaire des créateurs ' | ||
| 194 | + 'québécois</a></p>') | ||
| 195 | + | ||
| 196 | + | ||
| 197 | +@router.get("/createur/{cid}", include_in_schema=False) | ||
| 198 | +def creator_page(cid: str) -> HTMLResponse: | ||
| 199 | + path = f"/createur/{quote(cid)}" | ||
| 200 | + con = _db() | ||
| 201 | + row = con.execute("SELECT * FROM creators WHERE id=?", (cid,)).fetchone() | ||
| 202 | + if row is None: | ||
| 203 | + return _not_found(path) | ||
| 204 | + if row["status"] != "active": | ||
| 205 | + return _gone(path) # retiré (opt-out) ou désactivé → 410 | ||
| 206 | + if row["is_minor"]: | ||
| 207 | + return _not_found(path) # jamais publié (§15) | ||
| 208 | + accounts = con.execute( | ||
| 209 | + "SELECT * FROM accounts WHERE creator_id=? AND needs_review=0 " | ||
| 210 | + "ORDER BY followers DESC", (cid,)).fetchall() | ||
| 211 | + doc = json.loads(row["doc"]) | ||
| 212 | + name = row["display_name"] | ||
| 213 | + | ||
| 214 | + primary = next((a for a in accounts | ||
| 215 | + if a["platform"] == row["primary_platform"]), | ||
| 216 | + accounts[0] if accounts else None) | ||
| 217 | + handle = primary["handle"] if primary else "" | ||
| 218 | + title = (f"{name} (@{handle}) — profils et liens | Créa-Ka" if handle | ||
| 219 | + else f"{name} — profils et liens | Créa-Ka") | ||
| 220 | + description = _description(name, doc.get("bio", ""), accounts) | ||
| 221 | + avatar = _avatar(doc, accounts) | ||
| 222 | + canonical = BASE_URL + path | ||
| 223 | + | ||
| 224 | + same_as = [a["url"] for a in accounts if a["url"]] | ||
| 225 | + person: dict = {"@type": "Person", "name": name, "url": canonical} | ||
| 226 | + if handle: | ||
| 227 | + person["alternateName"] = f"@{handle}" | ||
| 228 | + if doc.get("bio"): | ||
| 229 | + person["description"] = re.sub(r"\s+", " ", doc["bio"]).strip()[:500] | ||
| 230 | + if avatar: | ||
| 231 | + person["image"] = avatar | ||
| 232 | + if same_as: | ||
| 233 | + person["sameAs"] = same_as | ||
| 234 | + profile: dict = {"@context": "https://schema.org", "@type": "ProfilePage", | ||
| 235 | + "@id": canonical, "url": canonical, | ||
| 236 | + "name": title, "inLanguage": "fr-CA", | ||
| 237 | + "isPartOf": {"@id": BASE_URL + "/#website"}, | ||
| 238 | + "mainEntity": person} | ||
| 239 | + if row["updated_at"]: | ||
| 240 | + profile["dateModified"] = row["updated_at"] | ||
| 241 | + if row["first_seen"]: | ||
| 242 | + profile["dateCreated"] = row["first_seen"] | ||
| 243 | + jsonld = [profile, | ||
| 244 | + _breadcrumb([("Accueil", "/"), (name, path)])] | ||
| 245 | + | ||
| 246 | + items = [] | ||
| 247 | + for a in accounts: | ||
| 248 | + lbl = PLAT_LABEL.get(a["platform"], a["platform"]) | ||
| 249 | + extra = (f" — {_fmt_int(a['followers'])} abonnés" | ||
| 250 | + if a["followers"] else "") | ||
| 251 | + items.append(f'<li><a href="{_e(a["url"])}" rel="noopener">' | ||
| 252 | + f"{_e(lbl)} — @{_e(a['handle'])}</a>{extra}</li>") | ||
| 253 | + bio_html = "" | ||
| 254 | + if doc.get("bio"): | ||
| 255 | + clean_bio = re.sub(r"\s+", " ", doc["bio"]).strip() | ||
| 256 | + bio_html = f"<p>{_e(clean_bio)}</p>" | ||
| 257 | + body = (f"<h1>{_e(name)}{' (@' + _e(handle) + ')' if handle else ''}</h1>" | ||
| 258 | + + bio_html | ||
| 259 | + + (f"<ul>{''.join(items)}</ul>" if items else "") | ||
| 260 | + + '<p><a href="/">Annuaire des créateurs de contenu québécois</a></p>') | ||
| 261 | + return _render(title=title, description=description, path=path, | ||
| 262 | + jsonld=jsonld, body=body, og_image=avatar, | ||
| 263 | + og_type="profile") | ||
| 264 | + | ||
| 265 | + | ||
| 266 | +# --- Pages statiques du SPA (canonical/title corrects, sinon fallback = accueil) | ||
| 267 | + | ||
| 268 | +_STATIC_PAGES = { | ||
| 269 | + "/stats": ("Statistiques de l'annuaire | Créa-Ka", | ||
| 270 | + "Statistiques publiques de Créa-Ka : créateurs québécois " | ||
| 271 | + "recensés, comptes reliés, répartition par plateforme, niche, " | ||
| 272 | + "région et taille d'audience.", False), | ||
| 273 | + "/retrait": ("Retrait de fiche (opt-out) | Créa-Ka", | ||
| 274 | + "Demander le retrait de votre fiche de l'annuaire Créa-Ka : " | ||
| 275 | + "masquage immédiat et respecté (Loi 25).", False), | ||
| 276 | + "/contact": ("Contact | Créa-Ka", | ||
| 277 | + "Contacter l'équipe de Créa-Ka et du Groupe KA : projets, " | ||
| 278 | + "partenariats, médias, vie privée et Loi 25.", False), | ||
| 279 | + "/compte": ("Mon compte | Créa-Ka", | ||
| 280 | + "Espace compte KA ID sur Créa-Ka.", True), | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +for _path, (_t, _d, _noidx) in _STATIC_PAGES.items(): | ||
| 284 | + def _mk(p=_path, t=_t, d=_d, noidx=_noidx): | ||
| 285 | + def handler() -> HTMLResponse: | ||
| 286 | + return _render(title=t, description=d, path=p, noindex=noidx, | ||
| 287 | + jsonld=None if noidx else | ||
| 288 | + [_breadcrumb([("Accueil", "/"), (t.split(" | ")[0], p)])]) | ||
| 289 | + return handler | ||
| 290 | + router.get(_path, include_in_schema=False)(_mk()) | ||
| 291 | + | ||
| 292 | + | ||
| 293 | +# --- robots.txt & sitemaps ------------------------------------------------------- | ||
| 294 | + | ||
| 295 | +@router.get("/robots.txt", include_in_schema=False) | ||
| 296 | +def robots() -> PlainTextResponse: | ||
| 297 | + return PlainTextResponse( | ||
| 298 | + "User-agent: *\n" | ||
| 299 | + "Disallow: /api/\n" | ||
| 300 | + "Disallow: /compte\n" | ||
| 301 | + "Allow: /\n" | ||
| 302 | + f"\nSitemap: {BASE_URL}/sitemap.xml\n") | ||
| 303 | + | ||
| 304 | + | ||
| 305 | +def _xml(body: str) -> Response: | ||
| 306 | + return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + body, | ||
| 307 | + media_type="application/xml", | ||
| 308 | + headers={"Cache-Control": "max-age=3600"}) | ||
| 309 | + | ||
| 310 | + | ||
| 311 | +def _urlset(urls: list[tuple[str, str | None]]) -> Response: | ||
| 312 | + rows = [] | ||
| 313 | + for loc, lastmod in urls: | ||
| 314 | + lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else "" | ||
| 315 | + rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>") | ||
| 316 | + return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' | ||
| 317 | + + "\n".join(rows) + "\n</urlset>") | ||
| 318 | + | ||
| 319 | + | ||
| 320 | +def _active_count() -> int: | ||
| 321 | + return _db().execute("SELECT COUNT(*) c FROM creators " | ||
| 322 | + "WHERE status='active' AND is_minor=0").fetchone()["c"] | ||
| 323 | + | ||
| 324 | + | ||
| 325 | +@router.get("/sitemap.xml", include_in_schema=False) | ||
| 326 | +def sitemap_index() -> Response: | ||
| 327 | + chunks = max(1, -(-_active_count() // SITEMAP_CHUNK)) | ||
| 328 | + names = ["sitemap-pages.xml"] + [f"sitemap-createurs-{i + 1}.xml" | ||
| 329 | + for i in range(chunks)] | ||
| 330 | + today = date.today().isoformat() | ||
| 331 | + rows = [f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod>" | ||
| 332 | + "</sitemap>" for n in names] | ||
| 333 | + return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' | ||
| 334 | + + "\n".join(rows) + "\n</sitemapindex>") | ||
| 335 | + | ||
| 336 | + | ||
| 337 | +@router.get("/sitemap-pages.xml", include_in_schema=False) | ||
| 338 | +def sitemap_pages() -> Response: | ||
| 339 | + last = _db().execute("SELECT MAX(updated_at) m FROM creators " | ||
| 340 | + "WHERE status='active'").fetchone()["m"] | ||
| 341 | + lm = (last or "")[:10] or None | ||
| 342 | + return _urlset([(BASE_URL + "/", lm), | ||
| 343 | + (BASE_URL + "/stats", lm), | ||
| 344 | + (BASE_URL + "/retrait", None), | ||
| 345 | + (BASE_URL + "/contact", None)]) | ||
| 346 | + | ||
| 347 | + | ||
| 348 | +@router.get("/sitemap-createurs-{n}.xml", include_in_schema=False) | ||
| 349 | +def sitemap_creators(n: int) -> Response: | ||
| 350 | + if n < 1: | ||
| 351 | + return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' | ||
| 352 | + "</urlset>") | ||
| 353 | + rows = _db().execute( | ||
| 354 | + "SELECT id, updated_at FROM creators WHERE status='active' AND " | ||
| 355 | + "is_minor=0 ORDER BY id LIMIT ? OFFSET ?", | ||
| 356 | + (SITEMAP_CHUNK, (n - 1) * SITEMAP_CHUNK)).fetchall() | ||
| 357 | + return _urlset([(f"{BASE_URL}/createur/{quote(r['id'])}", | ||
| 358 | + (r["updated_at"] or "")[:10] or None) for r in rows]) | ||
modified
creaka/web.py
+6 −1
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware | |||
| 16 | from fastapi.responses import FileResponse, Response | 16 | from fastapi.responses import FileResponse, Response |
| 17 | from pydantic import BaseModel, Field | 17 | from pydantic import BaseModel, Field |
| 18 | 18 | ||
| 19 | −from . import auth, db, ethics, kapdf | 19 | +from . import auth, db, ethics, kapdf, seo |
| 20 | from . import stats as stats_mod | 20 | from . import stats as stats_mod |
| 21 | from .normalize import NICHES, PLATFORMS, REGIONS | 21 | from .normalize import NICHES, PLATFORMS, REGIONS |
| 22 | 22 | ||
@@ -164,6 +164,11 @@ def optout(req: OptOutRequest): | |||
| 164 | "message": "Fiche masquée. La demande sera vérifiée ; merci."} | 164 | "message": "Fiche masquée. La demande sera vérifiée ; merci."} |
| 165 | 165 | ||
| 166 | 166 | ||
| 167 | +# SEO : fiches créateur SSR, robots.txt, sitemaps — déclaré AVANT | ||
| 168 | +# les routes statiques pour prendre la main sur le fallback SPA | ||
| 169 | +app.include_router(seo.router) | ||
| 170 | + | ||
| 171 | + | ||
| 167 | # --- frontend statique ----------------------------------------------------------- | 172 | # --- frontend statique ----------------------------------------------------------- |
| 168 | 173 | ||
| 169 | @app.get("/") | 174 | @app.get("/") |
modified
frontend/dist/index.html
+16 −5
@@ -333,12 +333,10 @@ html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; ove | |||
| 333 | Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact | 333 | Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact |
| 334 | --> | 334 | --> |
| 335 | <!DOCTYPE html> | 335 | <!DOCTYPE html> |
| 336 | −<html lang="fr"> | 336 | +<html lang="fr-CA"> |
| 337 | <head> | 337 | <head> |
| 338 | <meta charset="utf-8"> | 338 | <meta charset="utf-8"> |
| 339 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | 339 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 340 | −<title>Créa-Ka — Un service Groupe KA</title> | ||
| 341 | −<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 342 | <meta name="theme-color" content="#141814"> | 340 | <meta name="theme-color" content="#141814"> |
| 343 | <link rel="preconnect" href="https://fonts.googleapis.com"> | 341 | <link rel="preconnect" href="https://fonts.googleapis.com"> |
| 344 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | 342 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
@@ -346,13 +344,25 @@ html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; ove | |||
| 346 | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> | 344 | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> |
| 347 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> | 345 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| 348 | <link rel="apple-touch-icon" href="/apple-touch-icon.png"> | 346 | <link rel="apple-touch-icon" href="/apple-touch-icon.png"> |
| 349 | −<meta property="og:title" content="Créa-Ka — Un service Groupe KA"> | 347 | +<!--KA:SEO--> |
| 348 | +<title>Créa-Ka — Annuaire des créateurs de contenu québécois</title> | ||
| 349 | +<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 350 | +<link rel="canonical" href="https://www.crea-ka.com/"> | ||
| 351 | +<meta property="og:site_name" content="Créa-Ka"> | ||
| 352 | +<meta property="og:locale" content="fr_CA"> | ||
| 353 | +<meta property="og:type" content="website"> | ||
| 354 | +<meta property="og:title" content="Créa-Ka — Annuaire des créateurs de contenu québécois"> | ||
| 350 | <meta property="og:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | 355 | <meta property="og:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> |
| 356 | +<meta property="og:url" content="https://www.crea-ka.com/"> | ||
| 351 | <meta property="og:image" content="https://www.crea-ka.com/og.png"> | 357 | <meta property="og:image" content="https://www.crea-ka.com/og.png"> |
| 352 | <meta property="og:image:width" content="1200"> | 358 | <meta property="og:image:width" content="1200"> |
| 353 | <meta property="og:image:height" content="630"> | 359 | <meta property="og:image:height" content="630"> |
| 354 | <meta name="twitter:card" content="summary_large_image"> | 360 | <meta name="twitter:card" content="summary_large_image"> |
| 361 | +<meta name="twitter:title" content="Créa-Ka — Annuaire des créateurs de contenu québécois"> | ||
| 362 | +<meta name="twitter:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 355 | <meta name="twitter:image" content="https://www.crea-ka.com/og.png"> | 363 | <meta name="twitter:image" content="https://www.crea-ka.com/og.png"> |
| 364 | +<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://www.crea-ka.com/#website","url":"https://www.crea-ka.com/","name":"Créa-Ka","description":"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.","inLanguage":"fr-CA","publisher":{"@id":"https://www.crea-ka.com/#org"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://www.crea-ka.com/?q={search_term_string}"},"query-input":"required name=search_term_string"}},{"@type":"Organization","@id":"https://www.crea-ka.com/#org","name":"Créa-Ka","url":"https://www.crea-ka.com/","logo":"https://www.crea-ka.com/og.png","email":"contact@groupe-ka.com","parentOrganization":{"@type":"Organization","name":"Groupe KA","url":"https://www.groupe-ka.com"}}]}</script> | ||
| 365 | +<!--/KA:SEO--> | ||
| 356 | <style> | 366 | <style> |
| 357 | /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER | 367 | /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER |
| 358 | (injecté au build par scripts/build_frontend.py) */ | 368 | (injecté au build par scripts/build_frontend.py) */ |
@@ -1424,7 +1434,7 @@ function selectHtml(id,label,opts,val,lbls){ | |||
| 1424 | return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`; | 1434 | return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`; |
| 1425 | } | 1435 | } |
| 1426 | async function renderHome(){ | 1436 | async function renderHome(){ |
| 1427 | − document.title="Créa-Ka — Un service Groupe KA"; | 1437 | + document.title="Créa-Ka — Annuaire des créateurs de contenu québécois"; |
| 1428 | if(!state.tax)state.tax=await api("/api/taxonomies"); | 1438 | if(!state.tax)state.tax=await api("/api/taxonomies"); |
| 1429 | if(!state.stats)state.stats=await api("/api/stats").catch(()=>null); | 1439 | if(!state.stats)state.stats=await api("/api/stats").catch(()=>null); |
| 1430 | const s=state.stats||{creators:0,accounts:0,by_platform:{}}; | 1440 | const s=state.stats||{creators:0,accounts:0,by_platform:{}}; |
@@ -2336,6 +2346,7 @@ function bindNav(root){ | |||
| 2336 | function route(){ | 2346 | function route(){ |
| 2337 | const p=location.pathname; | 2347 | const p=location.pathname; |
| 2338 | const m=p.match(/^\/createur\/([^/]+)$/); | 2348 | const m=p.match(/^\/createur\/([^/]+)$/); |
| 2349 | + const qq=new URLSearchParams(location.search).get("q");if(qq&&!state.q)state.q=qq; | ||
| 2339 | let r; | 2350 | let r; |
| 2340 | if(m)r=renderCreator(decodeURIComponent(m[1])); | 2351 | if(m)r=renderCreator(decodeURIComponent(m[1])); |
| 2341 | else if(p==="/retrait")r=renderOptout(); | 2352 | else if(p==="/retrait")r=renderOptout(); |
modified
frontend/src/index.template.html
+16 −5
@@ -14,12 +14,10 @@ | |||
| 14 | Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact | 14 | Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact |
| 15 | --> | 15 | --> |
| 16 | <!DOCTYPE html> | 16 | <!DOCTYPE html> |
| 17 | −<html lang="fr"> | 17 | +<html lang="fr-CA"> |
| 18 | <head> | 18 | <head> |
| 19 | <meta charset="utf-8"> | 19 | <meta charset="utf-8"> |
| 20 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | 20 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 21 | −<title>Créa-Ka — Un service Groupe KA</title> | ||
| 22 | −<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 23 | <meta name="theme-color" content="#141814"> | 21 | <meta name="theme-color" content="#141814"> |
| 24 | <link rel="preconnect" href="https://fonts.googleapis.com"> | 22 | <link rel="preconnect" href="https://fonts.googleapis.com"> |
| 25 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | 23 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
@@ -27,13 +25,25 @@ | |||
| 27 | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> | 25 | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> |
| 28 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> | 26 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| 29 | <link rel="apple-touch-icon" href="/apple-touch-icon.png"> | 27 | <link rel="apple-touch-icon" href="/apple-touch-icon.png"> |
| 30 | −<meta property="og:title" content="Créa-Ka — Un service Groupe KA"> | 28 | +<!--KA:SEO--> |
| 29 | +<title>Créa-Ka — Annuaire des créateurs de contenu québécois</title> | ||
| 30 | +<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 31 | +<link rel="canonical" href="https://www.crea-ka.com/"> | ||
| 32 | +<meta property="og:site_name" content="Créa-Ka"> | ||
| 33 | +<meta property="og:locale" content="fr_CA"> | ||
| 34 | +<meta property="og:type" content="website"> | ||
| 35 | +<meta property="og:title" content="Créa-Ka — Annuaire des créateurs de contenu québécois"> | ||
| 31 | <meta property="og:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | 36 | <meta property="og:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> |
| 37 | +<meta property="og:url" content="https://www.crea-ka.com/"> | ||
| 32 | <meta property="og:image" content="https://www.crea-ka.com/og.png"> | 38 | <meta property="og:image" content="https://www.crea-ka.com/og.png"> |
| 33 | <meta property="og:image:width" content="1200"> | 39 | <meta property="og:image:width" content="1200"> |
| 34 | <meta property="og:image:height" content="630"> | 40 | <meta property="og:image:height" content="630"> |
| 35 | <meta name="twitter:card" content="summary_large_image"> | 41 | <meta name="twitter:card" content="summary_large_image"> |
| 42 | +<meta name="twitter:title" content="Créa-Ka — Annuaire des créateurs de contenu québécois"> | ||
| 43 | +<meta name="twitter:description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience."> | ||
| 36 | <meta name="twitter:image" content="https://www.crea-ka.com/og.png"> | 44 | <meta name="twitter:image" content="https://www.crea-ka.com/og.png"> |
| 45 | +<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://www.crea-ka.com/#website","url":"https://www.crea-ka.com/","name":"Créa-Ka","description":"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.","inLanguage":"fr-CA","publisher":{"@id":"https://www.crea-ka.com/#org"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://www.crea-ka.com/?q={search_term_string}"},"query-input":"required name=search_term_string"}},{"@type":"Organization","@id":"https://www.crea-ka.com/#org","name":"Créa-Ka","url":"https://www.crea-ka.com/","logo":"https://www.crea-ka.com/og.png","email":"contact@groupe-ka.com","parentOrganization":{"@type":"Organization","name":"Groupe KA","url":"https://www.groupe-ka.com"}}]}</script> | ||
| 46 | +<!--/KA:SEO--> | ||
| 37 | <style> | 47 | <style> |
| 38 | /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER | 48 | /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER |
| 39 | (injecté au build par scripts/build_frontend.py) */ | 49 | (injecté au build par scripts/build_frontend.py) */ |
@@ -786,7 +796,7 @@ function selectHtml(id,label,opts,val,lbls){ | |||
| 786 | return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`; | 796 | return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`; |
| 787 | } | 797 | } |
| 788 | async function renderHome(){ | 798 | async function renderHome(){ |
| 789 | − document.title="Créa-Ka — Un service Groupe KA"; | 799 | + document.title="Créa-Ka — Annuaire des créateurs de contenu québécois"; |
| 790 | if(!state.tax)state.tax=await api("/api/taxonomies"); | 800 | if(!state.tax)state.tax=await api("/api/taxonomies"); |
| 791 | if(!state.stats)state.stats=await api("/api/stats").catch(()=>null); | 801 | if(!state.stats)state.stats=await api("/api/stats").catch(()=>null); |
| 792 | const s=state.stats||{creators:0,accounts:0,by_platform:{}}; | 802 | const s=state.stats||{creators:0,accounts:0,by_platform:{}}; |
@@ -1698,6 +1708,7 @@ function bindNav(root){ | |||
| 1698 | function route(){ | 1708 | function route(){ |
| 1699 | const p=location.pathname; | 1709 | const p=location.pathname; |
| 1700 | const m=p.match(/^\/createur\/([^/]+)$/); | 1710 | const m=p.match(/^\/createur\/([^/]+)$/); |
| 1711 | + const qq=new URLSearchParams(location.search).get("q");if(qq&&!state.q)state.q=qq; | ||
| 1701 | let r; | 1712 | let r; |
| 1702 | if(m)r=renderCreator(decodeURIComponent(m[1])); | 1713 | if(m)r=renderCreator(decodeURIComponent(m[1])); |
| 1703 | else if(p==="/retrait")r=renderOptout(); | 1714 | else if(p==="/retrait")r=renderOptout(); |
| 1704 | 1715 | ||