SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
4 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
28.6 KB · 670 lines python
Raw Blame History
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