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