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 | 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 | |