SPB Git

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

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 (Aug 22, 2026) parent 237507b Browse files

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 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
Menu

Theme