# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/web.py # Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist) # Créé : 2026-08-17 Modifié : 2026-08-25 # ============================================================================= from __future__ import annotations import json import re import secrets import threading import time from pathlib import Path from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from . import auth, db, hubfav, ingest, kaid from .dedup import AGGREGATORS from .schema import JobPosting ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" FRONTEND_DIST = ROOT / "frontend" / "dist" app = FastAPI(title="Job-Ka API", version="1.0", description="Agrégateur d'offres d'emploi — employeurs québécois, " "directement à la source") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000) # connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py app.include_router(auth.router) # personnalisation KA ID v2 (jobka/kaid.py, canonique ka-ui.git/kaid) kaid.init("job-ka") app.include_router(kaid.build_router(auth.current_user)) _sync_lock = threading.Lock() def _kaid_features(d: dict) -> dict: """Caractéristiques d'une offre pour le profil de préférences KA ID.""" salary = d.get("salary_year_max") or d.get("salary_year_min") return {k: v for k, v in { "city": d.get("city"), "region": d.get("region"), "category": d.get("category"), "employer": d.get("employer"), "work_mode": d.get("work_mode"), "employment_type": d.get("employment_type"), "seniority": d.get("seniority"), "language": d.get("language"), "salary": salary, }.items() if v not in (None, "")} def _row_to_dict(row) -> dict: d = dict(row) d["benefits"] = json.loads(d.get("benefits") or "[]") d["requirements"] = json.loads(d.get("requirements") or "{}") d["details"] = json.loads(d.get("details") or "{}") if d.get("dup_sources"): try: d["dup_sources"] = json.loads(d["dup_sources"]) except (ValueError, TypeError): d["dup_sources"] = [] # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail) d["is_direct"] = d.get("source") not in AGGREGATORS return d @app.get("/health") def health(): """Healthcheck de déploiement : BD accessible + fraîcheur des syncs.""" try: con = db.connect() total = con.execute( "SELECT COUNT(*) c FROM jobs WHERE active=1").fetchone()["c"] last = con.execute( "SELECT MAX(ts) ts FROM sync_log WHERE ok=1").fetchone()["ts"] con.close() except Exception as exc: raise HTTPException(500, f"BD inaccessible : {exc}") return {"status": "ok", "active_jobs": total, "last_sync_age_s": round(time.time() - last) if last else None} @app.get("/api/jobs") def list_jobs( request: Request, city: str | None = None, region: str | None = None, category: str | None = None, employer: str | None = None, source: str | None = None, work_mode: str | None = None, employment_type: str | None = None, seniority: str | None = None, # stage | junior | intermediaire | senior | direction salary_min: float | None = None, # $/an (converti côté serveur) with_salary: int | None = None, # 1 = transparence salariale seulement posted_after: str | None = None, # ISO : offres publiées depuis… language: str | None = None, # fr | en | bilingue q: str | None = None, active: int = 1, quarantined: int | None = None, # 1 = quarantaine seulement (supervision) sort: str = "recent", # recent | salary limit: int = Query(100, le=2000), offset: int = 0, ): con = db.connect() sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués args: list = [] # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py) sql += " AND quarantine IS NOT NULL" if quarantined == 1 \ else " AND quarantine IS NULL" if active in (0, 1): sql += " AND active=?"; args.append(active) if city: sql += " AND city=?"; args.append(city) if region: sql += " AND region=?"; args.append(region) if language: sql += " AND language=?"; args.append(language) if category: sql += " AND category=?"; args.append(category) if employer: sql += " AND employer LIKE ?"; args.append(f"%{employer}%") if source: sql += " AND source=?"; args.append(source) if work_mode: sql += " AND work_mode=?"; args.append(work_mode) if employment_type: sql += " AND employment_type=?"; args.append(employment_type) if seniority: sql += " AND seniority=?"; args.append(seniority) if salary_min is not None: sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?") args.append(salary_min) if with_salary == 1: sql += " AND salary_min IS NOT NULL" if posted_after: sql += " AND date_posted IS NOT NULL AND date_posted>=?" args.append(posted_after) if q: sql += " AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)" args += [f"%{q}%"] * 3 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] if sort == "salary": sql += " ORDER BY salary_year_max IS NULL, salary_year_max DESC" else: sql += " ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC" sql += " LIMIT ? OFFSET ?" args += [limit, offset] rows = [] for r in con.execute(sql, args).fetchall(): d = _row_to_dict(r) d.pop("description", None) # liste allégée : la fiche a le texte complet rows.append(d) con.close() # personnalisation KA ID : journal + reclassement (tri par défaut seulement, # jamais un tri explicite ; intention de session > préférences long terme) personalized = False user = auth.current_user(request) if user: active_filters = {"city": city, "region": region, "category": category, "employer": employer, "work_mode": work_mode, "employment_type": employment_type, "seniority": seniority, "language": language, "salary_min": salary_min, "q": q} active_filters = {k: v for k, v in active_filters.items() if v} if (q or active_filters) and offset == 0: kaid.track(user, "search", query=q, filters=active_filters) if sort == "recent": active = set(active_filters) - {"q", "salary_min"} if salary_min is not None: active.add("salary") rows, personalized = kaid.rerank( rows, user, features_of=_kaid_features, active_dims=active) return {"total": total, "count": len(rows), "jobs": rows, "personalized": personalized} @app.get("/api/jobs.geojson") def jobs_geojson( city: str | None = None, region: str | None = None, category: str | None = None, work_mode: str | None = None, employment_type: str | None = None, salary_min: float | None = None, with_salary: int | None = None, language: str | None = None, q: str | None = None, bbox: str | None = None, limit: int = Query(3000, le=8000), ): """Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs). `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo). Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher « N résultats sans position sur la carte ». """ con = db.connect() sql = ("SELECT uid, title, title_clean, employer, city, category, work_mode," " employment_type, salary_min, salary_max, salary_unit," " salary_year_min, salary_year_max, date_posted, source, lat, lng" " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL" " AND lat IS NOT NULL AND lng IS NOT NULL") args: list = [] if bbox: try: west, south, east, north = (float(v) for v in bbox.split(",")) except ValueError: raise HTTPException(400, "bbox attendu : ouest,sud,est,nord") sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" args += [south, north, west, east] if city: sql += " AND city=?"; args.append(city) if region: sql += " AND region=?"; args.append(region) if category: sql += " AND category=?"; args.append(category) if work_mode: sql += " AND work_mode=?"; args.append(work_mode) if employment_type: sql += " AND employment_type=?"; args.append(employment_type) if salary_min is not None: sql += " AND salary_year_max IS NOT NULL AND salary_year_max>=?" args.append(salary_min) if with_salary == 1: sql += " AND salary_min IS NOT NULL" if language: sql += " AND language=?"; args.append(language) if q: sql += " AND (title LIKE ? OR employer LIKE ?)" args += [f"%{q}%"] * 2 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "") args_all = list(args) if bbox: sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "") del args_all[0:4] total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"] sql += " ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?" args.append(limit) features = [] for r in con.execute(sql, args).fetchall(): features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, "properties": { "uid": r["uid"], "title": r["title_clean"] or r["title"], "employer": r["employer"], "city": r["city"], "category": r["category"], "work_mode": r["work_mode"], "employment_type": r["employment_type"], "salary_min": r["salary_min"], "salary_max": r["salary_max"], "salary_unit": r["salary_unit"], "salary_year_min": r["salary_year_min"], "salary_year_max": r["salary_year_max"], "date_posted": r["date_posted"], "source": r["source"], "is_direct": r["source"] not in AGGREGATORS, }, }) con.close() return {"type": "FeatureCollection", "features": features, "totalGeocoded": total_geo, "totalMatching": total_all} # --- contexte salarial de marché (métrique de la fiche d'offre) --------------- # Distribution des salaires annuels affichés par catégorie (point médian de la # fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une # base resynchronisée à l'heure, et jamais de requête lourde par visite. _SALARY_CTX_TTL = 600.0 _salary_ctx_cache: dict[str, tuple[float, dict | None]] = {} def _salary_context(con, category: str) -> dict | None: """Quartiles des salaires annuels affichés dans la catégorie (n >= 20).""" key = category or "__toutes__" hit = _salary_ctx_cache.get(key) if hit and time.time() - hit[0] < _SALARY_CTX_TTL: return hit[1] sql = ("SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))" " / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL" " AND quarantine IS NULL AND salary_year_min IS NOT NULL") args: list = [] if category: sql += " AND category=?"; args.append(category) vals = sorted(r["v"] for r in con.execute(sql, args)) ctx = None if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative def pct(p: float) -> float: return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0) ctx = {"category": category or "", "n": len(vals), "p25": pct(0.25), "median": pct(0.50), "p75": pct(0.75)} _salary_ctx_cache[key] = (time.time(), ctx) return ctx @app.get("/api/jobs/{uid}") def get_job(uid: str, request: Request): con = db.connect() row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() d = None if row is not None: d = _row_to_dict(row) d["salary_history"] = [dict(r) for r in con.execute( "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?" " ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] # métriques de contexte : salaire vs marché de la catégorie + volume # d'offres actives de l'employeur (comparables, jamais inventées) ctx = _salary_context(con, d.get("category") or "") if ctx and d.get("salary_year_min") is not None: mid = (d["salary_year_min"] + (d.get("salary_year_max") or d["salary_year_min"])) / 2.0 ctx = {**ctx, "job_mid": round(mid, 0), "delta_pct": round((mid - ctx["median"]) / ctx["median"] * 100, 1)} d["salary_context"] = ctx if d.get("employer"): d["employer_jobs"] = con.execute( "SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL" " AND quarantine IS NULL AND employer=?", (d["employer"],)).fetchone()["n"] con.close() if d is None: raise HTTPException(404, "Offre introuvable") kaid.track(auth.current_user(request), "detail_view", entity_type="job", entity_id=uid, features=_kaid_features(d)) return d @app.get("/api/facets") def facets(city: str | None = None): """Valeurs distinctes pour construire les filtres du frontend.""" con = db.connect() base = " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL" out = { "cities": [r["city"] for r in con.execute( f"SELECT DISTINCT city{base} AND city<>'' ORDER BY city")], # régions administratives (17) — le repli générique « Québec » # (appartenance provinciale sans région précise) n'est pas une facette "regions": [dict(r) for r in con.execute( f"SELECT region, COUNT(*) n{base} AND region<>''" " AND region<>'Québec' GROUP BY region ORDER BY region")], "languages": [dict(r) for r in con.execute( f"SELECT language, COUNT(*) n{base} AND language IS NOT NULL" " AND language<>'' GROUP BY language ORDER BY n DESC")], "categories": [dict(r) for r in con.execute( f"SELECT category, COUNT(*) n{base} AND category<>''" " GROUP BY category ORDER BY n DESC")], "employers": [dict(r) for r in con.execute( f"SELECT employer, COUNT(*) n{base} AND employer<>''" " GROUP BY employer ORDER BY n DESC LIMIT 100")], "work_modes": [r["work_mode"] for r in con.execute( f"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL" " ORDER BY work_mode")], "employment_types": [r["employment_type"] for r in con.execute( f"SELECT DISTINCT employment_type{base}" " AND employment_type IS NOT NULL ORDER BY employment_type")], "seniorities": [dict(r) for r in con.execute( f"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL" " GROUP BY seniority ORDER BY n DESC")], "sources": [dict(r) for r in con.execute( f"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC")], } con.close() return out @app.get("/api/sources") def sources(): registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] con = db.connect() counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source")} last = {r["source"]: r["ts"] for r in con.execute( "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} con.close() for s in registry: s["active_jobs"] = counts.get(s["id"], 0) s["last_sync"] = last.get(s["id"]) return {"sources": registry} @app.get("/api/stats") def stats(syncs_since_h: float | None = None): con = db.connect() row = con.execute( """SELECT COUNT(*) total, COUNT(DISTINCT employer) employers, COUNT(DISTINCT source) sources, SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary, AVG(salary_year_min) avg_salary_year, SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote, SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL""").fetchone() quarantined = con.execute( "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL" ).fetchone()["n"] # médiane des salaires annuels affichés (point médian des fourchettes) — # plus robuste que la moyenne face aux salaires de direction sal = sorted(r["v"] for r in con.execute( """SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min)) / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL AND salary_year_min IS NOT NULL""")) median_salary = round(sal[len(sal) // 2], 0) if sal else None cities = [dict(r) for r in con.execute( """SELECT city, COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL AND city<>'' GROUP BY city ORDER BY n DESC LIMIT 15""")] categories = [dict(r) for r in con.execute( """SELECT category, COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL AND category<>'' GROUP BY category ORDER BY n DESC""")] # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation # des sources → fausses alertes « stale »). if syncs_since_h and syncs_since_h > 0: cutoff = time.time() - syncs_since_h * 3600 log = [dict(r) for r in con.execute( "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000", (cutoff,))] if len(log) < 20: log = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] else: log = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] # offres directes (pages carrières + dépôt direct) c. portails agrégateurs agg = sorted(AGGREGATORS) or ["__aucun__"] ph = ",".join("?" * len(agg)) direct = con.execute( f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) e, COUNT(DISTINCT source) s FROM jobs WHERE active=1 AND dup_of IS NULL AND source NOT IN ({ph})""", agg).fetchone() portal = con.execute( f"""SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL AND source IN ({ph})""", agg).fetchone() dups = con.execute( "SELECT COUNT(*) n FROM jobs WHERE active=1" " AND dup_of IS NOT NULL").fetchone() con.close() return {**dict(row), "median_salary_year": median_salary, "top_cities": cities, "categories": categories, "direct_jobs": direct["n"], "direct_employers": direct["e"], "direct_sources": direct["s"], "portal_jobs": portal["n"], "duplicates_hidden": dups["n"], "quarantined": quarantined, "recent_syncs": log} @app.get("/api/stats/dashboard") def stats_dashboard(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None): """Tableau de bord analytique — contrat commun Groupe KA (SPEC.md). Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).""" from . import statsdash if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): period = "30j" return statsdash.compute(period, from_, to) @app.get("/api/stats/report") def stats_report(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None, mode: str = "complet"): """Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese | tendances | repartitions | donnees (mode inconnu -> complet).""" from . import kapdf, statsdash if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): period = "30j" if mode not in kapdf.REPORT_MODES: mode = "complet" dash = statsdash.compute(period, from_, to) data = kapdf.GroupeKAReport( site={"wordmark": "Job·Ka", "accent": "#0c8599", "domain": "www.job-ka.com", "tagline": "Tous les emplois des employeurs québécois"}, dashboard=dash, mode=mode, ).build() fname = kapdf.filename("job-ka", period, mode) return Response( content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats/catalog") def stats_catalog(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None): """v3 — blocs composables pour le constructeur de rapports personnalisés.""" from . import kapdf, statsdash if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): period = "30j" dash = statsdash.compute(period, from_, to) return {"updated": dash.get("updated"), "period": dash.get("period"), "blocks": kapdf.catalog(dash)} @app.post("/api/stats/report/custom") def stats_report_custom(spec: dict = Body(...)): """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis).""" from . import kapdf, statsdash period = str(spec.get("period") or "30j") if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): period = "30j" dash = statsdash.compute(period, str(spec.get("from") or "") or None, str(spec.get("to") or "") or None) known = {b["key"] for b in kapdf.catalog(dash)} blocks = [b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known][:40] if not blocks: raise HTTPException(400, "Aucun bloc valide dans la composition") data = kapdf.GroupeKAReport( site={"wordmark": "Job·Ka", "accent": "#0c8599", "domain": "www.job-ka.com", "tagline": "Tous les emplois des employeurs québécois"}, dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("job-ka", period, kapdf.CUSTOM_MODE) return Response( content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) # --- Dépôt direct d'offres par les employeurs --------------------------------- class OffreDeposee(BaseModel): """Offre soumise par un employeur (validation minimale, modérée ensuite).""" employer: str = Field(min_length=2, max_length=120) title: str = Field(min_length=3, max_length=160) description: str = Field(min_length=30, max_length=20000) contact_email: str = Field(min_length=5, max_length=160) city: str = Field("", max_length=80) url: str = Field("", max_length=400) salary_label: str = Field("", max_length=120) employment_type: str | None = None work_mode: str | None = None website: str = "" # pot de miel anti-robots : doit rester vide @app.post("/api/employeurs/offres") def deposer_offre(offre: OffreDeposee): """Canal de dépôt direct : l'offre est stockée INACTIVE (source « depot-direct ») en attente de modération manuelle. Voir /employeurs.""" if offre.website: # robot pris au pot de miel return {"status": "reçu"} if not re.match(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$", offre.contact_email): raise HTTPException(422, "Courriel de contact invalide") if offre.url and not re.match(r"^https?://", offre.url): raise HTTPException(422, "Lien invalide : http(s):// requis") if offre.employment_type and offre.employment_type not in ( "temps_plein", "temps_partiel", "contractuel", "stage", "saisonnier"): raise HTTPException(422, "Type d'emploi inconnu") if offre.work_mode and offre.work_mode not in ( "presentiel", "hybride", "teletravail"): raise HTTPException(422, "Mode de travail inconnu") job = JobPosting( source="depot-direct", external_id=secrets.token_hex(6), url=offre.url, employer=offre.employer, title=offre.title, description=offre.description, city=offre.city, salary_label=offre.salary_label, employment_type=offre.employment_type, work_mode=offre.work_mode, ats="depot-direct", ) job.details["contact_email"] = offre.contact_email job.details["moderation"] = "en_attente" job.finalize() now = time.time() cols = { "uid": job.uid, "source": job.source, "external_id": job.external_id, "url": job.url, "employer": job.employer, "title": job.title, "description": job.description, "city": job.city, "region": job.region, "postal_code": job.postal_code, "location_label": job.location_label, "work_mode": job.work_mode, "employment_type": job.employment_type, "salary_min": job.salary_min, "salary_max": job.salary_max, "salary_unit": job.salary_unit, "salary_label": job.salary_label, "salary_year_min": job.salary_year_min(), "salary_year_max": job.salary_year_max(), "salary_hour_min": job.salary_hour_min(), "salary_hour_max": job.salary_hour_max(), "benefits": json.dumps(job.benefits, ensure_ascii=False), "requirements": json.dumps(job.requirements, ensure_ascii=False), "category": job.category, "ats": job.ats, "details": json.dumps(job.details, ensure_ascii=False), "content_hash": job.content_hash(), "first_seen": now, "last_seen": now, "updated_at": now, "miss_count": 0, "active": 0, # inactive tant que non modérée } con = db.connect() con.execute( f"INSERT INTO jobs ({', '.join(cols)}) VALUES " f"({', '.join(':' + k for k in cols)})", cols) con.commit() con.close() return {"status": "reçu", "uid": job.uid, "message": "Merci ! Votre offre a bien été reçue. Elle sera " "vérifiée par notre équipe avant publication."} @app.post("/api/sync") def trigger_sync(background: BackgroundTasks, source: str | None = None): """Déclenche une synchronisation (équivalent d'un webhook entrant).""" def _job(): with _sync_lock: ingest.run([source] if source else None) background.add_task(_job) return {"status": "démarré", "source": source or "toutes"} # --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------ @app.get("/api/favorites") def favorites(request: Request): """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") items = hubfav.hub_list(user.get("ka_id") or "") if items is None: raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") return {"ids": [i["item_id"] for i in items if i.get("item_id")], "items": items} @app.post("/api/favorites/toggle") def toggle_favorite(request: Request, body: dict = Body(...)): """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub Groupe KA de façon synchrone : le hub est la seule source de vérité.""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") ka_id = user.get("ka_id") or "" if not hubfav.linked(ka_id): raise HTTPException(403, "Compte non relié au hub Groupe KA") on = bool(body.get("on")) item = hubfav.clean_item(body.get("item") or {}) if not item.get("item_id"): raise HTTPException(422, "item.item_id requis") if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") # signal fort du moteur de préférences (features lues de la BD) uid = item["item_id"] con = db.connect() row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() con.close() kaid.track(user, "favorite" if on else "unfavorite", entity_type="job", entity_id=uid, features=_kaid_features(_row_to_dict(row)) if row else None) return {"ok": True, "on": on} # --- Frontend (build Vite) --------------------------------------------------- if FRONTEND_DIST.exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi). from . import seo # noqa: E402 app.include_router(seo.router) @app.middleware("http") async def _cache_headers(request, call_next): """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé — sinon les navigateurs gardent une vieille version après un déploiement.""" resp = await call_next(request) path = request.url.path if path.startswith("/assets/"): resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif "text/html" in (resp.headers.get("content-type") or ""): resp.headers["Cache-Control"] = "no-cache" return resp # routes rendues uniquement côté client — tout autre chemin inconnu renvoie # index.html avec un statut 404 (pas de soft-404 pour les moteurs) _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs", "favoris", "confidentialite", "conditions"} _CLIENT_PREFIXES = ("emploi/",) @app.get("/{full_path:path}") def spa(full_path: str): target = FRONTEND_DIST / full_path if full_path and target.is_file(): return FileResponse(target) if full_path and target.is_dir() and (target / "index.html").is_file(): # sous-répertoire statique avec index (ex. /doc/) — servi tel quel return FileResponse(target / "index.html") known = (full_path == "" or full_path in _CLIENT_ROUTES or any(full_path.startswith(p) for p in _CLIENT_PREFIXES)) return FileResponse(FRONTEND_DIST / "index.html", status_code=200 if known else 404, headers={"Cache-Control": "no-cache"})