HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/web.py6# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)7# Créé : 2026-08-17 Modifié : 2026-08-258# =============================================================================9from __future__ import annotations1011import json12import re13import secrets14import threading15import time16from pathlib import Path1718from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request19from fastapi.middleware.cors import CORSMiddleware20from fastapi.middleware.gzip import GZipMiddleware21from fastapi.responses import FileResponse, Response22from fastapi.staticfiles import StaticFiles23from pydantic import BaseModel, Field2425from . import auth, db, hubfav, ingest, kaid26from .dedup import AGGREGATORS27from .schema import JobPosting2829ROOT = Path(__file__).resolve().parent.parent30SOURCES_PATH = ROOT / "data" / "sources.json"31FRONTEND_DIST = ROOT / "frontend" / "dist"3233app = FastAPI(title="Job-Ka API", version="1.0",34 description="Agrégateur d'offres d'emploi — employeurs québécois, "35 "directement à la source")36app.add_middleware(CORSMiddleware, allow_origins=["*"],37 allow_methods=["*"], allow_headers=["*"])38app.add_middleware(GZipMiddleware, minimum_size=1000)3940# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py41app.include_router(auth.router)4243# personnalisation KA ID v2 (jobka/kaid.py, canonique ka-ui.git/kaid)44kaid.init("job-ka")45app.include_router(kaid.build_router(auth.current_user))4647_sync_lock = threading.Lock()484950def _kaid_features(d: dict) -> dict:51 """Caractéristiques d'une offre pour le profil de préférences KA ID."""52 salary = d.get("salary_year_max") or d.get("salary_year_min")53 return {k: v for k, v in {54 "city": d.get("city"), "region": d.get("region"),55 "category": d.get("category"), "employer": d.get("employer"),56 "work_mode": d.get("work_mode"),57 "employment_type": d.get("employment_type"),58 "seniority": d.get("seniority"), "language": d.get("language"),59 "salary": salary,60 }.items() if v not in (None, "")}616263def _row_to_dict(row) -> dict:64 d = dict(row)65 d["benefits"] = json.loads(d.get("benefits") or "[]")66 d["requirements"] = json.loads(d.get("requirements") or "{}")67 d["details"] = json.loads(d.get("details") or "{}")68 if d.get("dup_sources"):69 try:70 d["dup_sources"] = json.loads(d["dup_sources"])71 except (ValueError, TypeError):72 d["dup_sources"] = []73 # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)74 d["is_direct"] = d.get("source") not in AGGREGATORS75 return d767778@app.get("/health")79def health():80 """Healthcheck de déploiement : BD accessible + fraîcheur des syncs."""81 try:82 con = db.connect()83 total = con.execute(84 "SELECT COUNT(*) c FROM jobs WHERE active=1").fetchone()["c"]85 last = con.execute(86 "SELECT MAX(ts) ts FROM sync_log WHERE ok=1").fetchone()["ts"]87 con.close()88 except Exception as exc:89 raise HTTPException(500, f"BD inaccessible : {exc}")90 return {"status": "ok", "active_jobs": total,91 "last_sync_age_s": round(time.time() - last) if last else None}929394@app.get("/api/jobs")95def list_jobs(96 request: Request,97 city: str | None = None,98 region: str | None = None,99 category: str | None = None,100 employer: str | None = None,101 source: str | None = None,102 work_mode: str | None = None,103 employment_type: str | None = None,104 seniority: str | None = None, # stage | junior | intermediaire | senior | direction105 salary_min: float | None = None, # $/an (converti côté serveur)106 with_salary: int | None = None, # 1 = transparence salariale seulement107 posted_after: str | None = None, # ISO : offres publiées depuis…108 language: str | None = None, # fr | en | bilingue109 q: str | None = None,110 active: int = 1,111 quarantined: int | None = None, # 1 = quarantaine seulement (supervision)112 sort: str = "recent", # recent | salary113 limit: int = Query(100, le=2000),114 offset: int = 0,115):116 con = db.connect()117 sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués118 args: list = []119 # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)120 sql += " AND quarantine IS NOT NULL" if quarantined == 1 \121 else " AND quarantine IS NULL"122 if active in (0, 1):123 sql += " AND active=?"; args.append(active)124 if city:125 sql += " AND city=?"; args.append(city)126 if region:127 sql += " AND region=?"; args.append(region)128 if language:129 sql += " AND language=?"; args.append(language)130 if category:131 sql += " AND category=?"; args.append(category)132 if employer:133 sql += " AND employer LIKE ?"; args.append(f"%{employer}%")134 if source:135 sql += " AND source=?"; args.append(source)136 if work_mode:137 sql += " AND work_mode=?"; args.append(work_mode)138 if employment_type:139 sql += " AND employment_type=?"; args.append(employment_type)140 if seniority:141 sql += " AND seniority=?"; args.append(seniority)142 if salary_min is not None:143 sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?")144 args.append(salary_min)145 if with_salary == 1:146 sql += " AND salary_min IS NOT NULL"147 if posted_after:148 sql += " AND date_posted IS NOT NULL AND date_posted>=?"149 args.append(posted_after)150 if q:151 sql += " AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)"152 args += [f"%{q}%"] * 3153 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]154 if sort == "salary":155 sql += " ORDER BY salary_year_max IS NULL, salary_year_max DESC"156 else:157 sql += " ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC"158 sql += " LIMIT ? OFFSET ?"159 args += [limit, offset]160 rows = []161 for r in con.execute(sql, args).fetchall():162 d = _row_to_dict(r)163 d.pop("description", None) # liste allégée : la fiche a le texte complet164 rows.append(d)165 con.close()166 # personnalisation KA ID : journal + reclassement (tri par défaut seulement,167 # jamais un tri explicite ; intention de session > préférences long terme)168 personalized = False169 user = auth.current_user(request)170 if user:171 active_filters = {"city": city, "region": region, "category": category,172 "employer": employer, "work_mode": work_mode,173 "employment_type": employment_type,174 "seniority": seniority, "language": language,175 "salary_min": salary_min, "q": q}176 active_filters = {k: v for k, v in active_filters.items() if v}177 if (q or active_filters) and offset == 0:178 kaid.track(user, "search", query=q, filters=active_filters)179 if sort == "recent":180 active = set(active_filters) - {"q", "salary_min"}181 if salary_min is not None:182 active.add("salary")183 rows, personalized = kaid.rerank(184 rows, user, features_of=_kaid_features, active_dims=active)185 return {"total": total, "count": len(rows), "jobs": rows,186 "personalized": personalized}187188189@app.get("/api/jobs.geojson")190def jobs_geojson(191 city: str | None = None,192 region: str | None = None,193 category: str | None = None,194 work_mode: str | None = None,195 employment_type: str | None = None,196 salary_min: float | None = None,197 with_salary: int | None = None,198 language: str | None = None,199 q: str | None = None,200 bbox: str | None = None,201 limit: int = Query(3000, le=8000),202):203 """Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).204205 `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).206 Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher207 « N résultats sans position sur la carte ».208 """209 con = db.connect()210 sql = ("SELECT uid, title, title_clean, employer, city, category, work_mode,"211 " employment_type, salary_min, salary_max, salary_unit,"212 " salary_year_min, salary_year_max, date_posted, source, lat, lng"213 " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL"214 " AND lat IS NOT NULL AND lng IS NOT NULL")215 args: list = []216 if bbox:217 try:218 west, south, east, north = (float(v) for v in bbox.split(","))219 except ValueError:220 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")221 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"222 args += [south, north, west, east]223 if city:224 sql += " AND city=?"; args.append(city)225 if region:226 sql += " AND region=?"; args.append(region)227 if category:228 sql += " AND category=?"; args.append(category)229 if work_mode:230 sql += " AND work_mode=?"; args.append(work_mode)231 if employment_type:232 sql += " AND employment_type=?"; args.append(employment_type)233 if salary_min is not None:234 sql += " AND salary_year_max IS NOT NULL AND salary_year_max>=?"235 args.append(salary_min)236 if with_salary == 1:237 sql += " AND salary_min IS NOT NULL"238 if language:239 sql += " AND language=?"; args.append(language)240 if q:241 sql += " AND (title LIKE ? OR employer LIKE ?)"242 args += [f"%{q}%"] * 2243244 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]245 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")246 args_all = list(args)247 if bbox:248 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")249 del args_all[0:4]250 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]251252 sql += " ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?"253 args.append(limit)254255 features = []256 for r in con.execute(sql, args).fetchall():257 features.append({258 "type": "Feature",259 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},260 "properties": {261 "uid": r["uid"], "title": r["title_clean"] or r["title"],262 "employer": r["employer"],263 "city": r["city"], "category": r["category"],264 "work_mode": r["work_mode"],265 "employment_type": r["employment_type"],266 "salary_min": r["salary_min"], "salary_max": r["salary_max"],267 "salary_unit": r["salary_unit"],268 "salary_year_min": r["salary_year_min"],269 "salary_year_max": r["salary_year_max"],270 "date_posted": r["date_posted"], "source": r["source"],271 "is_direct": r["source"] not in AGGREGATORS,272 },273 })274 con.close()275 return {"type": "FeatureCollection", "features": features,276 "totalGeocoded": total_geo, "totalMatching": total_all}277278279# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------280# Distribution des salaires annuels affichés par catégorie (point médian de la281# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une282# base resynchronisée à l'heure, et jamais de requête lourde par visite.283_SALARY_CTX_TTL = 600.0284_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}285286287def _salary_context(con, category: str) -> dict | None:288 """Quartiles des salaires annuels affichés dans la catégorie (n >= 20)."""289 key = category or "__toutes__"290 hit = _salary_ctx_cache.get(key)291 if hit and time.time() - hit[0] < _SALARY_CTX_TTL:292 return hit[1]293 sql = ("SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))"294 " / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL"295 " AND quarantine IS NULL AND salary_year_min IS NOT NULL")296 args: list = []297 if category:298 sql += " AND category=?"; args.append(category)299 vals = sorted(r["v"] for r in con.execute(sql, args))300 ctx = None301 if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative302 def pct(p: float) -> float:303 return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)304 ctx = {"category": category or "", "n": len(vals),305 "p25": pct(0.25), "median": pct(0.50), "p75": pct(0.75)}306 _salary_ctx_cache[key] = (time.time(), ctx)307 return ctx308309310@app.get("/api/jobs/{uid}")311def get_job(uid: str, request: Request):312 con = db.connect()313 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()314 d = None315 if row is not None:316 d = _row_to_dict(row)317 d["salary_history"] = [dict(r) for r in con.execute(318 "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?"319 " ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()]320 # métriques de contexte : salaire vs marché de la catégorie + volume321 # d'offres actives de l'employeur (comparables, jamais inventées)322 ctx = _salary_context(con, d.get("category") or "")323 if ctx and d.get("salary_year_min") is not None:324 mid = (d["salary_year_min"]325 + (d.get("salary_year_max") or d["salary_year_min"])) / 2.0326 ctx = {**ctx, "job_mid": round(mid, 0),327 "delta_pct": round((mid - ctx["median"]) / ctx["median"] * 100, 1)}328 d["salary_context"] = ctx329 if d.get("employer"):330 d["employer_jobs"] = con.execute(331 "SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL"332 " AND quarantine IS NULL AND employer=?",333 (d["employer"],)).fetchone()["n"]334 con.close()335 if d is None:336 raise HTTPException(404, "Offre introuvable")337 kaid.track(auth.current_user(request), "detail_view", entity_type="job",338 entity_id=uid, features=_kaid_features(d))339 return d340341342@app.get("/api/facets")343def facets(city: str | None = None):344 """Valeurs distinctes pour construire les filtres du frontend."""345 con = db.connect()346 base = " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL"347 out = {348 "cities": [r["city"] for r in con.execute(349 f"SELECT DISTINCT city{base} AND city<>'' ORDER BY city")],350 # régions administratives (17) — le repli générique « Québec »351 # (appartenance provinciale sans région précise) n'est pas une facette352 "regions": [dict(r) for r in con.execute(353 f"SELECT region, COUNT(*) n{base} AND region<>''"354 " AND region<>'Québec' GROUP BY region ORDER BY region")],355 "languages": [dict(r) for r in con.execute(356 f"SELECT language, COUNT(*) n{base} AND language IS NOT NULL"357 " AND language<>'' GROUP BY language ORDER BY n DESC")],358 "categories": [dict(r) for r in con.execute(359 f"SELECT category, COUNT(*) n{base} AND category<>''"360 " GROUP BY category ORDER BY n DESC")],361 "employers": [dict(r) for r in con.execute(362 f"SELECT employer, COUNT(*) n{base} AND employer<>''"363 " GROUP BY employer ORDER BY n DESC LIMIT 100")],364 "work_modes": [r["work_mode"] for r in con.execute(365 f"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL"366 " ORDER BY work_mode")],367 "employment_types": [r["employment_type"] for r in con.execute(368 f"SELECT DISTINCT employment_type{base}"369 " AND employment_type IS NOT NULL ORDER BY employment_type")],370 "seniorities": [dict(r) for r in con.execute(371 f"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL"372 " GROUP BY seniority ORDER BY n DESC")],373 "sources": [dict(r) for r in con.execute(374 f"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC")],375 }376 con.close()377 return out378379380@app.get("/api/sources")381def sources():382 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]383 con = db.connect()384 counts = {r["source"]: r["n"] for r in con.execute(385 "SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source")}386 last = {r["source"]: r["ts"] for r in con.execute(387 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}388 con.close()389 for s in registry:390 s["active_jobs"] = counts.get(s["id"], 0)391 s["last_sync"] = last.get(s["id"])392 return {"sources": registry}393394395@app.get("/api/stats")396def stats(syncs_since_h: float | None = None):397 con = db.connect()398 row = con.execute(399 """SELECT COUNT(*) total,400 COUNT(DISTINCT employer) employers,401 COUNT(DISTINCT source) sources,402 SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,403 AVG(salary_year_min) avg_salary_year,404 SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,405 SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded406 FROM jobs WHERE active=1 AND dup_of IS NULL407 AND quarantine IS NULL""").fetchone()408 quarantined = con.execute(409 "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL"410 ).fetchone()["n"]411 # médiane des salaires annuels affichés (point médian des fourchettes) —412 # plus robuste que la moyenne face aux salaires de direction413 sal = sorted(r["v"] for r in con.execute(414 """SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))415 / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL416 AND quarantine IS NULL AND salary_year_min IS NOT NULL"""))417 median_salary = round(sal[len(sal) // 2], 0) if sal else None418 cities = [dict(r) for r in con.execute(419 """SELECT city, COUNT(*) n FROM jobs420 WHERE active=1 AND dup_of IS NULL AND city<>''421 GROUP BY city ORDER BY n DESC LIMIT 15""")]422 categories = [dict(r) for r in con.execute(423 """SELECT category, COUNT(*) n FROM jobs424 WHERE active=1 AND dup_of IS NULL AND category<>''425 GROUP BY category ORDER BY n DESC""")]426 # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on427 # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre428 # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation429 # des sources → fausses alertes « stale »).430 if syncs_since_h and syncs_since_h > 0:431 cutoff = time.time() - syncs_since_h * 3600432 log = [dict(r) for r in con.execute(433 "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",434 (cutoff,))]435 if len(log) < 20:436 log = [dict(r) for r in con.execute(437 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]438 else:439 log = [dict(r) for r in con.execute(440 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]441 # offres directes (pages carrières + dépôt direct) c. portails agrégateurs442 agg = sorted(AGGREGATORS) or ["__aucun__"]443 ph = ",".join("?" * len(agg))444 direct = con.execute(445 f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) e,446 COUNT(DISTINCT source) s447 FROM jobs WHERE active=1 AND dup_of IS NULL448 AND source NOT IN ({ph})""", agg).fetchone()449 portal = con.execute(450 f"""SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL451 AND source IN ({ph})""", agg).fetchone()452 dups = con.execute(453 "SELECT COUNT(*) n FROM jobs WHERE active=1"454 " AND dup_of IS NOT NULL").fetchone()455 con.close()456 return {**dict(row), "median_salary_year": median_salary,457 "top_cities": cities, "categories": categories,458 "direct_jobs": direct["n"], "direct_employers": direct["e"],459 "direct_sources": direct["s"], "portal_jobs": portal["n"],460 "duplicates_hidden": dups["n"], "quarantined": quarantined,461 "recent_syncs": log}462463464@app.get("/api/stats/dashboard")465def stats_dashboard(period: str = "30j",466 from_: str | None = Query(None, alias="from"),467 to: str | None = None):468 """Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).469 Cache mémoire 5 min par clé de période (voir jobka/statsdash.py)."""470 from . import statsdash471 if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):472 period = "30j"473 return statsdash.compute(period, from_, to)474475476@app.get("/api/stats/report")477def stats_report(period: str = "30j",478 from_: str | None = Query(None, alias="from"),479 to: str | None = None,480 mode: str = "complet"):481 """Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |482 tendances | repartitions | donnees (mode inconnu -> complet)."""483 from . import kapdf, statsdash484 if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):485 period = "30j"486 if mode not in kapdf.REPORT_MODES:487 mode = "complet"488 dash = statsdash.compute(period, from_, to)489 data = kapdf.GroupeKAReport(490 site={"wordmark": "Job·Ka", "accent": "#0c8599",491 "domain": "www.job-ka.com",492 "tagline": "Tous les emplois des employeurs québécois"},493 dashboard=dash,494 mode=mode,495 ).build()496 fname = kapdf.filename("job-ka", period, mode)497 return Response(498 content=data, media_type="application/pdf",499 headers={"Content-Disposition": f'attachment; filename="{fname}"'})500501502@app.get("/api/stats/catalog")503def stats_catalog(period: str = "30j",504 from_: str | None = Query(None, alias="from"),505 to: str | None = None):506 """v3 — blocs composables pour le constructeur de rapports personnalisés."""507 from . import kapdf, statsdash508 if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):509 period = "30j"510 dash = statsdash.compute(period, from_, to)511 return {"updated": dash.get("updated"), "period": dash.get("period"),512 "blocks": kapdf.catalog(dash)}513514515@app.post("/api/stats/report/custom")516def stats_report_custom(spec: dict = Body(...)):517 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",518 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""519 from . import kapdf, statsdash520 period = str(spec.get("period") or "30j")521 if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):522 period = "30j"523 dash = statsdash.compute(period, str(spec.get("from") or "") or None,524 str(spec.get("to") or "") or None)525 known = {b["key"] for b in kapdf.catalog(dash)}526 blocks = [b for b in (spec.get("blocks") or [])527 if isinstance(b, dict) and b.get("key") in known][:40]528 if not blocks:529 raise HTTPException(400, "Aucun bloc valide dans la composition")530 data = kapdf.GroupeKAReport(531 site={"wordmark": "Job·Ka", "accent": "#0c8599",532 "domain": "www.job-ka.com",533 "tagline": "Tous les emplois des employeurs québécois"},534 dashboard=dash, mode=kapdf.CUSTOM_MODE,535 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},536 ).build()537 fname = kapdf.filename("job-ka", period, kapdf.CUSTOM_MODE)538 return Response(539 content=data, media_type="application/pdf",540 headers={"Content-Disposition": f'attachment; filename="{fname}"'})541542543# --- Dépôt direct d'offres par les employeurs ---------------------------------544class OffreDeposee(BaseModel):545 """Offre soumise par un employeur (validation minimale, modérée ensuite)."""546547 employer: str = Field(min_length=2, max_length=120)548 title: str = Field(min_length=3, max_length=160)549 description: str = Field(min_length=30, max_length=20000)550 contact_email: str = Field(min_length=5, max_length=160)551 city: str = Field("", max_length=80)552 url: str = Field("", max_length=400)553 salary_label: str = Field("", max_length=120)554 employment_type: str | None = None555 work_mode: str | None = None556 website: str = "" # pot de miel anti-robots : doit rester vide557558559@app.post("/api/employeurs/offres")560def deposer_offre(offre: OffreDeposee):561 """Canal de dépôt direct : l'offre est stockée INACTIVE (source562 « depot-direct ») en attente de modération manuelle. Voir /employeurs."""563 if offre.website: # robot pris au pot de miel564 return {"status": "reçu"}565 if not re.match(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$", offre.contact_email):566 raise HTTPException(422, "Courriel de contact invalide")567 if offre.url and not re.match(r"^https?://", offre.url):568 raise HTTPException(422, "Lien invalide : http(s):// requis")569 if offre.employment_type and offre.employment_type not in (570 "temps_plein", "temps_partiel", "contractuel", "stage", "saisonnier"):571 raise HTTPException(422, "Type d'emploi inconnu")572 if offre.work_mode and offre.work_mode not in (573 "presentiel", "hybride", "teletravail"):574 raise HTTPException(422, "Mode de travail inconnu")575576 job = JobPosting(577 source="depot-direct", external_id=secrets.token_hex(6),578 url=offre.url, employer=offre.employer, title=offre.title,579 description=offre.description, city=offre.city,580 salary_label=offre.salary_label,581 employment_type=offre.employment_type, work_mode=offre.work_mode,582 ats="depot-direct",583 )584 job.details["contact_email"] = offre.contact_email585 job.details["moderation"] = "en_attente"586 job.finalize()587588 now = time.time()589 cols = {590 "uid": job.uid, "source": job.source, "external_id": job.external_id,591 "url": job.url, "employer": job.employer, "title": job.title,592 "description": job.description, "city": job.city,593 "region": job.region, "postal_code": job.postal_code,594 "location_label": job.location_label, "work_mode": job.work_mode,595 "employment_type": job.employment_type,596 "salary_min": job.salary_min, "salary_max": job.salary_max,597 "salary_unit": job.salary_unit, "salary_label": job.salary_label,598 "salary_year_min": job.salary_year_min(),599 "salary_year_max": job.salary_year_max(),600 "salary_hour_min": job.salary_hour_min(),601 "salary_hour_max": job.salary_hour_max(),602 "benefits": json.dumps(job.benefits, ensure_ascii=False),603 "requirements": json.dumps(job.requirements, ensure_ascii=False),604 "category": job.category, "ats": job.ats,605 "details": json.dumps(job.details, ensure_ascii=False),606 "content_hash": job.content_hash(),607 "first_seen": now, "last_seen": now, "updated_at": now,608 "miss_count": 0, "active": 0, # inactive tant que non modérée609 }610 con = db.connect()611 con.execute(612 f"INSERT INTO jobs ({', '.join(cols)}) VALUES "613 f"({', '.join(':' + k for k in cols)})", cols)614 con.commit()615 con.close()616 return {"status": "reçu", "uid": job.uid,617 "message": "Merci ! Votre offre a bien été reçue. Elle sera "618 "vérifiée par notre équipe avant publication."}619620621@app.post("/api/sync")622def trigger_sync(background: BackgroundTasks, source: str | None = None):623 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""624 def _job():625 with _sync_lock:626 ingest.run([source] if source else None)627 background.add_task(_job)628 return {"status": "démarré", "source": source or "toutes"}629630631# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------632@app.get("/api/favorites")633def favorites(request: Request):634 """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""635 user = auth.current_user(request)636 if not user:637 raise HTTPException(401, "Connexion KA ID requise")638 items = hubfav.hub_list(user.get("ka_id") or "")639 if items is None:640 raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")641 return {"ids": [i["item_id"] for i in items if i.get("item_id")],642 "items": items}643644645@app.post("/api/favorites/toggle")646def toggle_favorite(request: Request, body: dict = Body(...)):647 """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub648 Groupe KA de façon synchrone : le hub est la seule source de vérité."""649 user = auth.current_user(request)650 if not user:651 raise HTTPException(401, "Connexion KA ID requise")652 ka_id = user.get("ka_id") or ""653 if not hubfav.linked(ka_id):654 raise HTTPException(403, "Compte non relié au hub Groupe KA")655 on = bool(body.get("on"))656 item = hubfav.clean_item(body.get("item") or {})657 if not item.get("item_id"):658 raise HTTPException(422, "item.item_id requis")659 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):660 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")661 # signal fort du moteur de préférences (features lues de la BD)662 uid = item["item_id"]663 con = db.connect()664 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()665 con.close()666 kaid.track(user, "favorite" if on else "unfavorite", entity_type="job",667 entity_id=uid,668 features=_kaid_features(_row_to_dict(row)) if row else None)669 return {"ok": True, "on": on}670671672# --- Frontend (build Vite) ---------------------------------------------------673if FRONTEND_DIST.exists():674 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")675676 # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages677 # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le678 # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).679 from . import seo # noqa: E402680 app.include_router(seo.router)681682 @app.middleware("http")683 async def _cache_headers(request, call_next):684 """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —685 sinon les navigateurs gardent une vieille version après un déploiement."""686 resp = await call_next(request)687 path = request.url.path688 if path.startswith("/assets/"):689 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"690 elif "text/html" in (resp.headers.get("content-type") or ""):691 resp.headers["Cache-Control"] = "no-cache"692 return resp693694 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie695 # index.html avec un statut 404 (pas de soft-404 pour les moteurs)696 _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs",697 "favoris", "confidentialite", "conditions"}698 _CLIENT_PREFIXES = ("emploi/",)699700 @app.get("/{full_path:path}")701 def spa(full_path: str):702 target = FRONTEND_DIST / full_path703 if full_path and target.is_file():704 return FileResponse(target)705 if full_path and target.is_dir() and (target / "index.html").is_file():706 # sous-répertoire statique avec index (ex. /doc/) — servi tel quel707 return FileResponse(target / "index.html")708 known = (full_path == "" or full_path in _CLIENT_ROUTES709 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))710 return FileResponse(FRONTEND_DIST / "index.html",711 status_code=200 if known else 404,712 headers={"Cache-Control": "no-cache"})713