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