SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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 1 mo ago (Aug 22, 2026) parent 178ecdb

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 9 Créé : 2026-08-17 Modifié : 2026-08-17
10 10 =============================================================================
11 11 -->
12 −<html lang="fr">
12 +<html lang="fr-CA">
13 13 <head>
14 14 <meta charset="UTF-8" />
15 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 479 if FRONTEND_DIST.exists():
480 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 488 @app.middleware("http")
483 489 async def _cache_headers(request, call_next):
484 490 """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —
485 491