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