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%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 22, 2026) parent 237507b

4 changed files +396 −11

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 16 from fastapi.responses import FileResponse, Response
17 17 from pydantic import BaseModel, Field
18 18
19 −from . import auth, db, ethics, kapdf
19 +from . import auth, db, ethics, kapdf, seo
20 20 from . import stats as stats_mod
21 21 from .normalize import NICHES, PLATFORMS, REGIONS
22 22
@@ -164,6 +164,11 @@ def optout(req: OptOutRequest):
164 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 172 # --- frontend statique -----------------------------------------------------------
168 173
169 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 333 Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact
334 334 -->
335 335 <!DOCTYPE html>
336 −<html lang="fr">
336 +<html lang="fr-CA">
337 337 <head>
338 338 <meta charset="utf-8">
339 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 340 <meta name="theme-color" content="#141814">
343 341 <link rel="preconnect" href="https://fonts.googleapis.com">
344 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 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 345 <link rel="icon" type="image/svg+xml" href="/favicon.svg">
348 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 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 357 <meta property="og:image" content="https://www.crea-ka.com/og.png">
352 358 <meta property="og:image:width" content="1200">
353 359 <meta property="og:image:height" content="630">
354 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 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 366 <style>
357 367 /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER
358 368 (injecté au build par scripts/build_frontend.py) */
@@ -1424,7 +1434,7 @@ function selectHtml(id,label,opts,val,lbls){
1424 1434 return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`;
1425 1435 }
1426 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 1438 if(!state.tax)state.tax=await api("/api/taxonomies");
1429 1439 if(!state.stats)state.stats=await api("/api/stats").catch(()=>null);
1430 1440 const s=state.stats||{creators:0,accounts:0,by_platform:{}};
@@ -2336,6 +2346,7 @@ function bindNav(root){
2336 2346 function route(){
2337 2347 const p=location.pathname;
2338 2348 const m=p.match(/^\/createur\/([^/]+)$/);
2349 + const qq=new URLSearchParams(location.search).get("q");if(qq&&!state.q)state.q=qq;
2339 2350 let r;
2340 2351 if(m)r=renderCreator(decodeURIComponent(m[1]));
2341 2352 else if(p==="/retrait")r=renderOptout();
modified frontend/src/index.template.html +16 −5
@@ -14,12 +14,10 @@
14 14 Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact
15 15 -->
16 16 <!DOCTYPE html>
17 −<html lang="fr">
17 +<html lang="fr-CA">
18 18 <head>
19 19 <meta charset="utf-8">
20 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 21 <meta name="theme-color" content="#141814">
24 22 <link rel="preconnect" href="https://fonts.googleapis.com">
25 23 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -27,13 +25,25 @@
27 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 26 <link rel="icon" type="image/svg+xml" href="/favicon.svg">
29 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 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 38 <meta property="og:image" content="https://www.crea-ka.com/og.png">
33 39 <meta property="og:image:width" content="1200">
34 40 <meta property="og:image:height" content="630">
35 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 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 47 <style>
38 48 /* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER
39 49 (injecté au build par scripts/build_frontend.py) */
@@ -786,7 +796,7 @@ function selectHtml(id,label,opts,val,lbls){
786 796 return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`;
787 797 }
788 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 800 if(!state.tax)state.tax=await api("/api/taxonomies");
791 801 if(!state.stats)state.stats=await api("/api/stats").catch(()=>null);
792 802 const s=state.stats||{creators:0,accounts:0,by_platform:{}};
@@ -1698,6 +1708,7 @@ function bindNav(root){
1698 1708 function route(){
1699 1709 const p=location.pathname;
1700 1710 const m=p.match(/^\/createur\/([^/]+)$/);
1711 + const qq=new URLSearchParams(location.search).get("q");if(qq&&!state.q)state.q=qq;
1701 1712 let r;
1702 1713 if(m)r=renderCreator(decodeURIComponent(m[1]));
1703 1714 else if(p==="/retrait")r=renderOptout();
1704 1715