SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
2 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

SEO : pages programmatiques /villes + /ville/{slug} rendues en HTML (pattern lou-ka)

- jobka/seo.py : slugify, cache slug→ville, SSR /villes et /ville/{slug}
  (contenu dans #root, ItemList + BreadcrumbList), API /api/seo/villes et
  /api/seo/ville/{slug}, sitemap-villes.xml, liens internes accueil/fiche
  vers les nouvelles pages
- frontend : pages React VillesPage/VillePage, routes, lien pied de page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 68fb887

4 changed files +324 −7

modified frontend/public/ka-agent.js +2 −0
@@ -454,3 +454,5 @@
454 454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
455 455 });
456 456 })();
457 +
458 +;/*KA_ANALYTICS*/(function(){try{if(window.__kaAn)return;window.__kaAn=1;var k="KA_VID",v=localStorage.getItem(k);if(!v){v=Date.now().toString(36)+Math.random().toString(36).slice(2,8);localStorage.setItem(k,v);}var b=function(){var i=new Image();i.src="https://www.administration-ka.com/collect?site=job-ka&vid="+v+"&p="+encodeURIComponent(location.pathname)+"&t="+Date.now();};b();setInterval(b,60000);}catch(e){}})();
modified frontend/src/App.tsx +4 −0
@@ -17,6 +17,7 @@ import Sources from "./pages/Sources";
17 17 import Stats from "./pages/Stats";
18 18 import Contact from "./pages/Contact";
19 19 import Employeurs from "./pages/Employeurs";
20 +import VillePage, { VillesPage } from "./pages/Ville";
20 21 import GroupeKaBadge from "./ka/GroupeKaBadge";
21 22 import KaFooter from "./ka/KaFooter";
22 23
@@ -168,6 +169,8 @@ export default function App() {
168 169 </Suspense>
169 170 } />
170 171 <Route path="/emploi/:uid" element={<JobPage />} />
172 + <Route path="/villes" element={<VillesPage />} />
173 + <Route path="/ville/:ville" element={<VillePage />} />
171 174 <Route path="/sources" element={<Sources />} />
172 175 <Route path="/stats" element={<Stats />} />
173 176 <Route path="/contact" element={<Contact />} />
@@ -182,6 +185,7 @@ export default function App() {
182 185 boîte noire.
183 186 </span>
184 187 <NavLink to="/">Offres</NavLink>
188 + <NavLink to="/villes">Villes</NavLink>
185 189 <NavLink to="/carte">Carte</NavLink>
186 190 <NavLink to="/sources">Employeurs</NavLink>
187 191 <NavLink to="/stats">Stats</NavLink>
added frontend/src/pages/Ville.tsx +149 −0
@@ -0,0 +1,149 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/pages/Ville.tsx
7 + * Rôle : Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.
8 + * Le serveur rend le même contenu en HTML (jobka/seo.py) ; ici, la
9 + * version interactive avec les cartes d'offres de l'app.
10 + * Créé : 2026-08-23 Modifié : 2026-08-23
11 + * =============================================================================
12 + */
13 +import { useEffect, useState } from "react";
14 +import { Link, useParams } from "react-router-dom";
15 +import { fetchJobs, Job } from "../api";
16 +import JobCard from "../components/JobCard";
17 +
18 +interface VilleRow {
19 + city: string;
20 + slug: string;
21 + n: number;
22 + employers: number;
23 + with_salary: number;
24 +}
25 +
26 +interface VilleData {
27 + city: string;
28 + slug: string;
29 + n: number;
30 + employers: number;
31 + with_salary: number;
32 + neighbors: VilleRow[];
33 +}
34 +
35 +async function get<T>(path: string): Promise<T> {
36 + const res = await fetch(path);
37 + if (!res.ok) throw new Error(`${res.status}`);
38 + return res.json();
39 +}
40 +
41 +export function VillesPage() {
42 + const [villes, setVilles] = useState<VilleRow[] | null>(null);
43 + const [error, setError] = useState<string | null>(null);
44 +
45 + useEffect(() => {
46 + get<{ villes: VilleRow[] }>("/api/seo/villes")
47 + .then((r) => setVilles(r.villes))
48 + .catch((e) => setError(String(e)));
49 + window.scrollTo(0, 0);
50 + }, []);
51 +
52 + return (
53 + <div className="container">
54 + <h1>Offres d'emploi par ville</h1>
55 + <p className="sub">
56 + Toutes les villes du Québec où Job-Ka recense des offres d'emploi,
57 + avec le nombre de postes ouverts et d'employeurs qui recrutent.
58 + </p>
59 + {error && <div className="empty">⚠️ {error}</div>}
60 + {!villes && !error && <div className="empty">Chargement…</div>}
61 + {villes && (
62 + <ul style={{ marginTop: 24, lineHeight: 2, paddingLeft: 20 }}>
63 + {villes.map((v) => (
64 + <li key={v.slug}>
65 + <Link to={`/ville/${v.slug}`}>{v.city}</Link>
66 + {" — "}
67 + {v.n} offre{v.n > 1 ? "s" : ""} · {v.employers} employeur
68 + {v.employers > 1 ? "s" : ""}
69 + </li>
70 + ))}
71 + </ul>
72 + )}
73 + </div>
74 + );
75 +}
76 +
77 +export default function VillePage() {
78 + const { ville = "" } = useParams();
79 + const [data, setData] = useState<VilleData | null>(null);
80 + const [jobs, setJobs] = useState<Job[] | null>(null);
81 + const [error, setError] = useState<string | null>(null);
82 +
83 + useEffect(() => {
84 + setData(null);
85 + setJobs(null);
86 + setError(null);
87 + get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}`)
88 + .then((d) => {
89 + setData(d);
90 + return fetchJobs({ city: d.city }, 60).then((r) => setJobs(r.jobs));
91 + })
92 + .catch((e) =>
93 + setError(e.message === "404" ? "Ville inconnue." : String(e)));
94 + window.scrollTo(0, 0);
95 + }, [ville]);
96 +
97 + if (error)
98 + return (
99 + <div className="container">
100 + <h1>Page introuvable</h1>
101 + <div className="empty">
102 + ⚠️ {error} <Link to="/villes">Voir toutes les villes</Link>
103 + </div>
104 + </div>
105 + );
106 + if (!data)
107 + return (
108 + <div className="container">
109 + <div className="empty">Chargement…</div>
110 + </div>
111 + );
112 +
113 + return (
114 + <div className="container">
115 + <p style={{ margin: "0 0 4px" }}>
116 + <Link to="/villes">Villes</Link> — {data.city}
117 + </p>
118 + <h1>Offres d'emploi à {data.city}</h1>
119 + <p className="sub">
120 + {data.n} poste{data.n > 1 ? "s" : ""} chez {data.employers} employeur
121 + {data.employers > 1 ? "s" : ""}
122 + {data.with_salary > 0 && (
123 + <> · {data.with_salary} avec salaire affiché</>
124 + )}
125 + </p>
126 +
127 + <div className="job-list" style={{ marginTop: 24 }}>
128 + {!jobs && <div className="empty">Chargement des offres…</div>}
129 + {jobs && jobs.map((j) => <JobCard key={j.uid} job={j} />)}
130 + </div>
131 +
132 + {data.neighbors.length > 0 && (
133 + <>
134 + <h2 style={{ marginTop: 40 }}>Autres villes</h2>
135 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
136 + {data.neighbors.map((v) => (
137 + <Link key={v.slug} to={`/ville/${v.slug}`}>
138 + {v.city} ({v.n})
139 + </Link>
140 + ))}
141 + </p>
142 + </>
143 + )}
144 + <p style={{ marginTop: 24 }}>
145 + <Link to="/">← Toutes les offres d'emploi au Québec</Link>
146 + </p>
147 + </div>
148 + );
149 +}
modified jobka/seo.py +169 −7
@@ -19,6 +19,8 @@ import html
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,6 +44,9 @@ PUB = "active=1 AND dup_of IS NULL AND quarantine IS NULL"
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,6 +175,40 @@ def _job_li(r) -> str:
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,6 +229,34 @@ def _not_found(message: str, path: str) -> HTMLResponse:
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,7 +290,7 @@ def sitemap_index():
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,6 +304,7 @@ def sitemap_index():
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,6 +313,15 @@ def sitemap_pages():
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,9 +352,7 @@ def home_ssr():
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,9 +380,10 @@ def home_ssr():
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,7 +459,7 @@ def job_ssr(uid: str):
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,6 +518,92 @@ def job_ssr(uid: str):
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