SPB Git

spb/job-ka

Public

SEO : villes groupées par slug (variantes de casse fusionnées, libellé majoritaire)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent bc4b4ca Browse files

1 changed file +51 −43

Unified Split
modified jobka/seo.py +51 −43
@@ -183,30 +183,47 @@ def slugify(text: str) -> str:
183 return t 183 return t
184 184
185 185
186−_villes_cache: dict = {"ts": 0.0, "map": {}} 186+_villes_cache: dict = {"ts": 0.0, "by_slug": {}}
187 187
188 188
189−def _ville_map() -> dict[str, str]: 189+def _villes_index() -> dict[str, dict]:
190− """slug → ville, reconstruit au plus toutes les 10 minutes.""" 190+ """slug → {city (libellé majoritaire), cities (variantes), n, employers,
191+ with_salary} — les variantes de casse/accents d'une même ville sont
192+ regroupées par slug. Reconstruit au plus toutes les 10 minutes."""
191 if time.time() - _villes_cache["ts"] > 600: 193 if time.time() - _villes_cache["ts"] > 600:
192 con = db.connect() 194 con = db.connect()
193− cities = [r["city"] for r in con.execute( 195+ rows = con.execute(
194− f"SELECT DISTINCT city FROM jobs WHERE {PUB} AND city<>''")] 196+ f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers,
197+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END)
198+ with_salary
199+ FROM jobs WHERE {PUB} AND city<>''
200+ GROUP BY city""").fetchall()
195 con.close() 201 con.close()
196− _villes_cache["map"] = {slugify(c): c for c in sorted(cities)} 202+ by_slug: dict[str, dict] = {}
203+ for r in rows:
204+ s = slugify(r["city"])
205+ if not s:
206+ continue
207+ e = by_slug.setdefault(s, {"slug": s, "city": r["city"],
208+ "cities": [], "n": 0, "employers": 0,
209+ "with_salary": 0, "_best": -1})
210+ e["cities"].append(r["city"])
211+ e["n"] += r["n"]
212+ e["employers"] += r["employers"]
213+ e["with_salary"] += r["with_salary"] or 0
214+ if r["n"] > e["_best"]:
215+ e["_best"] = r["n"]
216+ e["city"] = r["city"]
217+ _villes_cache["by_slug"] = by_slug
197 _villes_cache["ts"] = time.time() 218 _villes_cache["ts"] = time.time()
198− return _villes_cache["map"] 219+ return _villes_cache["by_slug"]
199 220
200 221
201−def _villes_rows(con, minimum: int = 1) -> list[dict]: 222+def _villes_rows(_con=None, minimum: int = 1) -> list[dict]:
202− rows = [dict(r) for r in con.execute( 223+ rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"],
203− f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers, 224+ "employers": e["employers"], "with_salary": e["with_salary"]}
204− SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary 225+ for e in _villes_index().values() if e["n"] >= minimum]
205− FROM jobs WHERE {PUB} AND city<>'' 226+ return sorted(rows, key=lambda r: -r["n"])
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 227
211 228
212 def _breadcrumb(items: list[tuple[str, str]]) -> dict: 229 def _breadcrumb(items: list[tuple[str, str]]) -> dict:
@@ -241,19 +258,13 @@ def api_villes():
241 258
242 @router.get("/api/seo/ville/{slug}") 259 @router.get("/api/seo/ville/{slug}")
243 def api_ville(slug: str): 260 def api_ville(slug: str):
244− city = _ville_map().get(slug) 261+ e = _villes_index().get(slug)
245− if not city: 262+ if not e:
246 raise HTTPException(404, "Ville inconnue") 263 raise HTTPException(404, "Ville inconnue")
247− con = db.connect() 264+ neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE)
248− agg = con.execute( 265+ if v["slug"] != slug][:12]
249− f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) employers, 266+ return {"city": e["city"], "slug": slug, "n": e["n"],
250− SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary 267+ "employers": e["employers"], "with_salary": e["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} 268 "neighbors": neighbors}
258 269
259 270
@@ -553,36 +564,33 @@ def villes_ssr():
553 @router.get("/ville/{slug}", include_in_schema=False) 564 @router.get("/ville/{slug}", include_in_schema=False)
554 def ville_ssr(slug: str): 565 def ville_ssr(slug: str):
555 path = f"/ville/{slug}" 566 path = f"/ville/{slug}"
556− city = _ville_map().get(slug) 567+ e = _villes_index().get(slug)
557− if not city: 568+ if not e:
558 return _not_found("Aucune offre d'emploi recensée pour cette ville", path) 569 return _not_found("Aucune offre d'emploi recensée pour cette ville", path)
570+ city, n = e["city"], e["n"]
571+ marks = ",".join("?" * len(e["cities"]))
559 con = db.connect() 572 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( 573 rows = con.execute(
565 f"""SELECT uid, title, title_clean, employer, city, salary_min, 574 f"""SELECT uid, title, title_clean, employer, city, salary_min,
566 salary_max, salary_unit, salary_label 575 salary_max, salary_unit, salary_label
567− FROM jobs WHERE {PUB} AND city=? 576+ FROM jobs WHERE {PUB} AND city IN ({marks})
568 ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC 577 ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC
569− LIMIT 100""", (city,)).fetchall() 578+ LIMIT 100""", e["cities"]).fetchall()
570− neighbors = [v for v in _villes_rows(con, MIN_JOBS_PAGE)
571− if v["city"] != city][:12]
572 con.close() 579 con.close()
573− n = agg["n"] 580+ neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE)
581+ if v["slug"] != slug][:12]
574 if n == 0: 582 if n == 0:
575 return _not_found(f"Aucune offre d'emploi active à {city}", path) 583 return _not_found(f"Aucune offre d'emploi active à {city}", path)
576 584
577 title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka" 585 title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka"
578 description = ( 586 description = (
579− f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(agg['employers'])} " 587+ f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(e['employers'])} "
580− f"employeurs, dont {_fmt_n(agg['with_salary'])} avec salaire affiché. " 588+ f"employeurs, dont {_fmt_n(e['with_salary'])} avec salaire affiché. "
581 "Offres agrégées à la source, à jour, avec lien direct vers la page " 589 "Offres agrégées à la source, à jour, avec lien direct vers la page "
582 "carrière de l'employeur.") 590 "carrière de l'employeur.")
583 body = [f"<h1>Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes</h1>", 591 body = [f"<h1>Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes</h1>",
584− f"<p>{_fmt_n(agg['employers'])} employeurs recrutent à {_e(city)}" 592+ f"<p>{_fmt_n(e['employers'])} employeurs recrutent à {_e(city)}"
585− f" ; {_fmt_n(agg['with_salary'])} offres affichent le salaire.</p>", 593+ f" ; {_fmt_n(e['with_salary'])} offres affichent le salaire.</p>",
586 "<h2>Offres récentes</h2><ul>" 594 "<h2>Offres récentes</h2><ul>"
587 + "".join(_job_li(r) for r in rows) + "</ul>"] 595 + "".join(_job_li(r) for r in rows) + "</ul>"]
588 if neighbors: 596 if neighbors:
589 597
Menu

Theme