| |
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") |