SPB Git

spb/job-ka

Public

SEO : SSR léger (accueil, fiches JobPosting, pages statiques), robots.txt et sitemaps

- jobka/seo.py : pattern Lou-Ka — head unique (title, description, canonical,
  hreflang fr-ca, og:locale fr_CA, twitter) + contenu HTML dans #root que
  React remplace au montage
- accueil : title avec le nombre d'offres, JSON-LD WebSite/Organization,
  h1 + listes de liens (villes, dernières offres)
- fiche emploi : JSON-LD JobPosting (baseSalary CAD, jobLocation QC/CA,
  employmentType, validThrough, TELECOMMUTE) + BreadcrumbList ; 404 inconnue,
  410 retirée, noindex quarantaine, canonical vers l'offre maîtresse (doublons)
- robots.txt (Disallow /api/) + sitemap index + sitemap-pages +
  sitemap-emplois-N chunkés (10 000 URL, lastmod)
- web.py : include_router(seo) avant le rattrape-tout SPA
- index.html : lang fr-CA

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 22, 2026) parent 178ecdb Browse files

3 changed files +506 −1

modified frontend/index.html +1 −1
@@ -9,7 +9,7 @@ Rôle : Coquille HTML de l'application (SPA React)
9 Créé : 2026-08-17 Modifié : 2026-08-17 9 Créé : 2026-08-17 Modifié : 2026-08-17
10 ============================================================================= 10 =============================================================================
11 --> 11 -->
12−<html lang="fr"> 12+<html lang="fr-CA">
13 <head> 13 <head>
14 <meta charset="UTF-8" /> 14 <meta charset="UTF-8" />
15 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> 15 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
added jobka/seo.py +499 −0
@@ -0,0 +1,499 @@
1+# =============================================================================
2+# Job·Ka — Groupe KA
3+# Auteur : Simon-Pierre Boucher
4+# Contact : contact@spboucher.ai
5+# Fichier : jobka/seo.py
6+# Rôle : Référencement — SSR léger, robots.txt, sitemaps, données structurées
7+# Créé : 2026-08-22 Modifié : 2026-08-22
8+#
9+# Principe (pattern Lou-Ka) : pour chaque route publique, le serveur renvoie le
10+# MÊME index.html que le build Vite, mais avec un <head> unique (title,
11+# description, canonical, hreflang, og:, JSON-LD) et le contenu essentiel en
12+# HTML DANS <div id="root">. Les moteurs de recherche voient une page complète
13+# sans exécuter JavaScript ; React, en se montant, remplace ce contenu par
14+# l'application interactive.
15+# =============================================================================
16+from __future__ import annotations
17+
18+import html
19+import json
20+import math
21+import re
22+from datetime import date, datetime, timezone
23+from pathlib import Path
24+from urllib.parse import quote
25+from xml.sax.saxutils import escape as xml_escape
26+
27+from fastapi import APIRouter, HTTPException
28+from fastapi.responses import HTMLResponse, PlainTextResponse, Response
29+
30+from . import db
31+
32+router = APIRouter()
33+
34+ROOT = Path(__file__).resolve().parent.parent
35+FRONTEND_DIST = ROOT / "frontend" / "dist"
36+
37+BASE_URL = "https://www.job-ka.com"
38+SITE_NAME = "Job-Ka"
39+
40+# offre « publiée » : active, non doublon, hors quarantaine qualité
41+PUB = "active=1 AND dup_of IS NULL AND quarantine IS NULL"
42+
43+SITEMAP_CHUNK = 10000
44+
45+# vocabulaires internes → valeurs schema.org (JobPosting)
46+EMPLOYMENT_TYPE_SCHEMA = {
47+ "temps_plein": "FULL_TIME",
48+ "temps_partiel": "PART_TIME",
49+ "contractuel": "CONTRACTOR",
50+ "stage": "INTERN",
51+ "saisonnier": "TEMPORARY",
52+}
53+SALARY_UNIT_SCHEMA = {"hour": "HOUR", "week": "WEEK", "year": "YEAR"}
54+SALARY_UNIT_FR = {"hour": "/h", "week": "/sem.", "year": "/an"}
55+EMPLOYMENT_TYPE_FR = {
56+ "temps_plein": "temps plein",
57+ "temps_partiel": "temps partiel",
58+ "contractuel": "contractuel",
59+ "stage": "stage",
60+ "saisonnier": "saisonnier",
61+}
62+WORK_MODE_FR = {
63+ "presentiel": "présentiel",
64+ "hybride": "hybride",
65+ "teletravail": "télétravail",
66+}
67+
68+
69+# --- Gabarit (index.html du build Vite) --------------------------------------
70+
71+_shell_cache: dict = {"mtime": 0.0, "html": ""}
72+
73+
74+def _shell() -> str:
75+ f = FRONTEND_DIST / "index.html"
76+ mtime = f.stat().st_mtime
77+ if mtime != _shell_cache["mtime"]:
78+ _shell_cache["html"] = f.read_text(encoding="utf-8")
79+ _shell_cache["mtime"] = mtime
80+ return _shell_cache["html"]
81+
82+
83+def _render(*, title: str, description: str, path: str,
84+ jsonld: list[dict] | None = None, body: str = "",
85+ og_image: str | None = None, canonical_path: str | None = None,
86+ noindex: bool = False, status: int = 200) -> HTMLResponse:
87+ """index.html du build + head unique + contenu HTML dans #root."""
88+ canonical = BASE_URL + (canonical_path if canonical_path is not None else path)
89+ page = _shell()
90+ page = re.sub(r"<title>.*?</title>",
91+ lambda _m: f"<title>{html.escape(title)}</title>",
92+ page, count=1, flags=re.S)
93+ page = re.sub(r'<meta name="description"[^>]*/>',
94+ lambda _m: ('<meta name="description" content="'
95+ f'{html.escape(description, quote=True)}" />'),
96+ page, count=1)
97+ # retire du gabarit statique les meta og:/twitter génériques (re-injectées)
98+ page = re.sub(
99+ r'\s*<meta (?:property="og:[^"]*"|name="twitter:[^"]*")[^>]*/>', "", page)
100+ extras = [
101+ f'<link rel="canonical" href="{canonical}" />',
102+ f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />',
103+ f'<link rel="alternate" hreflang="x-default" href="{canonical}" />',
104+ f'<meta property="og:site_name" content="{SITE_NAME}" />',
105+ '<meta property="og:locale" content="fr_CA" />',
106+ '<meta property="og:type" content="website" />',
107+ f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',
108+ f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',
109+ f'<meta property="og:url" content="{canonical}" />',
110+ '<meta name="twitter:card" content="summary_large_image" />',
111+ f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',
112+ ]
113+ if noindex:
114+ extras.insert(0, '<meta name="robots" content="noindex" />')
115+ img = og_image or (BASE_URL + "/og.png")
116+ extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')
117+ if not og_image:
118+ extras.append('<meta property="og:image:width" content="1200" />')
119+ extras.append('<meta property="og:image:height" content="630" />')
120+ extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')
121+ for obj in (jsonld or []):
122+ blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
123+ extras.append(f'<script type="application/ld+json">{blob}</script>')
124+ page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1)
125+ if body:
126+ seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'
127+ 'font-family:system-ui,sans-serif;color:#101418">' + body
128+ + '<p>Job-Ka — Un service '
129+ '<a href="https://www.groupe-ka.com">Groupe KA</a></p>'
130+ + "</div>")
131+ page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)
132+ return HTMLResponse(page, status_code=status,
133+ headers={"Cache-Control": "no-cache"})
134+
135+
136+def _e(t) -> str:
137+ return html.escape(str(t or ""))
138+
139+
140+def _iso(ts) -> str:
141+ if not ts:
142+ return date.today().isoformat()
143+ return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()
144+
145+
146+def _fmt_amount(v: float) -> str:
147+ """87500.0 → « 87 500 $ » ; 25.5 → « 25,50 $ »."""
148+ if float(v) == int(v) and v >= 1000:
149+ return f"{int(v):,} $".replace(",", " ")
150+ return f"{v:,.2f} $".replace(",", " ").replace(".", ",")
151+
152+
153+def _salary_txt(d: dict) -> str:
154+ """Texte fr-CA du salaire (« 25,00 $ à 30,00 $/h »), sinon salary_label."""
155+ if d.get("salary_min") is not None:
156+ unit = SALARY_UNIT_FR.get(d.get("salary_unit") or "", "")
157+ lo = _fmt_amount(d["salary_min"])
158+ if d.get("salary_max") and d["salary_max"] != d["salary_min"]:
159+ return f"{lo} à {_fmt_amount(d['salary_max'])}{unit}"
160+ return f"{lo}{unit}"
161+ return (d.get("salary_label") or "").strip()
162+
163+
164+def _job_li(r) -> str:
165+ """Une offre dans une liste HTML serveur."""
166+ label = r["title_clean"] or r["title"] or r["uid"]
167+ bits = [b for b in (r["employer"], r["city"],
168+ _salary_txt(dict(r))) if b]
169+ return (f'<li><a href="/emploi/{_e(r["uid"])}">{_e(label)}</a>'
170+ f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')
171+
172+
173+def _breadcrumb(items: list[tuple[str, str]]) -> dict:
174+ return {"@context": "https://schema.org", "@type": "BreadcrumbList",
175+ "itemListElement": [
176+ {"@type": "ListItem", "position": i + 1, "name": name,
177+ "item": BASE_URL + path}
178+ for i, (name, path) in enumerate(items)]}
179+
180+
181+def _not_found(message: str, path: str) -> HTMLResponse:
182+ """404 HTML : le shell React est servi (la SPA affichera sa page), mais le
183+ statut et le contenu serveur disent clairement « introuvable » aux bots."""
184+ return _render(title="Page introuvable | Job-Ka",
185+ description="Cette page n'existe pas sur Job-Ka.",
186+ path=path,
187+ body=f"<h1>{_e(message)}</h1>"
188+ '<p><a href="/">Voir toutes les offres d\'emploi au '
189+ "Québec</a></p>",
190+ status=404)
191+
192+
193+# --- robots.txt & sitemaps ----------------------------------------------------
194+
195+@router.get("/robots.txt", include_in_schema=False)
196+def robots() -> PlainTextResponse:
197+ return PlainTextResponse(
198+ "User-agent: *\n"
199+ "Allow: /\n"
200+ "Disallow: /api/\n"
201+ "Disallow: /health\n"
202+ f"\nSitemap: {BASE_URL}/sitemap.xml\n")
203+
204+
205+def _xml(content: str) -> Response:
206+ return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,
207+ media_type="application/xml",
208+ headers={"Cache-Control": "public, max-age=3600"})
209+
210+
211+def _urlset(urls: list[tuple[str, str | None]]) -> Response:
212+ rows = []
213+ for loc, lastmod in urls:
214+ lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""
215+ rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")
216+ return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
217+ + "\n".join(rows) + "\n</urlset>")
218+
219+
220+@router.get("/sitemap.xml", include_in_schema=False)
221+def sitemap_index():
222+ con = db.connect()
223+ total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"]
224+ con.close()
225+ chunks = max(1, math.ceil(total / SITEMAP_CHUNK))
226+ names = ["sitemap-pages.xml"] + [
227+ f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)]
228+ today = date.today().isoformat()
229+ rows = "\n".join(
230+ f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"
231+ for n in names)
232+ return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
233+ + rows + "\n</sitemapindex>")
234+
235+
236+@router.get("/sitemap-pages.xml", include_in_schema=False)
237+def sitemap_pages():
238+ urls: list[tuple[str, str | None]] = [
239+ (f"{BASE_URL}/", None),
240+ (f"{BASE_URL}/carte", None),
241+ (f"{BASE_URL}/sources", None),
242+ (f"{BASE_URL}/stats", None),
243+ (f"{BASE_URL}/employeurs", None),
244+ (f"{BASE_URL}/contact", None)]
245+ return _urlset(urls)
246+
247+
248+@router.get("/sitemap-emplois-{num}.xml", include_in_schema=False)
249+def sitemap_emplois(num: int):
250+ if num < 1:
251+ raise HTTPException(404)
252+ con = db.connect()
253+ rows = con.execute(
254+ f"""SELECT uid, updated_at FROM jobs WHERE {PUB}
255+ ORDER BY uid LIMIT ? OFFSET ?""",
256+ (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()
257+ con.close()
258+ if not rows:
259+ raise HTTPException(404)
260+ return _urlset([(f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}",
261+ _iso(r["updated_at"])) for r in rows])
262+
263+
264+# --- Pages SSR ----------------------------------------------------------------
265+
266+def _fmt_n(n: int) -> str:
267+ return f"{n:,}".replace(",", " ")
268+
269+
270+@router.get("/", include_in_schema=False)
271+def home_ssr():
272+ con = db.connect()
273+ agg = con.execute(
274+ f"""SELECT COUNT(*) total, COUNT(DISTINCT employer) employers,
275+ COUNT(DISTINCT source) sources
276+ FROM jobs WHERE {PUB}""").fetchone()
277+ total, employers, nsources = agg["total"], agg["employers"], agg["sources"]
278+ villes = [dict(r) for r in con.execute(
279+ f"""SELECT city, COUNT(*) n FROM jobs WHERE {PUB} AND city<>''
280+ GROUP BY city ORDER BY n DESC LIMIT 25""")]
281+ with_salary = con.execute(
282+ f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL"
283+ ).fetchone()["c"]
284+ recents = con.execute(
285+ f"""SELECT uid, title, title_clean, employer, city, salary_min,
286+ salary_max, salary_unit, salary_label
287+ FROM jobs WHERE {PUB}
288+ ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC
289+ LIMIT 25""").fetchall()
290+ con.close()
291+
292+ title = f"Offres d'emploi au Québec — {_fmt_n(total)} postes | Job-Ka"
293+ description = (
294+ f"{_fmt_n(total)} offres d'emploi de {_fmt_n(employers)} employeurs "
295+ f"québécois, agrégées directement à la source ({nsources} sources : "
296+ "pages carrières et portails). Salaires transparents "
297+ f"({_fmt_n(with_salary)} offres avec salaire affiché), filtres par "
298+ "ville, région et métier, carte interactive.")
299+ body = (
300+ f"<h1>Offres d'emploi au Québec — {_fmt_n(total)} postes à pourvoir</h1>"
301+ + f"<p>Job-Ka recense les offres d'emploi de {_fmt_n(employers)} "
302+ "employeurs québécois, directement à la source : chaque offre est "
303+ "traçable à sa page carrière ou à son portail d'origine, avec le "
304+ f"salaire affiché quand il est publié ({_fmt_n(with_salary)} offres "
305+ "avec transparence salariale).</p>"
306+ + "<h2>Emplois par ville</h2><ul>"
307+ + "".join(
308+ f'<li><a href="/?ville={quote(v["city"])}">Emplois à '
309+ f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes)
310+ + "</ul>"
311+ + "<h2>Dernières offres publiées</h2><ul>"
312+ + "".join(_job_li(r) for r in recents)
313+ + "</ul>"
314+ + '<p><a href="/carte">Voir les offres sur la carte</a> · '
315+ '<a href="/sources">Employeurs et sources</a> · '
316+ '<a href="/stats">Statistiques du marché de l\'emploi</a> · '
317+ '<a href="/employeurs">Publier une offre</a></p>')
318+ jsonld = [
319+ {"@context": "https://schema.org", "@type": "WebSite",
320+ "name": SITE_NAME, "url": BASE_URL + "/",
321+ "description": description, "inLanguage": "fr-CA"},
322+ {"@context": "https://schema.org", "@type": "Organization",
323+ "name": "Job-Ka (Groupe KA)", "url": BASE_URL + "/",
324+ "logo": BASE_URL + "/og.png"}]
325+ return _render(title=title, description=description, path="/",
326+ jsonld=jsonld, body=body)
327+
328+
329+@router.get("/emploi/{uid:path}", include_in_schema=False)
330+def job_ssr(uid: str):
331+ con = db.connect()
332+ row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()
333+ con.close()
334+ path = f"/emploi/{uid}"
335+ if row is None:
336+ return _render(title="Offre introuvable | Job-Ka",
337+ description="Cette offre d'emploi n'existe pas ou plus sur Job-Ka.",
338+ path=path,
339+ body="<h1>Offre introuvable</h1>"
340+ '<p><a href="/">Voir toutes les offres d\'emploi '
341+ "au Québec</a></p>",
342+ status=404)
343+ d = dict(row)
344+ if not d["active"]:
345+ # offre retirée chez la source : 410 Gone + lien vers l'accueil
346+ return _render(
347+ title="Offre retirée | Job-Ka",
348+ description="Cette offre d'emploi a été retirée par l'employeur.",
349+ path=path,
350+ body="<h1>Cette offre n'est plus disponible</h1>"
351+ "<p>Elle a été retirée par l'employeur ou le poste est comblé. "
352+ '<a href="/">Voir les offres d\'emploi actuellement '
353+ "ouvertes au Québec</a>.</p>",
354+ status=410)
355+
356+ label = d["title_clean"] or d["title"] or "Offre d'emploi"
357+ where_bits = [b for b in (d["employer"], d["city"]) if b]
358+ title = f"{label}{' — ' + ', '.join(where_bits) if where_bits else ''} | Job-Ka"
359+
360+ salary_txt = _salary_txt(d)
361+ etype_fr = EMPLOYMENT_TYPE_FR.get(d["employment_type"] or "", "")
362+ mode_fr = WORK_MODE_FR.get(d["work_mode"] or "", "")
363+ bits = [b for b in (d["employer"], d["city"] or d["region"], etype_fr,
364+ mode_fr, salary_txt) if b]
365+ desc_txt = re.sub(r"\s+", " ", d["description"] or "").strip()
366+ description = (" · ".join(bits) + ". " if bits else "") + \
367+ (desc_txt[:160].strip() + "…" if len(desc_txt) > 160 else desc_txt)
368+ description = description[:300] or \
369+ f"Offre d'emploi{' à ' + d['city'] if d['city'] else ' au Québec'} sur Job-Ka."
370+
371+ facts = [("Employeur", d["employer"]), ("Ville", d["city"]),
372+ ("Région", d["region"]), ("Type d'emploi", etype_fr),
373+ ("Mode de travail", mode_fr), ("Salaire", salary_txt),
374+ ("Publiée le", d["date_posted"]),
375+ ("Postuler avant le", d["date_deadline"])]
376+ body = [f"<h1>{_e(label)}{' — ' + _e(d['employer']) if d['employer'] else ''}</h1>",
377+ "<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"
378+ for k, v in facts if v) + "</ul>"]
379+ if desc_txt:
380+ body.append(f"<p>{_e(desc_txt[:800])}</p>")
381+ link = d["apply_url"] or d["url"]
382+ if link:
383+ body.append(f'<p><a href="{_e(link)}" rel="nofollow">'
384+ "Postuler chez l'employeur (offre originale)</a></p>")
385+ if d["city"]:
386+ body.append(f'<p><a href="/?ville={quote(d["city"])}">'
387+ f"Autres offres d'emploi à {_e(d['city'])}</a></p>")
388+ body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>')
389+
390+ # --- JSON-LD JobPosting (schéma Google Offres d'emploi) ---
391+ posting: dict = {
392+ "@context": "https://schema.org", "@type": "JobPosting",
393+ "title": label,
394+ "description": desc_txt[:6000] or label,
395+ "datePosted": d["date_posted"] or _iso(d["first_seen"]),
396+ "hiringOrganization": {"@type": "Organization",
397+ "name": d["employer"] or SITE_NAME},
398+ "identifier": {"@type": "PropertyValue", "name": d["source"],
399+ "value": d["external_id"]},
400+ "url": BASE_URL + path,
401+ "inLanguage": {"fr": "fr-CA", "en": "en-CA"}.get(d["language"] or "", "fr-CA"),
402+ }
403+ if d["company_logo"] and str(d["company_logo"]).startswith("http"):
404+ posting["hiringOrganization"]["logo"] = d["company_logo"]
405+ address: dict = {"@type": "PostalAddress",
406+ "addressRegion": "QC", "addressCountry": "CA"}
407+ if d["city"]:
408+ address["addressLocality"] = d["city"]
409+ if d["postal_code"]:
410+ address["postalCode"] = d["postal_code"]
411+ if d["address"]:
412+ address["streetAddress"] = d["address"]
413+ posting["jobLocation"] = {"@type": "Place", "address": address}
414+ if d["work_mode"] == "teletravail":
415+ posting["jobLocationType"] = "TELECOMMUTE"
416+ posting["applicantLocationRequirements"] = {
417+ "@type": "Country", "name": "CA"}
418+ if d["employment_type"] in EMPLOYMENT_TYPE_SCHEMA:
419+ posting["employmentType"] = EMPLOYMENT_TYPE_SCHEMA[d["employment_type"]]
420+ if d["date_deadline"]:
421+ posting["validThrough"] = d["date_deadline"]
422+ if d["salary_min"] is not None and d["salary_unit"] in SALARY_UNIT_SCHEMA:
423+ value: dict = {"@type": "QuantitativeValue",
424+ "unitText": SALARY_UNIT_SCHEMA[d["salary_unit"]]}
425+ if d["salary_max"] and d["salary_max"] != d["salary_min"]:
426+ value["minValue"] = d["salary_min"]
427+ value["maxValue"] = d["salary_max"]
428+ else:
429+ value["value"] = d["salary_min"]
430+ posting["baseSalary"] = {"@type": "MonetaryAmount",
431+ "currency": "CAD", "value": value}
432+ crumbs = [("Accueil", "/"), (label, path)]
433+ # doublon inter-sources : la fiche canonique est l'offre maîtresse ;
434+ # quarantaine qualité : fiche accessible mais non indexée
435+ canonical = f"/emploi/{d['dup_of']}" if d["dup_of"] else None
436+ return _render(title=title, description=description, path=path,
437+ canonical_path=canonical,
438+ noindex=bool(d["quarantine"]),
439+ jsonld=[posting, _breadcrumb(crumbs)], body="".join(body),
440+ og_image=d["company_logo"]
441+ if d["company_logo"] and str(d["company_logo"]).startswith("http")
442+ else None)
443+
444+
445+# --- Pages statiques de l'app : head unique, contenu rendu par React ----------
446+
447+_STATIC_META = {
448+ "/carte": ("Carte des offres d'emploi au Québec | Job-Ka",
449+ "Toutes les offres d'emploi géolocalisées sur la carte du "
450+ "Québec : explorez les postes ouverts près de chez vous, "
451+ "ville par ville, avec salaire et employeur."),
452+ "/sources": ("Employeurs et sources des offres | Job-Ka",
453+ "La liste des employeurs québécois et des sources (pages "
454+ "carrières, portails) dont Job-Ka agrège les offres "
455+ "d'emploi, avec le nombre d'offres actives de chacun."),
456+ "/stats": ("Statistiques du marché de l'emploi au Québec | Job-Ka",
457+ "Salaires moyens, répartition des offres par ville, catégorie "
458+ "et mode de travail : les statistiques du marché de l'emploi "
459+ "québécois, calculées en continu par Job-Ka."),
460+ "/employeurs": ("Publier une offre d'emploi gratuitement | Job-Ka",
461+ "Employeurs québécois : publiez gratuitement votre offre "
462+ "d'emploi sur Job-Ka. Dépôt direct, modération rapide, "
463+ "visibilité auprès des chercheurs d'emploi du Québec."),
464+ "/contact": ("Nous joindre | Job-Ka",
465+ "Contactez l'équipe de Job-Ka : questions, signalement d'une "
466+ "offre, retrait d'une offre ou partenariat employeur."),
467+}
468+
469+
470+def _static_page(path: str):
471+ title, description = _STATIC_META[path]
472+ return _render(title=title, description=description, path=path,
473+ jsonld=[_breadcrumb([("Accueil", "/"),
474+ (title.split(" | ")[0], path)])])
475+
476+
477+@router.get("/carte", include_in_schema=False)
478+def carte_page():
479+ return _static_page("/carte")
480+
481+
482+@router.get("/sources", include_in_schema=False)
483+def sources_page():
484+ return _static_page("/sources")
485+
486+
487+@router.get("/stats", include_in_schema=False)
488+def stats_page():
489+ return _static_page("/stats")
490+
491+
492+@router.get("/employeurs", include_in_schema=False)
493+def employeurs_page():
494+ return _static_page("/employeurs")
495+
496+
497+@router.get("/contact", include_in_schema=False)
498+def contact_page():
499+ return _static_page("/contact")
modified jobka/web.py +6 −0
@@ -479,6 +479,12 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
479 if FRONTEND_DIST.exists(): 479 if FRONTEND_DIST.exists():
480 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") 480 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
481 481
482+ # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages
483+ # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le
484+ # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).
485+ from . import seo # noqa: E402
486+ app.include_router(seo.router)
487+
482 @app.middleware("http") 488 @app.middleware("http")
483 async def _cache_headers(request, call_next): 489 async def _cache_headers(request, call_next):
484 """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé — 490 """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —
485 491
Menu

Theme