# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/seo.py # Rôle : Référencement — SSR léger, robots.txt, sitemaps, données structurées # Créé : 2026-08-22 Modifié : 2026-08-22 # # Principe (pattern Lou-Ka) : pour chaque route publique, le serveur renvoie le # MÊME index.html que le build Vite, mais avec un unique (title, # description, canonical, hreflang, og:, JSON-LD) et le contenu essentiel en # HTML DANS
. Les moteurs de recherche voient une page complète # sans exécuter JavaScript ; React, en se montant, remplace ce contenu par # l'application interactive. # ============================================================================= from __future__ import annotations import html import json import math import re import time import unicodedata from datetime import date, datetime, timezone from pathlib import Path from urllib.parse import quote from xml.sax.saxutils import escape as xml_escape from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" BASE_URL = "https://www.job-ka.com" SITE_NAME = "Job-Ka" # offre « publiée » : active, non doublon, hors quarantaine qualité PUB = "active=1 AND dup_of IS NULL AND quarantine IS NULL" SITEMAP_CHUNK = 10000 # seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues) MIN_JOBS_PAGE = 3 # vocabulaires internes → valeurs schema.org (JobPosting) EMPLOYMENT_TYPE_SCHEMA = { "temps_plein": "FULL_TIME", "temps_partiel": "PART_TIME", "contractuel": "CONTRACTOR", "stage": "INTERN", "saisonnier": "TEMPORARY", } SALARY_UNIT_SCHEMA = {"hour": "HOUR", "week": "WEEK", "year": "YEAR"} SALARY_UNIT_FR = {"hour": "/h", "week": "/sem.", "year": "/an"} EMPLOYMENT_TYPE_FR = { "temps_plein": "temps plein", "temps_partiel": "temps partiel", "contractuel": "contractuel", "stage": "stage", "saisonnier": "saisonnier", } WORK_MODE_FR = { "presentiel": "présentiel", "hybride": "hybride", "teletravail": "télétravail", } # --- Gabarit (index.html du build Vite) -------------------------------------- _shell_cache: dict = {"mtime": 0.0, "html": ""} def _shell() -> str: f = FRONTEND_DIST / "index.html" mtime = f.stat().st_mtime if mtime != _shell_cache["mtime"]: _shell_cache["html"] = f.read_text(encoding="utf-8") _shell_cache["mtime"] = mtime return _shell_cache["html"] def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None, body: str = "", og_image: str | None = None, canonical_path: str | None = None, noindex: bool = False, status: int = 200) -> HTMLResponse: """index.html du build + head unique + contenu HTML dans #root.""" canonical = BASE_URL + (canonical_path if canonical_path is not None else path) page = _shell() page = re.sub(r".*?", lambda _m: f"{html.escape(title)}", page, count=1, flags=re.S) page = re.sub(r']*/>', lambda _m: (''), page, count=1) # retire du gabarit statique les meta og:/twitter génériques (re-injectées) page = re.sub( r'\s*]*/>', "", page) extras = [ f'', f'', f'', f'', '', '', f'', f'', f'', '', f'', ] if noindex: extras.insert(0, '') img = og_image or (BASE_URL + "/og.png") extras.append(f'') if not og_image: extras.append('') extras.append('') extras.append(f'') for obj in (jsonld or []): blob = json.dumps(obj, ensure_ascii=False).replace("{blob}') page = page.replace("", " " + "\n ".join(extras) + "\n", 1) if body: seo_div = ('
' + body + '

Job-Ka — Un service ' 'Groupe KA

