SPB Git forge

spb/job-ka

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

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 1 mo ago (Aug 23, 2026) parent bc4b4ca

1 changed file +51 −43

modified jobka/seo.py +51 −43
@@ -183,30 +183,47 @@ def slugify(text: str) -> str:
183 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]:
190 − """slug → ville, reconstruit au plus toutes les 10 minutes."""
189 +def _villes_index() -> dict[str, dict]:
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 193 if time.time() - _villes_cache["ts"] > 600:
192 194 con = db.connect()
193 − cities = [r["city"] for r in con.execute(
194 − f"SELECT DISTINCT city FROM jobs WHERE {PUB} AND city<>''")]
195 + rows = con.execute(
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 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 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]:
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
222 +def _villes_rows(_con=None, minimum: int = 1) -> list[dict]:
223 + rows = [{"city": e["city"], "slug": e["slug"], "n": e["n"],
224 + "employers": e["employers"], "with_salary": e["with_salary"]}
225 + for e in _villes_index().values() if e["n"] >= minimum]
226 + return sorted(rows, key=lambda r: -r["n"])
210 227
211 228
212 229 def _breadcrumb(items: list[tuple[str, str]]) -> dict:
@@ -241,19 +258,13 @@ def api_villes():
241 258
242 259 @router.get("/api/seo/ville/{slug}")
243 260 def api_ville(slug: str):
244 − city = _ville_map().get(slug)
245 − if not city:
261 + e = _villes_index().get(slug)
262 + if not e:
246 263 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"],
264 + neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE)
265 + if v["slug"] != slug][:12]
266 + return {"city": e["city"], "slug": slug, "n": e["n"],
267 + "employers": e["employers"], "with_salary": e["with_salary"],
257 268 "neighbors": neighbors}
258 269
259 270
@@ -553,36 +564,33 @@ def villes_ssr():
553 564 @router.get("/ville/{slug}", include_in_schema=False)
554 565 def ville_ssr(slug: str):
555 566 path = f"/ville/{slug}"
556 − city = _ville_map().get(slug)
557 − if not city:
567 + e = _villes_index().get(slug)
568 + if not e:
558 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 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 573 rows = con.execute(
565 574 f"""SELECT uid, title, title_clean, employer, city, salary_min,
566 575 salary_max, salary_unit, salary_label
567 − FROM jobs WHERE {PUB} AND city=?
576 + FROM jobs WHERE {PUB} AND city IN ({marks})
568 577 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]
578 + LIMIT 100""", e["cities"]).fetchall()
572 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 582 if n == 0:
575 583 return _not_found(f"Aucune offre d'emploi active à {city}", path)
576 584
577 585 title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka"
578 586 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é. "
587 + f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(e['employers'])} "
588 + f"employeurs, dont {_fmt_n(e['with_salary'])} avec salaire affiché. "
581 589 "Offres agrégées à la source, à jour, avec lien direct vers la page "
582 590 "carrière de l'employeur.")
583 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)}"
585 − f" ; {_fmt_n(agg['with_salary'])} offres affichent le salaire.</p>",
592 + f"<p>{_fmt_n(e['employers'])} employeurs recrutent à {_e(city)}"
593 + f" ; {_fmt_n(e['with_salary'])} offres affichent le salaire.</p>",
586 594 "<h2>Offres récentes</h2><ul>"
587 595 + "".join(_job_li(r) for r in rows) + "</ul>"]
588 596 if neighbors:
589 597