| 19 |
19 |
import json |
| 20 |
20 |
import math |
| 21 |
21 |
import re |
|
22 |
+import time |
|
23 |
+import unicodedata |
| 22 |
24 |
from datetime import date, datetime, timezone |
| 23 |
25 |
from pathlib import Path |
| 24 |
26 |
from urllib.parse import quote |
| 42 |
44 |
|
| 43 |
45 |
SITEMAP_CHUNK = 10000 |
| 44 |
46 |
|
|
47 |
+# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues) |
|
48 |
+MIN_JOBS_PAGE = 3 |
|
49 |
+ |
| 45 |
50 |
# vocabulaires internes → valeurs schema.org (JobPosting) |
| 46 |
51 |
EMPLOYMENT_TYPE_SCHEMA = { |
| 47 |
52 |
"temps_plein": "FULL_TIME", |
| 170 |
175 |
f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
| 171 |
176 |
|
| 172 |
177 |
|
|
178 |
+def 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 t |
|
184 |
+ |
|
185 |
+ |
|
186 |
+_villes_cache: dict = {"ts": 0.0, "map": {}} |
|
187 |
+ |
|
188 |
+ |
|
189 |
+def _ville_map() -> dict[str, str]: |
|
190 |
+ """slug → ville, reconstruit au plus toutes les 10 minutes.""" |
|
191 |
+ if time.time() - _villes_cache["ts"] > 600: |
|
192 |
+ con = db.connect() |
|
193 |
+ cities = [r["city"] for r in con.execute( |
|
194 |
+ f"SELECT DISTINCT city FROM jobs WHERE {PUB} AND city<>''")] |
|
195 |
+ con.close() |
|
196 |
+ _villes_cache["map"] = {slugify(c): c for c in sorted(cities)} |
|
197 |
+ _villes_cache["ts"] = time.time() |
|
198 |
+ return _villes_cache["map"] |
|
199 |
+ |
|
200 |
+ |
|
201 |
+def _villes_rows(con, minimum: int = 1) -> list[dict]: |
|
202 |
+ rows = [dict(r) for r in con.execute( |
|
203 |
+ f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers, |
|
204 |
+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary |
|
205 |
+ FROM jobs WHERE {PUB} AND city<>'' |
|
206 |
+ GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))] |
|
207 |
+ for r in rows: |
|
208 |
+ r["slug"] = slugify(r["city"]) |
|
209 |
+ return rows |
|
210 |
+ |
|
211 |
+ |
| 173 |
212 |
def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
| 174 |
213 |
return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
| 175 |
214 |
"itemListElement": [ |
| 190 |
229 |
status=404) |
| 191 |
230 |
|
| 192 |
231 |
|
|
232 |
+# --- API JSON pour les pages villes du frontend -------------------------------- |
|
233 |
+ |
|
234 |
+@router.get("/api/seo/villes") |
|
235 |
+def api_villes(): |
|
236 |
+ con = db.connect() |
|
237 |
+ rows = _villes_rows(con) |
|
238 |
+ con.close() |
|
239 |
+ return {"villes": rows} |
|
240 |
+ |
|
241 |
+ |
|
242 |
+@router.get("/api/seo/ville/{slug}") |
|
243 |
+def api_ville(slug: str): |
|
244 |
+ city = _ville_map().get(slug) |
|
245 |
+ if not city: |
|
246 |
+ raise HTTPException(404, "Ville inconnue") |
|
247 |
+ con = db.connect() |
|
248 |
+ agg = con.execute( |
|
249 |
+ f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) employers, |
|
250 |
+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary |
|
251 |
+ FROM jobs WHERE {PUB} AND city=?""", (city,)).fetchone() |
|
252 |
+ neighbors = [v for v in _villes_rows(con, MIN_JOBS_PAGE) |
|
253 |
+ if v["city"] != city][:12] |
|
254 |
+ con.close() |
|
255 |
+ return {"city": city, "slug": slug, "n": agg["n"], |
|
256 |
+ "employers": agg["employers"], "with_salary": agg["with_salary"], |
|
257 |
+ "neighbors": neighbors} |
|
258 |
+ |
|
259 |
+ |
| 193 |
260 |
# --- robots.txt & sitemaps ---------------------------------------------------- |
| 194 |
261 |
|
| 195 |
262 |
@router.get("/robots.txt", include_in_schema=False) |
| 223 |
290 |
total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"] |
| 224 |
291 |
con.close() |
| 225 |
292 |
chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| 226 |
|
− names = ["sitemap-pages.xml"] + [ |
|
293 |
+ names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ |
| 227 |
294 |
f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)] |
| 228 |
295 |
today = date.today().isoformat() |
| 229 |
296 |
rows = "\n".join( |
| 237 |
304 |
def sitemap_pages(): |
| 238 |
305 |
urls: list[tuple[str, str | None]] = [ |
| 239 |
306 |
(f"{BASE_URL}/", None), |
|
307 |
+ (f"{BASE_URL}/villes", None), |
| 240 |
308 |
(f"{BASE_URL}/carte", None), |
| 241 |
309 |
(f"{BASE_URL}/sources", None), |
| 242 |
310 |
(f"{BASE_URL}/stats", None), |
| 245 |
313 |
return _urlset(urls) |
| 246 |
314 |
|
| 247 |
315 |
|
|
316 |
+@router.get("/sitemap-villes.xml", include_in_schema=False) |
|
317 |
+def sitemap_villes(): |
|
318 |
+ con = db.connect() |
|
319 |
+ villes = _villes_rows(con, MIN_JOBS_PAGE) |
|
320 |
+ con.close() |
|
321 |
+ today = date.today().isoformat() |
|
322 |
+ return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes]) |
|
323 |
+ |
|
324 |
+ |
| 248 |
325 |
@router.get("/sitemap-emplois-{num}.xml", include_in_schema=False) |
| 249 |
326 |
def sitemap_emplois(num: int): |
| 250 |
327 |
if num < 1: |
| 275 |
352 |
COUNT(DISTINCT source) sources |
| 276 |
353 |
FROM jobs WHERE {PUB}""").fetchone() |
| 277 |
354 |
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""")] |
|
355 |
+ villes = _villes_rows(con, MIN_JOBS_PAGE) |
| 281 |
356 |
with_salary = con.execute( |
| 282 |
357 |
f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL" |
| 283 |
358 |
).fetchone()["c"] |
| 305 |
380 |
"avec transparence salariale).</p>" |
| 306 |
381 |
+ "<h2>Emplois par ville</h2><ul>" |
| 307 |
382 |
+ "".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) |
|
383 |
+ f'<li><a href="/ville/{v["slug"]}">Emplois à ' |
|
384 |
+ f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes[:30]) |
| 310 |
385 |
+ "</ul>" |
|
386 |
+ + f'<p><a href="/villes">Toutes les villes ({len(villes)})</a></p>' |
| 311 |
387 |
+ "<h2>Dernières offres publiées</h2><ul>" |
| 312 |
388 |
+ "".join(_job_li(r) for r in recents) |
| 313 |
389 |
+ "</ul>" |
| 383 |
459 |
body.append(f'<p><a href="{_e(link)}" rel="nofollow">' |
| 384 |
460 |
"Postuler chez l'employeur (offre originale)</a></p>") |
| 385 |
461 |
if d["city"]: |
| 386 |
|
− body.append(f'<p><a href="/?ville={quote(d["city"])}">' |
|
462 |
+ body.append(f'<p><a href="/ville/{slugify(d["city"])}">' |
| 387 |
463 |
f"Autres offres d'emploi à {_e(d['city'])}</a></p>") |
| 388 |
464 |
body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>') |
| 389 |
465 |
|
| 442 |
518 |
else None) |
| 443 |
519 |
|
| 444 |
520 |
|
|
521 |
+# --- Pages programmatiques villes : /villes + /ville/{slug} ------------------- |
|
522 |
+ |
|
523 |
+@router.get("/villes", include_in_schema=False) |
|
524 |
+def villes_ssr(): |
|
525 |
+ con = db.connect() |
|
526 |
+ villes = _villes_rows(con) |
|
527 |
+ con.close() |
|
528 |
+ total = sum(v["n"] for v in villes) |
|
529 |
+ title = f"Offres d'emploi par ville au Québec ({len(villes)} villes) | Job-Ka" |
|
530 |
+ description = ( |
|
531 |
+ f"Toutes les villes du Québec où Job-Ka recense des offres d'emploi : " |
|
532 |
+ f"{_fmt_n(total)} postes dans {len(villes)} villes, avec le nombre " |
|
533 |
+ "d'offres et d'employeurs par ville.") |
|
534 |
+ body = (f"<h1>Offres d'emploi par ville — {len(villes)} villes au Québec</h1><ul>" |
|
535 |
+ + "".join( |
|
536 |
+ f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>' |
|
537 |
+ f' — {v["n"]} offres de {v["employers"]} employeur' |
|
538 |
+ f'{"s" if v["employers"] > 1 else ""}</li>' for v in villes) |
|
539 |
+ + "</ul>") |
|
540 |
+ jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), |
|
541 |
+ {"@context": "https://schema.org", "@type": "ItemList", |
|
542 |
+ "name": "Offres d'emploi par ville au Québec", |
|
543 |
+ "numberOfItems": len(villes), |
|
544 |
+ "itemListElement": [ |
|
545 |
+ {"@type": "ListItem", "position": i + 1, |
|
546 |
+ "name": f"Emplois à {v['city']}", |
|
547 |
+ "url": f"{BASE_URL}/ville/{v['slug']}"} |
|
548 |
+ for i, v in enumerate(villes[:100])]}] |
|
549 |
+ return _render(title=title, description=description, path="/villes", |
|
550 |
+ jsonld=jsonld, body=body) |
|
551 |
+ |
|
552 |
+ |
|
553 |
+@router.get("/ville/{slug}", include_in_schema=False) |
|
554 |
+def ville_ssr(slug: str): |
|
555 |
+ path = f"/ville/{slug}" |
|
556 |
+ city = _ville_map().get(slug) |
|
557 |
+ if not city: |
|
558 |
+ return _not_found("Aucune offre d'emploi recensée pour cette ville", path) |
|
559 |
+ con = db.connect() |
|
560 |
+ agg = con.execute( |
|
561 |
+ f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) employers, |
|
562 |
+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary |
|
563 |
+ FROM jobs WHERE {PUB} AND city=?""", (city,)).fetchone() |
|
564 |
+ rows = con.execute( |
|
565 |
+ f"""SELECT uid, title, title_clean, employer, city, salary_min, |
|
566 |
+ salary_max, salary_unit, salary_label |
|
567 |
+ FROM jobs WHERE {PUB} AND city=? |
|
568 |
+ ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC |
|
569 |
+ LIMIT 100""", (city,)).fetchall() |
|
570 |
+ neighbors = [v for v in _villes_rows(con, MIN_JOBS_PAGE) |
|
571 |
+ if v["city"] != city][:12] |
|
572 |
+ con.close() |
|
573 |
+ n = agg["n"] |
|
574 |
+ if n == 0: |
|
575 |
+ return _not_found(f"Aucune offre d'emploi active à {city}", path) |
|
576 |
+ |
|
577 |
+ title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka" |
|
578 |
+ description = ( |
|
579 |
+ f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(agg['employers'])} " |
|
580 |
+ f"employeurs, dont {_fmt_n(agg['with_salary'])} avec salaire affiché. " |
|
581 |
+ "Offres agrégées à la source, à jour, avec lien direct vers la page " |
|
582 |
+ "carrière de l'employeur.") |
|
583 |
+ body = [f"<h1>Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes</h1>", |
|
584 |
+ f"<p>{_fmt_n(agg['employers'])} employeurs recrutent à {_e(city)}" |
|
585 |
+ f" ; {_fmt_n(agg['with_salary'])} offres affichent le salaire.</p>", |
|
586 |
+ "<h2>Offres récentes</h2><ul>" |
|
587 |
+ + "".join(_job_li(r) for r in rows) + "</ul>"] |
|
588 |
+ if neighbors: |
|
589 |
+ body.append("<h2>Autres villes</h2><ul>" + "".join( |
|
590 |
+ f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>' |
|
591 |
+ f' — {v["n"]}</li>' for v in neighbors) + "</ul>") |
|
592 |
+ body.append('<p><a href="/villes">Toutes les villes</a> · ' |
|
593 |
+ '<a href="/">Toutes les offres d\'emploi au Québec</a></p>') |
|
594 |
+ crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)] |
|
595 |
+ jsonld = [_breadcrumb(crumbs), |
|
596 |
+ {"@context": "https://schema.org", "@type": "ItemList", |
|
597 |
+ "name": f"Offres d'emploi à {city}", "numberOfItems": n, |
|
598 |
+ "itemListElement": [ |
|
599 |
+ {"@type": "ListItem", "position": i + 1, |
|
600 |
+ "name": r["title_clean"] or r["title"] or r["uid"], |
|
601 |
+ "url": f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}"} |
|
602 |
+ for i, r in enumerate(rows[:50])]}] |
|
603 |
+ return _render(title=title, description=description, path=path, |
|
604 |
+ jsonld=jsonld, body="".join(body)) |
|
605 |
+ |
|
606 |
+ |
| 445 |
607 |
# --- Pages statiques de l'app : head unique, contenu rendu par React ---------- |
| 446 |
608 |
|
| 447 |
609 |
_STATIC_META = { |
| 448 |
610 |
|