SEO : villes groupées par slug (variantes de casse fusionnées, libellé majoritaire)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +51 −43
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 | ||||