HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/seo.py6# Rôle : Référencement — SSR léger, robots.txt, sitemaps, données structurées7# Créé : 2026-08-22 Modifié : 2026-08-228#9# Principe (pattern Lou-Ka) : pour chaque route publique, le serveur renvoie le10# 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 en12# HTML DANS <div id="root">. Les moteurs de recherche voient une page complète13# sans exécuter JavaScript ; React, en se montant, remplace ce contenu par14# l'application interactive.15# =============================================================================16from __future__ import annotations1718import html19import json20import math21import re22import time23import unicodedata24from datetime import date, datetime, timezone25from pathlib import Path26from urllib.parse import quote27from xml.sax.saxutils import escape as xml_escape2829from fastapi import APIRouter, HTTPException30from fastapi.responses import HTMLResponse, PlainTextResponse, Response3132from . import db3334router = APIRouter()3536ROOT = Path(__file__).resolve().parent.parent37FRONTEND_DIST = ROOT / "frontend" / "dist"3839BASE_URL = "https://www.job-ka.com"40SITE_NAME = "Job-Ka"4142# offre « publiée » : active, non doublon, hors quarantaine qualité43PUB = "active=1 AND dup_of IS NULL AND quarantine IS NULL"4445SITEMAP_CHUNK = 100004647# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues)48MIN_JOBS_PAGE = 34950# vocabulaires internes → valeurs schema.org (JobPosting)51EMPLOYMENT_TYPE_SCHEMA = {52 "temps_plein": "FULL_TIME",53 "temps_partiel": "PART_TIME",54 "contractuel": "CONTRACTOR",55 "stage": "INTERN",56 "saisonnier": "TEMPORARY",57}58SALARY_UNIT_SCHEMA = {"hour": "HOUR", "week": "WEEK", "year": "YEAR"}59SALARY_UNIT_FR = {"hour": "/h", "week": "/sem.", "year": "/an"}60EMPLOYMENT_TYPE_FR = {61 "temps_plein": "temps plein",62 "temps_partiel": "temps partiel",63 "contractuel": "contractuel",64 "stage": "stage",65 "saisonnier": "saisonnier",66}67WORK_MODE_FR = {68 "presentiel": "présentiel",69 "hybride": "hybride",70 "teletravail": "télétravail",71}727374# --- Gabarit (index.html du build Vite) --------------------------------------7576_shell_cache: dict = {"mtime": 0.0, "html": ""}777879def _shell() -> str:80 f = FRONTEND_DIST / "index.html"81 mtime = f.stat().st_mtime82 if mtime != _shell_cache["mtime"]:83 _shell_cache["html"] = f.read_text(encoding="utf-8")84 _shell_cache["mtime"] = mtime85 return _shell_cache["html"]868788def _render(*, title: str, description: str, path: str,89 jsonld: list[dict] | None = None, body: str = "",90 og_image: str | None = None, canonical_path: str | None = None,91 noindex: bool = False, status: int = 200) -> HTMLResponse:92 """index.html du build + head unique + contenu HTML dans #root."""93 canonical = BASE_URL + (canonical_path if canonical_path is not None else path)94 page = _shell()95 page = re.sub(r"<title>.*?</title>",96 lambda _m: f"<title>{html.escape(title)}</title>",97 page, count=1, flags=re.S)98 page = re.sub(r'<meta name="description"[^>]*/>',99 lambda _m: ('<meta name="description" content="'100 f'{html.escape(description, quote=True)}" />'),101 page, count=1)102 # retire du gabarit statique les meta og:/twitter génériques (re-injectées)103 page = re.sub(104 r'\s*<meta (?:property="og:[^"]*"|name="twitter:[^"]*")[^>]*/>', "", page)105 extras = [106 f'<link rel="canonical" href="{canonical}" />',107 f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />',108 f'<link rel="alternate" hreflang="x-default" href="{canonical}" />',109 f'<meta property="og:site_name" content="{SITE_NAME}" />',110 '<meta property="og:locale" content="fr_CA" />',111 '<meta property="og:type" content="website" />',112 f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',113 f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',114 f'<meta property="og:url" content="{canonical}" />',115 '<meta name="twitter:card" content="summary_large_image" />',116 f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',117 ]118 if noindex:119 extras.insert(0, '<meta name="robots" content="noindex" />')120 img = og_image or (BASE_URL + "/og.png")121 extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')122 if not og_image:123 extras.append('<meta property="og:image:width" content="1200" />')124 extras.append('<meta property="og:image:height" content="630" />')125 extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')126 for obj in (jsonld or []):127 blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")128 extras.append(f'<script type="application/ld+json">{blob}</script>')129 page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1)130 if body:131 seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'132 'font-family:system-ui,sans-serif;color:#101418">' + body133 + '<p>Job-Ka — Un service '134 '<a href="https://www.groupe-ka.com">Groupe KA</a></p>'135 + "</div>")136 page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)137 return HTMLResponse(page, status_code=status,138 headers={"Cache-Control": "no-cache"})139140141def _e(t) -> str:142 return html.escape(str(t or ""))143144145def _iso(ts) -> str:146 if not ts:147 return date.today().isoformat()148 return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()149150151def _fmt_amount(v: float) -> str:152 """87500.0 → « 87 500 $ » ; 25.5 → « 25,50 $ »."""153 if float(v) == int(v) and v >= 1000:154 return f"{int(v):,} $".replace(",", " ")155 return f"{v:,.2f} $".replace(",", " ").replace(".", ",")156157158def _salary_txt(d: dict) -> str:159 """Texte fr-CA du salaire (« 25,00 $ à 30,00 $/h »), sinon salary_label."""160 if d.get("salary_min") is not None:161 unit = SALARY_UNIT_FR.get(d.get("salary_unit") or "", "")162 lo = _fmt_amount(d["salary_min"])163 if d.get("salary_max") and d["salary_max"] != d["salary_min"]:164 return f"{lo} à {_fmt_amount(d['salary_max'])}{unit}"165 return f"{lo}{unit}"166 return (d.get("salary_label") or "").strip()167168169def _job_li(r) -> str:170 """Une offre dans une liste HTML serveur."""171 label = r["title_clean"] or r["title"] or r["uid"]172 bits = [b for b in (r["employer"], r["city"],173 _salary_txt(dict(r))) if b]174 return (f'<li><a href="/emploi/{_e(r["uid"])}">{_e(label)}</a>'175 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')176177178def slugify(text: str) -> str:179 """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot."""180 t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae")181 t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()182 t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")183 return t184185186_villes_cache: dict = {"ts": 0.0, "by_slug": {}}187188189def _villes_index() -> dict[str, dict]:190 """slug → {city (libellé majoritaire), cities (variantes), n, employers,191 with_salary} — les variantes de casse/accents d'une même ville sont192 regroupées par slug. Reconstruit au plus toutes les 10 minutes."""193 if time.time() - _villes_cache["ts"] > 600:194 con = db.connect()195 rows = con.execute(196 f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers,197 SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END)198 with_salary199 FROM jobs WHERE {PUB} AND city<>''200 GROUP BY city""").fetchall()201 con.close()202 by_slug: dict[str, dict] = {}203 for r in rows:204 s = slugify(r["city"])205 if not s:206 continue207 e = by_slug.setdefault(s, {"slug": s, "city": r["city"],208 "cities": [], "n": 0, "employers": 0,209 "with_salary": 0, "_best": -1})210 e["cities"].append(r["city"])211 e["n"] += r["n"]212 e["employers"] += r["employers"]213 e["with_salary"] += r["with_salary"] or 0214 if r["n"] > e["_best"]:215 e["_best"] = r["n"]216 e["city"] = r["city"]217 _villes_cache["by_slug"] = by_slug218 _villes_cache["ts"] = time.time()219 return _villes_cache["by_slug"]220221222def _villes_rows(_con=None, minimum: int = 1) -> list[dict]:223 rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"],224 "employers": e["employers"], "with_salary": e["with_salary"]}225 for e in _villes_index().values() if e["n"] >= minimum]226 return sorted(rows, key=lambda r: -r["n"])227228229def _breadcrumb(items: list[tuple[str, str]]) -> dict:230 return {"@context": "https://schema.org", "@type": "BreadcrumbList",231 "itemListElement": [232 {"@type": "ListItem", "position": i + 1, "name": name,233 "item": BASE_URL + path}234 for i, (name, path) in enumerate(items)]}235236237def _not_found(message: str, path: str) -> HTMLResponse:238 """404 HTML : le shell React est servi (la SPA affichera sa page), mais le239 statut et le contenu serveur disent clairement « introuvable » aux bots."""240 return _render(title="Page introuvable | Job-Ka",241 description="Cette page n'existe pas sur Job-Ka.",242 path=path,243 body=f"<h1>{_e(message)}</h1>"244 '<p><a href="/">Voir toutes les offres d\'emploi au '245 "Québec</a></p>",246 status=404)247248249# --- API JSON pour les pages villes du frontend --------------------------------250251@router.get("/api/seo/villes")252def api_villes():253 con = db.connect()254 rows = _villes_rows(con)255 con.close()256 return {"villes": rows}257258259@router.get("/api/seo/ville/{slug}")260def api_ville(slug: str):261 e = _villes_index().get(slug)262 if not e:263 raise HTTPException(404, "Ville inconnue")264 neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE)265 if v["slug"] != slug][:12]266 return {"city": e["city"], "slug": slug, "n": e["n"],267 "employers": e["employers"], "with_salary": e["with_salary"],268 "neighbors": neighbors}269270271# --- robots.txt & sitemaps ----------------------------------------------------272273@router.get("/robots.txt", include_in_schema=False)274def robots() -> PlainTextResponse:275 return PlainTextResponse(276 "User-agent: *\n"277 "Allow: /\n"278 "Disallow: /api/\n"279 "Disallow: /health\n"280 f"\nSitemap: {BASE_URL}/sitemap.xml\n")281282283def _xml(content: str) -> Response:284 return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,285 media_type="application/xml",286 headers={"Cache-Control": "public, max-age=3600"})287288289def _urlset(urls: list[tuple[str, str | None]]) -> Response:290 rows = []291 for loc, lastmod in urls:292 lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""293 rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")294 return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'295 + "\n".join(rows) + "\n</urlset>")296297298@router.get("/sitemap.xml", include_in_schema=False)299def sitemap_index():300 con = db.connect()301 total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"]302 con.close()303 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))304 names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [305 f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)]306 today = date.today().isoformat()307 rows = "\n".join(308 f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"309 for n in names)310 return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'311 + rows + "\n</sitemapindex>")312313314@router.get("/sitemap-pages.xml", include_in_schema=False)315def sitemap_pages():316 urls: list[tuple[str, str | None]] = [317 (f"{BASE_URL}/", None),318 (f"{BASE_URL}/villes", None),319 (f"{BASE_URL}/carte", None),320 (f"{BASE_URL}/sources", None),321 (f"{BASE_URL}/stats", None),322 (f"{BASE_URL}/employeurs", None),323 (f"{BASE_URL}/contact", None)]324 return _urlset(urls)325326327@router.get("/sitemap-villes.xml", include_in_schema=False)328def sitemap_villes():329 con = db.connect()330 villes = _villes_rows(con, MIN_JOBS_PAGE)331 con.close()332 today = date.today().isoformat()333 return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes])334335336@router.get("/sitemap-emplois-{num}.xml", include_in_schema=False)337def sitemap_emplois(num: int):338 if num < 1:339 raise HTTPException(404)340 con = db.connect()341 rows = con.execute(342 f"""SELECT uid, updated_at FROM jobs WHERE {PUB}343 ORDER BY uid LIMIT ? OFFSET ?""",344 (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()345 con.close()346 if not rows:347 raise HTTPException(404)348 return _urlset([(f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}",349 _iso(r["updated_at"])) for r in rows])350351352# --- Pages SSR ----------------------------------------------------------------353354def _fmt_n(n: int) -> str:355 return f"{n:,}".replace(",", " ")356357358@router.get("/", include_in_schema=False)359def home_ssr():360 con = db.connect()361 agg = con.execute(362 f"""SELECT COUNT(*) total, COUNT(DISTINCT employer) employers,363 COUNT(DISTINCT source) sources364 FROM jobs WHERE {PUB}""").fetchone()365 total, employers, nsources = agg["total"], agg["employers"], agg["sources"]366 villes = _villes_rows(con, MIN_JOBS_PAGE)367 with_salary = con.execute(368 f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL"369 ).fetchone()["c"]370 recents = con.execute(371 f"""SELECT uid, title, title_clean, employer, city, salary_min,372 salary_max, salary_unit, salary_label373 FROM jobs WHERE {PUB}374 ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC375 LIMIT 25""").fetchall()376 con.close()377378 title = f"Offres d'emploi au Québec — {_fmt_n(total)} postes | Job-Ka"379 description = (380 f"{_fmt_n(total)} offres d'emploi de {_fmt_n(employers)} employeurs "381 f"québécois, agrégées directement à la source ({nsources} sources : "382 "pages carrières et portails). Salaires transparents "383 f"({_fmt_n(with_salary)} offres avec salaire affiché), filtres par "384 "ville, région et métier, carte interactive.")385 body = (386 f"<h1>Offres d'emploi au Québec — {_fmt_n(total)} postes à pourvoir</h1>"387 + f"<p>Job-Ka recense les offres d'emploi de {_fmt_n(employers)} "388 "employeurs québécois, directement à la source : chaque offre est "389 "traçable à sa page carrière ou à son portail d'origine, avec le "390 f"salaire affiché quand il est publié ({_fmt_n(with_salary)} offres "391 "avec transparence salariale).</p>"392 + "<h2>Emplois par ville</h2><ul>"393 + "".join(394 f'<li><a href="/ville/{v["slug"]}">Emplois à '395 f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes[:30])396 + "</ul>"397 + f'<p><a href="/villes">Toutes les villes ({len(villes)})</a></p>'398 + "<h2>Dernières offres publiées</h2><ul>"399 + "".join(_job_li(r) for r in recents)400 + "</ul>"401 + '<p><a href="/carte">Voir les offres sur la carte</a> · '402 '<a href="/sources">Employeurs et sources</a> · '403 '<a href="/stats">Statistiques du marché de l\'emploi</a> · '404 '<a href="/employeurs">Publier une offre</a></p>')405 jsonld = [406 {"@context": "https://schema.org", "@type": "WebSite",407 "name": SITE_NAME, "url": BASE_URL + "/",408 "description": description, "inLanguage": "fr-CA"},409 {"@context": "https://schema.org", "@type": "Organization",410 "name": "Job-Ka (Groupe KA)", "url": BASE_URL + "/",411 "logo": BASE_URL + "/og.png"}]412 return _render(title=title, description=description, path="/",413 jsonld=jsonld, body=body)414415416@router.get("/emploi/{uid:path}", include_in_schema=False)417def job_ssr(uid: str):418 con = db.connect()419 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()420 con.close()421 path = f"/emploi/{uid}"422 if row is None:423 return _render(title="Offre introuvable | Job-Ka",424 description="Cette offre d'emploi n'existe pas ou plus sur Job-Ka.",425 path=path,426 body="<h1>Offre introuvable</h1>"427 '<p><a href="/">Voir toutes les offres d\'emploi '428 "au Québec</a></p>",429 status=404)430 d = dict(row)431 if not d["active"]:432 # offre retirée chez la source : 410 Gone + lien vers l'accueil433 return _render(434 title="Offre retirée | Job-Ka",435 description="Cette offre d'emploi a été retirée par l'employeur.",436 path=path,437 body="<h1>Cette offre n'est plus disponible</h1>"438 "<p>Elle a été retirée par l'employeur ou le poste est comblé. "439 '<a href="/">Voir les offres d\'emploi actuellement '440 "ouvertes au Québec</a>.</p>",441 status=410)442443 label = d["title_clean"] or d["title"] or "Offre d'emploi"444 where_bits = [b for b in (d["employer"], d["city"]) if b]445 title = f"{label}{' — ' + ', '.join(where_bits) if where_bits else ''} | Job-Ka"446447 salary_txt = _salary_txt(d)448 etype_fr = EMPLOYMENT_TYPE_FR.get(d["employment_type"] or "", "")449 mode_fr = WORK_MODE_FR.get(d["work_mode"] or "", "")450 bits = [b for b in (d["employer"], d["city"] or d["region"], etype_fr,451 mode_fr, salary_txt) if b]452 desc_txt = re.sub(r"\s+", " ", d["description"] or "").strip()453 description = (" · ".join(bits) + ". " if bits else "") + \454 (desc_txt[:160].strip() + "…" if len(desc_txt) > 160 else desc_txt)455 description = description[:300] or \456 f"Offre d'emploi{' à ' + d['city'] if d['city'] else ' au Québec'} sur Job-Ka."457458 facts = [("Employeur", d["employer"]), ("Ville", d["city"]),459 ("Région", d["region"]), ("Type d'emploi", etype_fr),460 ("Mode de travail", mode_fr), ("Salaire", salary_txt),461 ("Publiée le", d["date_posted"]),462 ("Postuler avant le", d["date_deadline"])]463 body = [f"<h1>{_e(label)}{' — ' + _e(d['employer']) if d['employer'] else ''}</h1>",464 "<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"465 for k, v in facts if v) + "</ul>"]466 if desc_txt:467 body.append(f"<p>{_e(desc_txt[:800])}</p>")468 link = d["apply_url"] or d["url"]469 if link:470 body.append(f'<p><a href="{_e(link)}" rel="nofollow">'471 "Postuler chez l'employeur (offre originale)</a></p>")472 if d["city"]:473 body.append(f'<p><a href="/ville/{slugify(d["city"])}">'474 f"Autres offres d'emploi à {_e(d['city'])}</a></p>")475 body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>')476477 # --- JSON-LD JobPosting (schéma Google Offres d'emploi) ---478 posting: dict = {479 "@context": "https://schema.org", "@type": "JobPosting",480 "title": label,481 "description": desc_txt[:6000] or label,482 "datePosted": d["date_posted"] or _iso(d["first_seen"]),483 "hiringOrganization": {"@type": "Organization",484 "name": d["employer"] or SITE_NAME},485 "identifier": {"@type": "PropertyValue", "name": d["source"],486 "value": d["external_id"]},487 "url": BASE_URL + path,488 "inLanguage": {"fr": "fr-CA", "en": "en-CA"}.get(d["language"] or "", "fr-CA"),489 }490 if d["company_logo"] and str(d["company_logo"]).startswith("http"):491 posting["hiringOrganization"]["logo"] = d["company_logo"]492 address: dict = {"@type": "PostalAddress",493 "addressRegion": "QC", "addressCountry": "CA"}494 if d["city"]:495 address["addressLocality"] = d["city"]496 if d["postal_code"]:497 address["postalCode"] = d["postal_code"]498 if d["address"]:499 address["streetAddress"] = d["address"]500 posting["jobLocation"] = {"@type": "Place", "address": address}501 if d["work_mode"] == "teletravail":502 posting["jobLocationType"] = "TELECOMMUTE"503 posting["applicantLocationRequirements"] = {504 "@type": "Country", "name": "CA"}505 if d["employment_type"] in EMPLOYMENT_TYPE_SCHEMA:506 posting["employmentType"] = EMPLOYMENT_TYPE_SCHEMA[d["employment_type"]]507 if d["date_deadline"]:508 posting["validThrough"] = d["date_deadline"]509 if d["salary_min"] is not None and d["salary_unit"] in SALARY_UNIT_SCHEMA:510 value: dict = {"@type": "QuantitativeValue",511 "unitText": SALARY_UNIT_SCHEMA[d["salary_unit"]]}512 if d["salary_max"] and d["salary_max"] != d["salary_min"]:513 value["minValue"] = d["salary_min"]514 value["maxValue"] = d["salary_max"]515 else:516 value["value"] = d["salary_min"]517 posting["baseSalary"] = {"@type": "MonetaryAmount",518 "currency": "CAD", "value": value}519 crumbs = [("Accueil", "/"), (label, path)]520 # doublon inter-sources : la fiche canonique est l'offre maîtresse ;521 # quarantaine qualité : fiche accessible mais non indexée522 canonical = f"/emploi/{d['dup_of']}" if d["dup_of"] else None523 return _render(title=title, description=description, path=path,524 canonical_path=canonical,525 noindex=bool(d["quarantine"]),526 jsonld=[posting, _breadcrumb(crumbs)], body="".join(body),527 og_image=d["company_logo"]528 if d["company_logo"] and str(d["company_logo"]).startswith("http")529 else None)530531532# --- Pages programmatiques villes : /villes + /ville/{slug} -------------------533534@router.get("/villes", include_in_schema=False)535def villes_ssr():536 con = db.connect()537 villes = _villes_rows(con)538 con.close()539 total = sum(v["n"] for v in villes)540 title = f"Offres d'emploi par ville au Québec ({len(villes)} villes) | Job-Ka"541 description = (542 f"Toutes les villes du Québec où Job-Ka recense des offres d'emploi : "543 f"{_fmt_n(total)} postes dans {len(villes)} villes, avec le nombre "544 "d'offres et d'employeurs par ville.")545 body = (f"<h1>Offres d'emploi par ville — {len(villes)} villes au Québec</h1><ul>"546 + "".join(547 f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>'548 f' — {v["n"]} offres de {v["employers"]} employeur'549 f'{"s" if v["employers"] > 1 else ""}</li>' for v in villes)550 + "</ul>")551 jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]),552 {"@context": "https://schema.org", "@type": "ItemList",553 "name": "Offres d'emploi par ville au Québec",554 "numberOfItems": len(villes),555 "itemListElement": [556 {"@type": "ListItem", "position": i + 1,557 "name": f"Emplois à {v['city']}",558 "url": f"{BASE_URL}/ville/{v['slug']}"}559 for i, v in enumerate(villes[:100])]}]560 return _render(title=title, description=description, path="/villes",561 jsonld=jsonld, body=body)562563564@router.get("/ville/{slug}", include_in_schema=False)565def ville_ssr(slug: str):566 path = f"/ville/{slug}"567 e = _villes_index().get(slug)568 if not e:569 return _not_found("Aucune offre d'emploi recensée pour cette ville", path)570 city, n = e["city"], e["n"]571 marks = ",".join("?" * len(e["cities"]))572 con = db.connect()573 rows = con.execute(574 f"""SELECT uid, title, title_clean, employer, city, salary_min,575 salary_max, salary_unit, salary_label576 FROM jobs WHERE {PUB} AND city IN ({marks})577 ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC578 LIMIT 100""", e["cities"]).fetchall()579 con.close()580 neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE)581 if v["slug"] != slug][:12]582 if n == 0:583 return _not_found(f"Aucune offre d'emploi active à {city}", path)584585 title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka"586 description = (587 f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(e['employers'])} "588 f"employeurs, dont {_fmt_n(e['with_salary'])} avec salaire affiché. "589 "Offres agrégées à la source, à jour, avec lien direct vers la page "590 "carrière de l'employeur.")591 body = [f"<h1>Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes</h1>",592 f"<p>{_fmt_n(e['employers'])} employeurs recrutent à {_e(city)}"593 f" ; {_fmt_n(e['with_salary'])} offres affichent le salaire.</p>",594 "<h2>Offres récentes</h2><ul>"595 + "".join(_job_li(r) for r in rows) + "</ul>"]596 if neighbors:597 body.append("<h2>Autres villes</h2><ul>" + "".join(598 f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>'599 f' — {v["n"]}</li>' for v in neighbors) + "</ul>")600 body.append('<p><a href="/villes">Toutes les villes</a> · '601 '<a href="/">Toutes les offres d\'emploi au Québec</a></p>')602 crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)]603 jsonld = [_breadcrumb(crumbs),604 {"@context": "https://schema.org", "@type": "ItemList",605 "name": f"Offres d'emploi à {city}", "numberOfItems": n,606 "itemListElement": [607 {"@type": "ListItem", "position": i + 1,608 "name": r["title_clean"] or r["title"] or r["uid"],609 "url": f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}"}610 for i, r in enumerate(rows[:50])]}]611 return _render(title=title, description=description, path=path,612 jsonld=jsonld, body="".join(body))613614615# --- Pages statiques de l'app : head unique, contenu rendu par React ----------616617_STATIC_META = {618 "/carte": ("Carte des offres d'emploi au Québec | Job-Ka",619 "Toutes les offres d'emploi géolocalisées sur la carte du "620 "Québec : explorez les postes ouverts près de chez vous, "621 "ville par ville, avec salaire et employeur."),622 "/sources": ("Employeurs et sources des offres | Job-Ka",623 "La liste des employeurs québécois et des sources (pages "624 "carrières, portails) dont Job-Ka agrège les offres "625 "d'emploi, avec le nombre d'offres actives de chacun."),626 "/stats": ("Statistiques du marché de l'emploi au Québec | Job-Ka",627 "Salaires moyens, répartition des offres par ville, catégorie "628 "et mode de travail : les statistiques du marché de l'emploi "629 "québécois, calculées en continu par Job-Ka."),630 "/employeurs": ("Publier une offre d'emploi gratuitement | Job-Ka",631 "Employeurs québécois : publiez gratuitement votre offre "632 "d'emploi sur Job-Ka. Dépôt direct, modération rapide, "633 "visibilité auprès des chercheurs d'emploi du Québec."),634 "/contact": ("Nous joindre | Job-Ka",635 "Contactez l'équipe de Job-Ka : questions, signalement d'une "636 "offre, retrait d'une offre ou partenariat employeur."),637}638639640def _static_page(path: str):641 title, description = _STATIC_META[path]642 return _render(title=title, description=description, path=path,643 jsonld=[_breadcrumb([("Accueil", "/"),644 (title.split(" | ")[0], path)])])645646647@router.get("/carte", include_in_schema=False)648def carte_page():649 return _static_page("/carte")650651652@router.get("/sources", include_in_schema=False)653def sources_page():654 return _static_page("/sources")655656657@router.get("/stats", include_in_schema=False)658def stats_page():659 return _static_page("/stats")660661662@router.get("/employeurs", include_in_schema=False)663def employeurs_page():664 return _static_page("/employeurs")665666667@router.get("/contact", include_in_schema=False)668def contact_page():669 return _static_page("/contact")670