| 19 | import json |
19 | import json |
| 20 | import math |
20 | import math |
| 21 | import re |
21 | import re |
| |
22 | +import time |
| |
23 | +import unicodedata |
| 22 | from datetime import date, datetime, timezone |
24 | from datetime import date, datetime, timezone |
| 23 | from pathlib import Path |
25 | from pathlib import Path |
| 24 | from urllib.parse import quote |
26 | from urllib.parse import quote |
| 42 | |
44 | |
| 43 | SITEMAP_CHUNK = 10000 |
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 | # vocabulaires internes → valeurs schema.org (JobPosting) |
50 | # vocabulaires internes → valeurs schema.org (JobPosting) |
| 46 | EMPLOYMENT_TYPE_SCHEMA = { |
51 | EMPLOYMENT_TYPE_SCHEMA = { |
| 47 | "temps_plein": "FULL_TIME", |
52 | "temps_plein": "FULL_TIME", |
| 170 | f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
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 | def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
212 | def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
| 174 | return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
213 | return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
| 175 | "itemListElement": [ |
214 | "itemListElement": [ |
| 190 | status=404) |
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 | # --- robots.txt & sitemaps ---------------------------------------------------- |
260 | # --- robots.txt & sitemaps ---------------------------------------------------- |
| 194 | |
261 | |
| 195 | @router.get("/robots.txt", include_in_schema=False) |
262 | @router.get("/robots.txt", include_in_schema=False) |
| 223 | total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"] |
290 | total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"] |
| 224 | con.close() |
291 | con.close() |
| 225 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
292 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| 226 | − names = ["sitemap-pages.xml"] + [ |
293 | + names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ |
| 227 | f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)] |
294 | f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)] |
| 228 | today = date.today().isoformat() |
295 | today = date.today().isoformat() |
| 229 | rows = "\n".join( |
296 | rows = "\n".join( |
| 237 | def sitemap_pages(): |
304 | def sitemap_pages(): |
| 238 | urls: list[tuple[str, str | None]] = [ |
305 | urls: list[tuple[str, str | None]] = [ |
| 239 | (f"{BASE_URL}/", None), |
306 | (f"{BASE_URL}/", None), |
| |
307 | + (f"{BASE_URL}/villes", None), |
| 240 | (f"{BASE_URL}/carte", None), |
308 | (f"{BASE_URL}/carte", None), |
| 241 | (f"{BASE_URL}/sources", None), |
309 | (f"{BASE_URL}/sources", None), |
| 242 | (f"{BASE_URL}/stats", None), |
310 | (f"{BASE_URL}/stats", None), |
| 245 | return _urlset(urls) |
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 | @router.get("/sitemap-emplois-{num}.xml", include_in_schema=False) |
325 | @router.get("/sitemap-emplois-{num}.xml", include_in_schema=False) |
| 249 | def sitemap_emplois(num: int): |
326 | def sitemap_emplois(num: int): |
| 250 | if num < 1: |
327 | if num < 1: |
| 275 | COUNT(DISTINCT source) sources |
352 | COUNT(DISTINCT source) sources |
| 276 | FROM jobs WHERE {PUB}""").fetchone() |
353 | FROM jobs WHERE {PUB}""").fetchone() |
| 277 | total, employers, nsources = agg["total"], agg["employers"], agg["sources"] |
354 | total, employers, nsources = agg["total"], agg["employers"], agg["sources"] |
| 278 | − villes = [dict(r) for r in con.execute( |
355 | + villes = _villes_rows(con, MIN_JOBS_PAGE) |
| 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( |
356 | with_salary = con.execute( |
| 282 | f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL" |
357 | f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL" |
| 283 | ).fetchone()["c"] |
358 | ).fetchone()["c"] |
| 305 | "avec transparence salariale).</p>" |
380 | "avec transparence salariale).</p>" |
| 306 | + "<h2>Emplois par ville</h2><ul>" |
381 | + "<h2>Emplois par ville</h2><ul>" |
| 307 | + "".join( |
382 | + "".join( |
| 308 | − f'<li><a href="/?ville={quote(v["city"])}">Emplois à ' |
383 | + f'<li><a href="/ville/{v["slug"]}">Emplois à ' |
| 309 | − f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes) |
384 | + f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes[:30]) |
| 310 | + "</ul>" |
385 | + "</ul>" |
| |
386 | + + f'<p><a href="/villes">Toutes les villes ({len(villes)})</a></p>' |
| 311 | + "<h2>Dernières offres publiées</h2><ul>" |
387 | + "<h2>Dernières offres publiées</h2><ul>" |
| 312 | + "".join(_job_li(r) for r in recents) |
388 | + "".join(_job_li(r) for r in recents) |
| 313 | + "</ul>" |
389 | + "</ul>" |
| 383 | body.append(f'<p><a href="{_e(link)}" rel="nofollow">' |
459 | body.append(f'<p><a href="{_e(link)}" rel="nofollow">' |
| 384 | "Postuler chez l'employeur (offre originale)</a></p>") |
460 | "Postuler chez l'employeur (offre originale)</a></p>") |
| 385 | if d["city"]: |
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 | f"Autres offres d'emploi à {_e(d['city'])}</a></p>") |
463 | 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>') |
464 | body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>') |
| 389 | |
465 | |
| 442 | else None) |
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 | # --- Pages statiques de l'app : head unique, contenu rendu par React ---------- |
607 | # --- Pages statiques de l'app : head unique, contenu rendu par React ---------- |
| 446 | |
608 | |
| 447 | _STATIC_META = { |
609 | _STATIC_META = { |
| 448 | |
610 | |