SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

Badge « Offre directe » + stats directes/portails + canal de dépôt direct employeurs

- is_direct (source hors AGGREGATORS) exposé sur /api/jobs, /api/jobs/{uid}
  et jobs.geojson ; badge sobre sur les cartes d offres (frontend rebuild)
- /api/stats : direct_jobs, direct_employers, direct_sources, portal_jobs,
  duplicates_hidden ; KPI « Offres directes employeur » au tableau de bord
- POST /api/employeurs/offres : dépôt direct validé (pydantic + pot de miel),
  normalisé par JobPosting.finalize(), stocké source=depot-direct INACTIF en
  attente de modération ; page /employeurs (formulaire + explications)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent f6e7571

7 changed files +311 −1

modified frontend/src/App.tsx +4 −0
@@ -16,6 +16,7 @@ import JobPage from "./pages/Job";
16 16 import Sources from "./pages/Sources";
17 17 import Stats from "./pages/Stats";
18 18 import Contact from "./pages/Contact";
19 +import Employeurs from "./pages/Employeurs";
19 20 import GroupeKaBadge from "./ka/GroupeKaBadge";
20 21 import KaFooter from "./ka/KaFooter";
21 22
@@ -75,6 +76,7 @@ export default function App() {
75 76 <NavLink to="/carte" className={({ isActive }) => (isActive ? "active" : "")}>Carte</NavLink>
76 77 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>Employeurs</NavLink>
77 78 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>Stats</NavLink>
79 + <NavLink to="/employeurs" className={({ isActive }) => (isActive ? "active" : "")}>Publier</NavLink>
78 80 <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>Contact</NavLink>
79 81 </nav>
80 82 <div className="auth-box">
@@ -93,6 +95,7 @@ export default function App() {
93 95 <Route path="/sources" element={<Sources />} />
94 96 <Route path="/stats" element={<Stats />} />
95 97 <Route path="/contact" element={<Contact />} />
98 + <Route path="/employeurs" element={<Employeurs />} />
96 99 <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} />
97 100 </Routes>
98 101 <div className="prefooter">
@@ -106,6 +109,7 @@ export default function App() {
106 109 <NavLink to="/carte">Carte</NavLink>
107 110 <NavLink to="/sources">Employeurs</NavLink>
108 111 <NavLink to="/stats">Stats</NavLink>
112 + <NavLink to="/employeurs">Publier une offre</NavLink>
109 113 <NavLink to="/contact">Contact</NavLink>
110 114 </div>
111 115 </div>
modified frontend/src/api.ts +6 −0
@@ -43,6 +43,7 @@ export interface Job {
43 43 lng: number | null;
44 44 active: number;
45 45 dup_sources?: string[];
46 + is_direct?: boolean;
46 47 }
47 48
48 49 export interface JobList {
@@ -81,6 +82,11 @@ export interface Stats {
81 82 avg_salary_year: number | null;
82 83 remote: number;
83 84 geocoded: number;
85 + direct_jobs?: number;
86 + direct_employers?: number;
87 + direct_sources?: number;
88 + portal_jobs?: number;
89 + duplicates_hidden?: number;
84 90 top_cities: { city: string; n: number }[];
85 91 categories: { category: string; n: number }[];
86 92 recent_syncs: {
modified frontend/src/components/JobCard.tsx +8 −0
@@ -19,6 +19,14 @@ export default function JobCard({ job }: { job: Job }) {
19 19 <span className="employer">{job.employer}</span>
20 20 {job.city ? <span className="muted"> — {job.city}</span> : null}
21 21 <div className="meta">
22 + {job.is_direct !== false && (
23 + <span
24 + className="badge direct"
25 + title="Offre publiée à la source : page carrière de l'employeur, pas un portail"
26 + >
27 + Offre directe
28 + </span>
29 + )}
22 30 {salary && <span className="badge salary">{salary}</span>}
23 31 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
24 32 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
added frontend/src/pages/Employeurs.tsx +173 −0
@@ -0,0 +1,173 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/pages/Employeurs.tsx
7 + * Rôle : Page « Publier une offre » — dépôt direct par les employeurs
8 + * (POST /api/employeurs/offres, modération avant publication)
9 + * Créé : 2026-08-18 Modifié : 2026-08-18
10 + * =============================================================================
11 + */
12 +import { FormEvent, useEffect, useState } from "react";
13 +
14 +interface FormState {
15 + employer: string;
16 + contact_email: string;
17 + title: string;
18 + city: string;
19 + url: string;
20 + salary_label: string;
21 + employment_type: string;
22 + work_mode: string;
23 + description: string;
24 + website: string; // pot de miel anti-robots (doit rester vide)
25 +}
26 +
27 +const EMPTY: FormState = {
28 + employer: "", contact_email: "", title: "", city: "", url: "",
29 + salary_label: "", employment_type: "", work_mode: "", description: "",
30 + website: "",
31 +};
32 +
33 +export default function EmployeursPage() {
34 + const [form, setForm] = useState<FormState>(EMPTY);
35 + const [sending, setSending] = useState(false);
36 + const [done, setDone] = useState<string | null>(null);
37 + const [err, setErr] = useState<string | null>(null);
38 +
39 + useEffect(() => {
40 + document.title = "Publier une offre | Job-Ka — Un service Groupe KA";
41 + window.scrollTo(0, 0);
42 + }, []);
43 +
44 + const set = (k: keyof FormState) =>
45 + (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
46 + setForm((f) => ({ ...f, [k]: e.target.value }));
47 +
48 + const submit = (e: FormEvent) => {
49 + e.preventDefault();
50 + setSending(true);
51 + setErr(null);
52 + fetch("/api/employeurs/offres", {
53 + method: "POST",
54 + headers: { "Content-Type": "application/json" },
55 + body: JSON.stringify({
56 + ...form,
57 + employment_type: form.employment_type || null,
58 + work_mode: form.work_mode || null,
59 + }),
60 + })
61 + .then(async (r) => {
62 + if (!r.ok) {
63 + const body = await r.json().catch(() => null);
64 + throw new Error(body?.detail?.[0]?.msg ?? body?.detail ?? `Erreur ${r.status}`);
65 + }
66 + return r.json();
67 + })
68 + .then((d) => {
69 + setDone(d.message ?? "Offre reçue — merci !");
70 + setForm(EMPTY);
71 + })
72 + .catch((ex) => setErr(String(ex?.message ?? ex)))
73 + .finally(() => setSending(false));
74 + };
75 +
76 + return (
77 + <div className="container contact-page">
78 + <span className="kicker">Employeurs</span>
79 + <h1>
80 + Publiez votre offre <span className="hl">directement</span>
81 + </h1>
82 + <p className="lede">
83 + Job-Ka référence les offres à la source : pages carrières des
84 + employeurs québécois d'abord, portails ensuite. Vous êtes un employeur
85 + sans page carrière connectée ? Déposez votre offre ici — elle est
86 + vérifiée par notre équipe avant publication, puis affichée avec le
87 + badge « Offre directe employeur ». C'est gratuit.
88 + </p>
89 +
90 + <ul className="muted" style={{ lineHeight: 1.8, marginBottom: 24 }}>
91 + <li>Votre offre reste <b>traçable</b> : elle renvoie vers votre site si vous fournissez un lien.</li>
92 + <li>Si votre entreprise utilise un ATS (Workday, Lever, Njoyn, Taleo…), écrivez-nous :
93 + nous pouvons <b>connecter votre page carrière</b> et synchroniser toutes vos offres automatiquement.</li>
94 + <li>Le courriel de contact sert uniquement à la modération — il n'est jamais affiché.</li>
95 + </ul>
96 +
97 + {done ? (
98 + <div className="card" style={{ padding: 24 }}>
99 + <h3 style={{ marginTop: 0 }}>Offre reçue ✓</h3>
100 + <p>{done}</p>
101 + <button className="btn" type="button" onClick={() => setDone(null)}>
102 + Déposer une autre offre
103 + </button>
104 + </div>
105 + ) : (
106 + <form onSubmit={submit} className="card depot-form"
107 + style={{ padding: 24, display: "grid", gap: 14, maxWidth: 760 }}>
108 + <div style={{ display: "grid", gap: 14, gridTemplateColumns: "1fr 1fr" }}>
109 + <label>Entreprise *
110 + <input required minLength={2} maxLength={120}
111 + value={form.employer} onChange={set("employer")} />
112 + </label>
113 + <label>Courriel de contact *
114 + <input required type="email" maxLength={160}
115 + value={form.contact_email} onChange={set("contact_email")} />
116 + </label>
117 + <label>Titre du poste *
118 + <input required minLength={3} maxLength={160}
119 + value={form.title} onChange={set("title")} />
120 + </label>
121 + <label>Ville
122 + <input maxLength={80} value={form.city} onChange={set("city")} />
123 + </label>
124 + <label>Lien vers l'offre (votre site)
125 + <input type="url" placeholder="https://…" maxLength={400}
126 + value={form.url} onChange={set("url")} />
127 + </label>
128 + <label>Salaire (texte libre)
129 + <input maxLength={120} placeholder="ex. 25 $ à 30 $/h"
130 + value={form.salary_label} onChange={set("salary_label")} />
131 + </label>
132 + <label>Type d'emploi
133 + <select value={form.employment_type} onChange={set("employment_type")}>
134 + <option value="">—</option>
135 + <option value="temps_plein">Temps plein</option>
136 + <option value="temps_partiel">Temps partiel</option>
137 + <option value="contractuel">Contractuel</option>
138 + <option value="stage">Stage</option>
139 + <option value="saisonnier">Saisonnier</option>
140 + </select>
141 + </label>
142 + <label>Mode de travail
143 + <select value={form.work_mode} onChange={set("work_mode")}>
144 + <option value="">—</option>
145 + <option value="presentiel">Présentiel</option>
146 + <option value="hybride">Hybride</option>
147 + <option value="teletravail">Télétravail</option>
148 + </select>
149 + </label>
150 + </div>
151 + <label>Description du poste * <span className="muted">(30 caractères minimum)</span>
152 + <textarea required minLength={30} maxLength={20000} rows={10}
153 + value={form.description} onChange={set("description")} />
154 + </label>
155 + {/* pot de miel : caché aux humains, rempli par les robots */}
156 + <input type="text" name="website" value={form.website}
157 + onChange={set("website")} autoComplete="off" tabIndex={-1}
158 + style={{ position: "absolute", left: "-9999px" }} aria-hidden="true" />
159 + {err && <p style={{ color: "var(--red, #c0392b)" }}>{err}</p>}
160 + <div>
161 + <button className="btn btn-primary" type="submit" disabled={sending}>
162 + {sending ? "Envoi…" : "Soumettre l'offre pour modération"}
163 + </button>
164 + </div>
165 + <p className="muted" style={{ fontSize: 13, margin: 0 }}>
166 + En soumettant, vous confirmez être autorisé à publier cette offre.
167 + Les offres sont vérifiées manuellement avant d'apparaître sur Job-Ka.
168 + </p>
169 + </form>
170 + )}
171 + </div>
172 + );
173 +}
modified frontend/src/styles.css +5 −0
@@ -134,6 +134,11 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
134 134 }
135 135 .badge.salary { background: var(--green-soft); color: var(--green); }
136 136 .badge.mode { background: var(--accent-soft); color: var(--accent-deep); }
137 +.badge.direct {
138 + background: transparent;
139 + border: 1px solid var(--accent, #0c8599);
140 + color: var(--accent-deep, #0b7285);
141 +}
137 142
138 143 /* --- fiche ------------------------------------------------------------------------- */
139 144 .sheet {
modified jobka/statsdash.py +11 −0
@@ -214,6 +214,14 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
214 214 f"SELECT COUNT(*) n{BASE} AND work_mode='teletravail'").fetchone()["n"]
215 215 pct_remote = round(100.0 * remote_n / actives_now, 1) if actives_now else None
216 216
217 + # offres directes (pages carrières + dépôt direct) c. portails agrégateurs
218 + from .dedup import AGGREGATORS
219 + _agg = sorted(AGGREGATORS) or ["__aucun__"]
220 + _ph = ",".join("?" * len(_agg))
221 + direct_n = con.execute(
222 + f"SELECT COUNT(*) n{BASE} AND source NOT IN ({_ph})", _agg).fetchone()["n"]
223 + pct_direct = round(100.0 * direct_n / actives_now, 1) if actives_now else None
224 +
217 225 kpis = [
218 226 {"id": "actives", "label": "Offres actives", "value": actives_now,
219 227 "delta_pct": _pct(actives_now, actives_prev) if actives_prev else None,
@@ -243,6 +251,9 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
243 251 if pct_remote is not None:
244 252 kpis.append({"id": "teletravail", "label": "Offres en télétravail",
245 253 "value": pct_remote, "unit": "%"})
254 + if pct_direct is not None:
255 + kpis.append({"id": "directes", "label": "Offres directes employeur",
256 + "value": pct_direct, "unit": "%"})
246 257 kpis = [{k: v for k, v in kpi.items() if v is not None} for kpi in kpis]
247 258
248 259 # ---- séries temporelles ----------------------------------------------------
modified jobka/web.py +104 −1
@@ -9,6 +9,8 @@
9 9 from __future__ import annotations
10 10
11 11 import json
12 +import re
13 +import secrets
12 14 import threading
13 15 import time
14 16 from pathlib import Path
@@ -18,8 +20,11 @@ from fastapi.middleware.cors import CORSMiddleware
18 20 from fastapi.middleware.gzip import GZipMiddleware
19 21 from fastapi.responses import FileResponse, Response
20 22 from fastapi.staticfiles import StaticFiles
23 +from pydantic import BaseModel, Field
21 24
22 25 from . import auth, db, ingest
26 +from .dedup import AGGREGATORS
27 +from .schema import JobPosting
23 28
24 29 ROOT = Path(__file__).resolve().parent.parent
25 30 SOURCES_PATH = ROOT / "data" / "sources.json"
@@ -48,6 +53,8 @@ def _row_to_dict(row) -> dict:
48 53 d["dup_sources"] = json.loads(d["dup_sources"])
49 54 except (ValueError, TypeError):
50 55 d["dup_sources"] = []
56 + # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)
57 + d["is_direct"] = d.get("source") not in AGGREGATORS
51 58 return d
52 59
53 60
@@ -206,6 +213,7 @@ def jobs_geojson(
206 213 "salary_year_min": r["salary_year_min"],
207 214 "salary_year_max": r["salary_year_max"],
208 215 "date_posted": r["date_posted"], "source": r["source"],
216 + "is_direct": r["source"] not in AGGREGATORS,
209 217 },
210 218 })
211 219 con.close()
@@ -293,8 +301,25 @@ def stats():
293 301 GROUP BY category ORDER BY n DESC""")]
294 302 log = [dict(r) for r in con.execute(
295 303 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
304 + # offres directes (pages carrières + dépôt direct) c. portails agrégateurs
305 + agg = sorted(AGGREGATORS) or ["__aucun__"]
306 + ph = ",".join("?" * len(agg))
307 + direct = con.execute(
308 + f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) e,
309 + COUNT(DISTINCT source) s
310 + FROM jobs WHERE active=1 AND dup_of IS NULL
311 + AND source NOT IN ({ph})""", agg).fetchone()
312 + portal = con.execute(
313 + f"""SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL
314 + AND source IN ({ph})""", agg).fetchone()
315 + dups = con.execute(
316 + "SELECT COUNT(*) n FROM jobs WHERE active=1"
317 + " AND dup_of IS NOT NULL").fetchone()
296 318 con.close()
297 319 return {**dict(row), "top_cities": cities, "categories": categories,
320 + "direct_jobs": direct["n"], "direct_employers": direct["e"],
321 + "direct_sources": direct["s"], "portal_jobs": portal["n"],
322 + "duplicates_hidden": dups["n"],
298 323 "recent_syncs": log}
299 324
300 325
@@ -333,6 +358,84 @@ def stats_report(period: str = "30j",
333 358 headers={"Content-Disposition": f'attachment; filename="{fname}"'})
334 359
335 360
361 +# --- Dépôt direct d'offres par les employeurs ---------------------------------
362 +class OffreDeposee(BaseModel):
363 + """Offre soumise par un employeur (validation minimale, modérée ensuite)."""
364 +
365 + employer: str = Field(min_length=2, max_length=120)
366 + title: str = Field(min_length=3, max_length=160)
367 + description: str = Field(min_length=30, max_length=20000)
368 + contact_email: str = Field(min_length=5, max_length=160)
369 + city: str = Field("", max_length=80)
370 + url: str = Field("", max_length=400)
371 + salary_label: str = Field("", max_length=120)
372 + employment_type: str | None = None
373 + work_mode: str | None = None
374 + website: str = "" # pot de miel anti-robots : doit rester vide
375 +
376 +
377 +@app.post("/api/employeurs/offres")
378 +def deposer_offre(offre: OffreDeposee):
379 + """Canal de dépôt direct : l'offre est stockée INACTIVE (source
380 + « depot-direct ») en attente de modération manuelle. Voir /employeurs."""
381 + if offre.website: # robot pris au pot de miel
382 + return {"status": "reçu"}
383 + if not re.match(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$", offre.contact_email):
384 + raise HTTPException(422, "Courriel de contact invalide")
385 + if offre.url and not re.match(r"^https?://", offre.url):
386 + raise HTTPException(422, "Lien invalide : http(s):// requis")
387 + if offre.employment_type and offre.employment_type not in (
388 + "temps_plein", "temps_partiel", "contractuel", "stage", "saisonnier"):
389 + raise HTTPException(422, "Type d'emploi inconnu")
390 + if offre.work_mode and offre.work_mode not in (
391 + "presentiel", "hybride", "teletravail"):
392 + raise HTTPException(422, "Mode de travail inconnu")
393 +
394 + job = JobPosting(
395 + source="depot-direct", external_id=secrets.token_hex(6),
396 + url=offre.url, employer=offre.employer, title=offre.title,
397 + description=offre.description, city=offre.city,
398 + salary_label=offre.salary_label,
399 + employment_type=offre.employment_type, work_mode=offre.work_mode,
400 + ats="depot-direct",
401 + )
402 + job.details["contact_email"] = offre.contact_email
403 + job.details["moderation"] = "en_attente"
404 + job.finalize()
405 +
406 + now = time.time()
407 + cols = {
408 + "uid": job.uid, "source": job.source, "external_id": job.external_id,
409 + "url": job.url, "employer": job.employer, "title": job.title,
410 + "description": job.description, "city": job.city,
411 + "region": job.region, "postal_code": job.postal_code,
412 + "location_label": job.location_label, "work_mode": job.work_mode,
413 + "employment_type": job.employment_type,
414 + "salary_min": job.salary_min, "salary_max": job.salary_max,
415 + "salary_unit": job.salary_unit, "salary_label": job.salary_label,
416 + "salary_year_min": job.salary_year_min(),
417 + "salary_year_max": job.salary_year_max(),
418 + "salary_hour_min": job.salary_hour_min(),
419 + "salary_hour_max": job.salary_hour_max(),
420 + "benefits": json.dumps(job.benefits, ensure_ascii=False),
421 + "requirements": json.dumps(job.requirements, ensure_ascii=False),
422 + "category": job.category, "ats": job.ats,
423 + "details": json.dumps(job.details, ensure_ascii=False),
424 + "content_hash": job.content_hash(),
425 + "first_seen": now, "last_seen": now, "updated_at": now,
426 + "miss_count": 0, "active": 0, # inactive tant que non modérée
427 + }
428 + con = db.connect()
429 + con.execute(
430 + f"INSERT INTO jobs ({', '.join(cols)}) VALUES "
431 + f"({', '.join(':' + k for k in cols)})", cols)
432 + con.commit()
433 + con.close()
434 + return {"status": "reçu", "uid": job.uid,
435 + "message": "Merci ! Votre offre a bien été reçue. Elle sera "
436 + "vérifiée par notre équipe avant publication."}
437 +
438 +
336 439 @app.post("/api/sync")
337 440 def trigger_sync(background: BackgroundTasks, source: str | None = None):
338 441 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
@@ -361,7 +464,7 @@ if FRONTEND_DIST.exists():
361 464
362 465 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie
363 466 # index.html avec un statut 404 (pas de soft-404 pour les moteurs)
364 − _CLIENT_ROUTES = {"carte", "sources", "stats", "contact",
467 + _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs",
365 468 "confidentialite", "conditions"}
366 469 _CLIENT_PREFIXES = ("emploi/",)
367 470
368 471