' + "
") page = page.replace('
', '
' + seo_div, 1) return HTMLResponse(page, status_code=status, headers={"Cache-Control": "no-cache"}) def _e(t) -> str: return html.escape(str(t or "")) def _iso(ts) -> str: if not ts: return date.today().isoformat() return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() def _fmt_amount(v: float) -> str: """87500.0 → « 87 500 $ » ; 25.5 → « 25,50 $ ».""" if float(v) == int(v) and v >= 1000: return f"{int(v):,} $".replace(",", " ") return f"{v:,.2f} $".replace(",", " ").replace(".", ",") def _salary_txt(d: dict) -> str: """Texte fr-CA du salaire (« 25,00 $ à 30,00 $/h »), sinon salary_label.""" if d.get("salary_min") is not None: unit = SALARY_UNIT_FR.get(d.get("salary_unit") or "", "") lo = _fmt_amount(d["salary_min"]) if d.get("salary_max") and d["salary_max"] != d["salary_min"]: return f"{lo} à {_fmt_amount(d['salary_max'])}{unit}" return f"{lo}{unit}" return (d.get("salary_label") or "").strip() def _job_li(r) -> str: """Une offre dans une liste HTML serveur.""" label = r["title_clean"] or r["title"] or r["uid"] bits = [b for b in (r["employer"], r["city"], _salary_txt(dict(r))) if b] return (f'
  • {_e(label)}' f'{" — " + _e(" · ".join(bits)) if bits else ""}
  • ') def slugify(text: str) -> str: """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot.""" t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae") t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") return t _villes_cache: dict = {"ts": 0.0, "by_slug": {}} def _villes_index() -> dict[str, dict]: """slug → {city (libellé majoritaire), cities (variantes), n, employers, with_salary} — les variantes de casse/accents d'une même ville sont regroupées par slug. Reconstruit au plus toutes les 10 minutes.""" if time.time() - _villes_cache["ts"] > 600: con = db.connect() rows = con.execute( f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers, SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary FROM jobs WHERE {PUB} AND city<>'' GROUP BY city""").fetchall() con.close() by_slug: dict[str, dict] = {} for r in rows: s = slugify(r["city"]) if not s: continue e = by_slug.setdefault(s, {"slug": s, "city": r["city"], "cities": [], "n": 0, "employers": 0, "with_salary": 0, "_best": -1}) e["cities"].append(r["city"]) e["n"] += r["n"] e["employers"] += r["employers"] e["with_salary"] += r["with_salary"] or 0 if r["n"] > e["_best"]: e["_best"] = r["n"] e["city"] = r["city"] _villes_cache["by_slug"] = by_slug _villes_cache["ts"] = time.time() return _villes_cache["by_slug"] def _villes_rows(_con=None, minimum: int = 1) -> list[dict]: rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"], "employers": e["employers"], "with_salary": e["with_salary"]} for e in _villes_index().values() if e["n"] >= minimum] return sorted(rows, key=lambda r: -r["n"]) def _breadcrumb(items: list[tuple[str, str]]) -> dict: return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE_URL + path} for i, (name, path) in enumerate(items)]} def _not_found(message: str, path: str) -> HTMLResponse: """404 HTML : le shell React est servi (la SPA affichera sa page), mais le statut et le contenu serveur disent clairement « introuvable » aux bots.""" return _render(title="Page introuvable | Job-Ka", description="Cette page n'existe pas sur Job-Ka.", path=path, body=f"

    {_e(message)}

    " '

    Voir toutes les offres d\'emploi au ' "Québec

    ", status=404) # --- API JSON pour les pages villes du frontend -------------------------------- @router.get("/api/seo/villes") def api_villes(): con = db.connect() rows = _villes_rows(con) con.close() return {"villes": rows} @router.get("/api/seo/ville/{slug}") def api_ville(slug: str): e = _villes_index().get(slug) if not e: raise HTTPException(404, "Ville inconnue") neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE) if v["slug"] != slug][:12] return {"city": e["city"], "slug": slug, "n": e["n"], "employers": e["employers"], "with_salary": e["with_salary"], "neighbors": neighbors} # --- robots.txt & sitemaps ---------------------------------------------------- @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /health\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(content: str) -> Response: return Response('\n' + content, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"}) def _urlset(urls: list[tuple[str, str | None]]) -> Response: rows = [] for loc, lastmod in urls: lm = f"{lastmod}" if lastmod else "" rows.append(f"{xml_escape(loc)}{lm}") return _xml('\n' + "\n".join(rows) + "\n") @router.get("/sitemap.xml", include_in_schema=False) def sitemap_index(): con = db.connect() total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"] con.close() chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)] today = date.today().isoformat() rows = "\n".join( f"{BASE_URL}/{n}{today}" for n in names) return _xml('\n' + rows + "\n") @router.get("/sitemap-pages.xml", include_in_schema=False) def sitemap_pages(): urls: list[tuple[str, str | None]] = [ (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), (f"{BASE_URL}/carte", None), (f"{BASE_URL}/sources", None), (f"{BASE_URL}/stats", None), (f"{BASE_URL}/employeurs", None), (f"{BASE_URL}/contact", None)] return _urlset(urls) @router.get("/sitemap-villes.xml", include_in_schema=False) def sitemap_villes(): con = db.connect() villes = _villes_rows(con, MIN_JOBS_PAGE) con.close() today = date.today().isoformat() return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes]) @router.get("/sitemap-emplois-{num}.xml", include_in_schema=False) def sitemap_emplois(num: int): if num < 1: raise HTTPException(404) con = db.connect() rows = con.execute( f"""SELECT uid, updated_at FROM jobs WHERE {PUB} ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() con.close() if not rows: raise HTTPException(404) return _urlset([(f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}", _iso(r["updated_at"])) for r in rows]) # --- Pages SSR ---------------------------------------------------------------- def _fmt_n(n: int) -> str: return f"{n:,}".replace(",", " ") @router.get("/", include_in_schema=False) def home_ssr(): con = db.connect() agg = con.execute( f"""SELECT COUNT(*) total, COUNT(DISTINCT employer) employers, COUNT(DISTINCT source) sources FROM jobs WHERE {PUB}""").fetchone() total, employers, nsources = agg["total"], agg["employers"], agg["sources"] villes = _villes_rows(con, MIN_JOBS_PAGE) with_salary = con.execute( f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL" ).fetchone()["c"] recents = con.execute( f"""SELECT uid, title, title_clean, employer, city, salary_min, salary_max, salary_unit, salary_label FROM jobs WHERE {PUB} ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC LIMIT 25""").fetchall() con.close() title = f"Offres d'emploi au Québec — {_fmt_n(total)} postes | Job-Ka" description = ( f"{_fmt_n(total)} offres d'emploi de {_fmt_n(employers)} employeurs " f"québécois, agrégées directement à la source ({nsources} sources : " "pages carrières et portails). Salaires transparents " f"({_fmt_n(with_salary)} offres avec salaire affiché), filtres par " "ville, région et métier, carte interactive.") body = ( f"

    Offres d'emploi au Québec — {_fmt_n(total)} postes à pourvoir

    " + f"

    Job-Ka recense les offres d'emploi de {_fmt_n(employers)} " "employeurs québécois, directement à la source : chaque offre est " "traçable à sa page carrière ou à son portail d'origine, avec le " f"salaire affiché quand il est publié ({_fmt_n(with_salary)} offres " "avec transparence salariale).

    " + "

    Emplois par ville

    " + f'

    Toutes les villes ({len(villes)})

    ' + "

    Dernières offres publiées

      " + "".join(_job_li(r) for r in recents) + "
    " + '

    Voir les offres sur la carte · ' 'Employeurs et sources · ' 'Statistiques du marché de l\'emploi · ' 'Publier une offre

    ') jsonld = [ {"@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": BASE_URL + "/", "description": description, "inLanguage": "fr-CA"}, {"@context": "https://schema.org", "@type": "Organization", "name": "Job-Ka (Groupe KA)", "url": BASE_URL + "/", "logo": BASE_URL + "/og.png"}] return _render(title=title, description=description, path="/", jsonld=jsonld, body=body) @router.get("/emploi/{uid:path}", include_in_schema=False) def job_ssr(uid: str): con = db.connect() row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() con.close() path = f"/emploi/{uid}" if row is None: return _render(title="Offre introuvable | Job-Ka", description="Cette offre d'emploi n'existe pas ou plus sur Job-Ka.", path=path, body="

    Offre introuvable

    " '

    Voir toutes les offres d\'emploi ' "au Québec

    ", status=404) d = dict(row) if not d["active"]: # offre retirée chez la source : 410 Gone + lien vers l'accueil return _render( title="Offre retirée | Job-Ka", description="Cette offre d'emploi a été retirée par l'employeur.", path=path, body="

    Cette offre n'est plus disponible

    " "

    Elle a été retirée par l'employeur ou le poste est comblé. " 'Voir les offres d\'emploi actuellement ' "ouvertes au Québec.

    ", status=410) label = d["title_clean"] or d["title"] or "Offre d'emploi" where_bits = [b for b in (d["employer"], d["city"]) if b] title = f"{label}{' — ' + ', '.join(where_bits) if where_bits else ''} | Job-Ka" salary_txt = _salary_txt(d) etype_fr = EMPLOYMENT_TYPE_FR.get(d["employment_type"] or "", "") mode_fr = WORK_MODE_FR.get(d["work_mode"] or "", "") bits = [b for b in (d["employer"], d["city"] or d["region"], etype_fr, mode_fr, salary_txt) if b] desc_txt = re.sub(r"\s+", " ", d["description"] or "").strip() description = (" · ".join(bits) + ". " if bits else "") + \ (desc_txt[:160].strip() + "…" if len(desc_txt) > 160 else desc_txt) description = description[:300] or \ f"Offre d'emploi{' à ' + d['city'] if d['city'] else ' au Québec'} sur Job-Ka." facts = [("Employeur", d["employer"]), ("Ville", d["city"]), ("Région", d["region"]), ("Type d'emploi", etype_fr), ("Mode de travail", mode_fr), ("Salaire", salary_txt), ("Publiée le", d["date_posted"]), ("Postuler avant le", d["date_deadline"])] body = [f"

    {_e(label)}{' — ' + _e(d['employer']) if d['employer'] else ''}

    ", "
      " + "".join(f"
    • {k} : {_e(v)}
    • " for k, v in facts if v) + "
    "] if desc_txt: body.append(f"

    {_e(desc_txt[:800])}

    ") link = d["apply_url"] or d["url"] if link: body.append(f'

    ' "Postuler chez l'employeur (offre originale)

    ") if d["city"]: body.append(f'

    ' f"Autres offres d'emploi à {_e(d['city'])}

    ") body.append('

    Toutes les offres d\'emploi au Québec

    ') # --- JSON-LD JobPosting (schéma Google Offres d'emploi) --- posting: dict = { "@context": "https://schema.org", "@type": "JobPosting", "title": label, "description": desc_txt[:6000] or label, "datePosted": d["date_posted"] or _iso(d["first_seen"]), "hiringOrganization": {"@type": "Organization", "name": d["employer"] or SITE_NAME}, "identifier": {"@type": "PropertyValue", "name": d["source"], "value": d["external_id"]}, "url": BASE_URL + path, "inLanguage": {"fr": "fr-CA", "en": "en-CA"}.get(d["language"] or "", "fr-CA"), } if d["company_logo"] and str(d["company_logo"]).startswith("http"): posting["hiringOrganization"]["logo"] = d["company_logo"] address: dict = {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"} if d["city"]: address["addressLocality"] = d["city"] if d["postal_code"]: address["postalCode"] = d["postal_code"] if d["address"]: address["streetAddress"] = d["address"] posting["jobLocation"] = {"@type": "Place", "address": address} if d["work_mode"] == "teletravail": posting["jobLocationType"] = "TELECOMMUTE" posting["applicantLocationRequirements"] = { "@type": "Country", "name": "CA"} if d["employment_type"] in EMPLOYMENT_TYPE_SCHEMA: posting["employmentType"] = EMPLOYMENT_TYPE_SCHEMA[d["employment_type"]] if d["date_deadline"]: posting["validThrough"] = d["date_deadline"] if d["salary_min"] is not None and d["salary_unit"] in SALARY_UNIT_SCHEMA: value: dict = {"@type": "QuantitativeValue", "unitText": SALARY_UNIT_SCHEMA[d["salary_unit"]]} if d["salary_max"] and d["salary_max"] != d["salary_min"]: value["minValue"] = d["salary_min"] value["maxValue"] = d["salary_max"] else: value["value"] = d["salary_min"] posting["baseSalary"] = {"@type": "MonetaryAmount", "currency": "CAD", "value": value} crumbs = [("Accueil", "/"), (label, path)] # doublon inter-sources : la fiche canonique est l'offre maîtresse ; # quarantaine qualité : fiche accessible mais non indexée canonical = f"/emploi/{d['dup_of']}" if d["dup_of"] else None return _render(title=title, description=description, path=path, canonical_path=canonical, noindex=bool(d["quarantine"]), jsonld=[posting, _breadcrumb(crumbs)], body="".join(body), og_image=d["company_logo"] if d["company_logo"] and str(d["company_logo"]).startswith("http") else None) # --- Pages programmatiques villes : /villes + /ville/{slug} ------------------- @router.get("/villes", include_in_schema=False) def villes_ssr(): con = db.connect() villes = _villes_rows(con) con.close() total = sum(v["n"] for v in villes) title = f"Offres d'emploi par ville au Québec ({len(villes)} villes) | Job-Ka" description = ( f"Toutes les villes du Québec où Job-Ka recense des offres d'emploi : " f"{_fmt_n(total)} postes dans {len(villes)} villes, avec le nombre " "d'offres et d'employeurs par ville.") body = (f"

    Offres d'emploi par ville — {len(villes)} villes au Québec

      " + "".join( f'
    • Emplois à {_e(v["city"])}' f' — {v["n"]} offres de {v["employers"]} employeur' f'{"s" if v["employers"] > 1 else ""}
    • ' for v in villes) + "
    ") jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Offres d'emploi par ville au Québec", "numberOfItems": len(villes), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": f"Emplois à {v['city']}", "url": f"{BASE_URL}/ville/{v['slug']}"} for i, v in enumerate(villes[:100])]}] return _render(title=title, description=description, path="/villes", jsonld=jsonld, body=body) @router.get("/ville/{slug}", include_in_schema=False) def ville_ssr(slug: str): path = f"/ville/{slug}" e = _villes_index().get(slug) if not e: return _not_found("Aucune offre d'emploi recensée pour cette ville", path) city, n = e["city"], e["n"] marks = ",".join("?" * len(e["cities"])) con = db.connect() rows = con.execute( f"""SELECT uid, title, title_clean, employer, city, salary_min, salary_max, salary_unit, salary_label FROM jobs WHERE {PUB} AND city IN ({marks}) ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC LIMIT 100""", e["cities"]).fetchall() con.close() neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE) if v["slug"] != slug][:12] if n == 0: return _not_found(f"Aucune offre d'emploi active à {city}", path) title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka" description = ( f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(e['employers'])} " f"employeurs, dont {_fmt_n(e['with_salary'])} avec salaire affiché. " "Offres agrégées à la source, à jour, avec lien direct vers la page " "carrière de l'employeur.") body = [f"

    Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes

    ", f"

    {_fmt_n(e['employers'])} employeurs recrutent à {_e(city)}" f" ; {_fmt_n(e['with_salary'])} offres affichent le salaire.

    ", "

    Offres récentes

      " + "".join(_job_li(r) for r in rows) + "
    "] if neighbors: body.append("

    Autres villes

    ") body.append('

    Toutes les villes · ' 'Toutes les offres d\'emploi au Québec

    ') crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)] jsonld = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": f"Offres d'emploi à {city}", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["title_clean"] or r["title"] or r["uid"], "url": f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}"} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body="".join(body)) # --- Pages statiques de l'app : head unique, contenu rendu par React ---------- _STATIC_META = { "/carte": ("Carte des offres d'emploi au Québec | Job-Ka", "Toutes les offres d'emploi géolocalisées sur la carte du " "Québec : explorez les postes ouverts près de chez vous, " "ville par ville, avec salaire et employeur."), "/sources": ("Employeurs et sources des offres | Job-Ka", "La liste des employeurs québécois et des sources (pages " "carrières, portails) dont Job-Ka agrège les offres " "d'emploi, avec le nombre d'offres actives de chacun."), "/stats": ("Statistiques du marché de l'emploi au Québec | Job-Ka", "Salaires moyens, répartition des offres par ville, catégorie " "et mode de travail : les statistiques du marché de l'emploi " "québécois, calculées en continu par Job-Ka."), "/employeurs": ("Publier une offre d'emploi gratuitement | Job-Ka", "Employeurs québécois : publiez gratuitement votre offre " "d'emploi sur Job-Ka. Dépôt direct, modération rapide, " "visibilité auprès des chercheurs d'emploi du Québec."), "/contact": ("Nous joindre | Job-Ka", "Contactez l'équipe de Job-Ka : questions, signalement d'une " "offre, retrait d'une offre ou partenariat employeur."), } def _static_page(path: str): title, description = _STATIC_META[path] return _render(title=title, description=description, path=path, jsonld=[_breadcrumb([("Accueil", "/"), (title.split(" | ")[0], path)])]) @router.get("/carte", include_in_schema=False) def carte_page(): return _static_page("/carte") @router.get("/sources", include_in_schema=False) def sources_page(): return _static_page("/sources") @router.get("/stats", include_in_schema=False) def stats_page(): return _static_page("/stats") @router.get("/employeurs", include_in_schema=False) def employeurs_page(): return _static_page("/employeurs") @router.get("/contact", include_in_schema=False) def contact_page(): return _static_page("/contact")