SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
2 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
31.4 KB · 713 lines python
Raw Blame History
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