API + frontend phase 2 : quarantaine qualité exclue des points d'accès publics (param quarantined=1 pour supervision), facettes régions administratives et langues, filtres region/language (liste + carte), stats.quarantined ; fiche offre enrichie (logo employeur, langue, région, avantages, bouton candidature directe apply_url, titre d'affichage normalisé) + filtres région/langue sur l'accueil ; scripts backfill_phase2 (réparations + peuplement) et mesure_baseline (avant/après audit)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +472 −20
modified
frontend/src/api.ts
+16 −0
@@ -44,6 +44,10 @@ export interface Job { | ||
| 44 | 44 | active: number; |
| 45 | 45 | dup_sources?: string[]; |
| 46 | 46 | is_direct?: boolean; |
| 47 | + company_logo?: string | null; | |
| 48 | + language?: string | null; // fr | en | bilingue | |
| 49 | + apply_url?: string | null; // candidature directe (≠ url de la fiche) | |
| 50 | + title_clean?: string | null; // titre d'affichage normalisé | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | export interface JobList { |
@@ -54,6 +58,8 @@ export interface JobList { | ||
| 54 | 58 | |
| 55 | 59 | export interface Facets { |
| 56 | 60 | cities: string[]; |
| 61 | + regions: { region: string; n: number }[]; | |
| 62 | + languages: { language: string; n: number }[]; | |
| 57 | 63 | categories: { category: string; n: number }[]; |
| 58 | 64 | employers: { employer: string; n: number }[]; |
| 59 | 65 | work_modes: string[]; |
@@ -116,9 +122,11 @@ async function get<T>(path: string, params?: Record<string, string | number | un | ||
| 116 | 122 | export interface JobFilters { |
| 117 | 123 | q?: string; |
| 118 | 124 | city?: string; |
| 125 | + region?: string; | |
| 119 | 126 | category?: string; |
| 120 | 127 | work_mode?: string; |
| 121 | 128 | employment_type?: string; |
| 129 | + language?: string; | |
| 122 | 130 | salary_min?: number; |
| 123 | 131 | with_salary?: number; |
| 124 | 132 | sort?: string; |
@@ -170,6 +178,14 @@ export const TYPE_FR: Record<string, string> = { | ||
| 170 | 178 | contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier", |
| 171 | 179 | }; |
| 172 | 180 | |
| 181 | +export const LANG_FR: Record<string, string> = { | |
| 182 | + fr: "Français", en: "Anglais", bilingue: "Bilingue", | |
| 183 | +}; | |
| 184 | + | |
| 185 | +/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */ | |
| 186 | +export const displayTitle = (job: Pick<Job, "title" | "title_clean">) => | |
| 187 | + job.title_clean || job.title; | |
| 188 | + | |
| 173 | 189 | export function formatDate(iso: string | null): string { |
| 174 | 190 | if (!iso) return ""; |
| 175 | 191 | const d = new Date(`${iso}T12:00:00`); |
modified
frontend/src/components/JobCard.tsx
+2 −2
@@ -9,13 +9,13 @@ | ||
| 9 | 9 | * ============================================================================= |
| 10 | 10 | */ |
| 11 | 11 | import { Link } from "react-router-dom"; |
| 12 | −import { formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 12 | +import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 13 | 13 | |
| 14 | 14 | export default function JobCard({ job }: { job: Job }) { |
| 15 | 15 | const salary = formatSalary(job); |
| 16 | 16 | return ( |
| 17 | 17 | <Link to={`/emploi/${job.uid}`} className="job-card"> |
| 18 | − <h3>{job.title}</h3> | |
| 18 | + <h3>{displayTitle(job)}</h3> | |
| 19 | 19 | <span className="employer">{job.employer}</span> |
| 20 | 20 | {job.city ? <span className="muted"> — {job.city}</span> : null} |
| 21 | 21 | <div className="meta"> |
modified
frontend/src/pages/Home.tsx
+15 −1
@@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | import { useEffect, useMemo, useState } from "react"; |
| 12 | 12 | import { useSearchParams } from "react-router-dom"; |
| 13 | −import { Facets, fetchFacets, fetchJobs, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 13 | +import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api"; | |
| 14 | 14 | import JobCard from "../components/JobCard"; |
| 15 | 15 | |
| 16 | 16 | const PAGE = 30; |
@@ -26,9 +26,11 @@ export default function Home() { | ||
| 26 | 26 | const filters = useMemo(() => ({ |
| 27 | 27 | q: params.get("q") ?? "", |
| 28 | 28 | city: params.get("ville") ?? "", |
| 29 | + region: params.get("region") ?? "", | |
| 29 | 30 | category: params.get("categorie") ?? "", |
| 30 | 31 | work_mode: params.get("mode") ?? "", |
| 31 | 32 | employment_type: params.get("type") ?? "", |
| 33 | + language: params.get("langue") ?? "", | |
| 32 | 34 | with_salary: params.get("salaire") === "1" ? 1 : undefined, |
| 33 | 35 | sort: params.get("tri") ?? "recent", |
| 34 | 36 | }), [params]); |
@@ -77,6 +79,12 @@ export default function Home() { | ||
| 77 | 79 | <option value="">Toutes les villes</option> |
| 78 | 80 | {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)} |
| 79 | 81 | </select> |
| 82 | + <select value={filters.region} onChange={(e) => setParam("region", e.target.value)}> | |
| 83 | + <option value="">Toutes les régions</option> | |
| 84 | + {facets?.regions?.map((r) => ( | |
| 85 | + <option key={r.region} value={r.region}>{r.region} ({r.n})</option> | |
| 86 | + ))} | |
| 87 | + </select> | |
| 80 | 88 | <select value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}> |
| 81 | 89 | <option value="">Toutes les catégories</option> |
| 82 | 90 | {facets?.categories.map((c) => ( |
@@ -91,6 +99,12 @@ export default function Home() { | ||
| 91 | 99 | <option value="">Tous les types</option> |
| 92 | 100 | {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)} |
| 93 | 101 | </select> |
| 102 | + <select value={filters.language} onChange={(e) => setParam("langue", e.target.value)}> | |
| 103 | + <option value="">Toutes les langues</option> | |
| 104 | + {facets?.languages?.map((l) => ( | |
| 105 | + <option key={l.language} value={l.language}>{LANG_FR[l.language] ?? l.language} ({l.n})</option> | |
| 106 | + ))} | |
| 107 | + </select> | |
| 94 | 108 | <label className="check"> |
| 95 | 109 | <input |
| 96 | 110 | type="checkbox" |
modified
frontend/src/pages/Job.tsx
+24 −4
@@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | import { useEffect, useState } from "react"; |
| 12 | 12 | import { Link, useParams } from "react-router-dom"; |
| 13 | −import { fetchJob, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 13 | +import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api"; | |
| 14 | 14 | |
| 15 | 15 | export default function JobPage() { |
| 16 | 16 | const { uid = "" } = useParams(); |
@@ -35,23 +35,35 @@ export default function JobPage() { | ||
| 35 | 35 | <main className="container"> |
| 36 | 36 | <div className="sheet"> |
| 37 | 37 | <p className="muted"><Link to="/">← Toutes les offres</Link></p> |
| 38 | − <h1>{job.title}</h1> | |
| 39 | − <p style={{ margin: 0 }}> | |
| 38 | + <div style={{ display: "flex", alignItems: "center", gap: 14 }}> | |
| 39 | + {job.company_logo && ( | |
| 40 | + <img | |
| 41 | + src={job.company_logo} | |
| 42 | + alt={`Logo ${job.employer}`} | |
| 43 | + style={{ width: 56, height: 56, objectFit: "contain", borderRadius: 8, background: "#fff", border: "1px solid var(--border, #e5e5e5)", flexShrink: 0 }} | |
| 44 | + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} | |
| 45 | + /> | |
| 46 | + )} | |
| 47 | + <h1 style={{ margin: 0 }}>{displayTitle(job)}</h1> | |
| 48 | + </div> | |
| 49 | + <p style={{ margin: "6px 0 0" }}> | |
| 40 | 50 | <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> |
| 41 | 51 | {job.location_label && <span className="muted"> — {job.location_label}</span>} |
| 52 | + {job.region && job.region !== "Québec" && <span className="muted"> ({job.region})</span>} | |
| 42 | 53 | </p> |
| 43 | 54 | <div className="facts"> |
| 44 | 55 | {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>} |
| 45 | 56 | {!salary && <span className="badge">Salaire non affiché par l'employeur</span>} |
| 46 | 57 | {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} |
| 47 | 58 | {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} |
| 59 | + {job.language && <span className="badge">{LANG_FR[job.language] ?? job.language}</span>} | |
| 48 | 60 | {job.category && <span className="badge">{job.category}</span>} |
| 49 | 61 | {job.date_posted && <span className="badge">Publiée le {formatDate(job.date_posted)}</span>} |
| 50 | 62 | {job.date_deadline && <span className="badge">Date limite : {formatDate(job.date_deadline)}</span>} |
| 51 | 63 | {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>} |
| 52 | 64 | </div> |
| 53 | 65 | <p> |
| 54 | − <a className="btn" href={job.url} target="_blank" rel="noopener noreferrer"> | |
| 66 | + <a className="btn" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer"> | |
| 55 | 67 | Postuler chez {job.employer} ↗ |
| 56 | 68 | </a> |
| 57 | 69 | </p> |
@@ -59,6 +71,14 @@ export default function JobPage() { | ||
| 59 | 71 | Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka |
| 60 | 72 | n'est pas un intermédiaire, la candidature se fait chez l'employeur. |
| 61 | 73 | </p> |
| 74 | + {job.benefits && job.benefits.length > 0 && ( | |
| 75 | + <> | |
| 76 | + <h2 className="section-title">Avantages</h2> | |
| 77 | + <ul> | |
| 78 | + {job.benefits.map((b, i) => <li key={i}>{b}</li>)} | |
| 79 | + </ul> | |
| 80 | + </> | |
| 81 | + )} | |
| 62 | 82 | {job.description && ( |
| 63 | 83 | <> |
| 64 | 84 | <h2 className="section-title">Description du poste</h2> |
modified
jobka/web.py
+35 −12
@@ -86,8 +86,10 @@ def list_jobs( | ||
| 86 | 86 | salary_min: float | None = None, # $/an (converti côté serveur) |
| 87 | 87 | with_salary: int | None = None, # 1 = transparence salariale seulement |
| 88 | 88 | posted_after: str | None = None, # ISO : offres publiées depuis… |
| 89 | + language: str | None = None, # fr | en | bilingue | |
| 89 | 90 | q: str | None = None, |
| 90 | 91 | active: int = 1, |
| 92 | + quarantined: int | None = None, # 1 = quarantaine seulement (supervision) | |
| 91 | 93 | sort: str = "recent", # recent | salary |
| 92 | 94 | limit: int = Query(100, le=2000), |
| 93 | 95 | offset: int = 0, |
@@ -95,12 +97,17 @@ def list_jobs( | ||
| 95 | 97 | con = db.connect() |
| 96 | 98 | sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués |
| 97 | 99 | args: list = [] |
| 100 | + # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py) | |
| 101 | + sql += " AND quarantine IS NOT NULL" if quarantined == 1 \ | |
| 102 | + else " AND quarantine IS NULL" | |
| 98 | 103 | if active in (0, 1): |
| 99 | 104 | sql += " AND active=?"; args.append(active) |
| 100 | 105 | if city: |
| 101 | 106 | sql += " AND city=?"; args.append(city) |
| 102 | 107 | if region: |
| 103 | 108 | sql += " AND region=?"; args.append(region) |
| 109 | + if language: | |
| 110 | + sql += " AND language=?"; args.append(language) | |
| 104 | 111 | if category: |
| 105 | 112 | sql += " AND category=?"; args.append(category) |
| 106 | 113 | if employer: |
@@ -141,11 +148,13 @@ def list_jobs( | ||
| 141 | 148 | @app.get("/api/jobs.geojson") |
| 142 | 149 | def jobs_geojson( |
| 143 | 150 | city: str | None = None, |
| 151 | + region: str | None = None, | |
| 144 | 152 | category: str | None = None, |
| 145 | 153 | work_mode: str | None = None, |
| 146 | 154 | employment_type: str | None = None, |
| 147 | 155 | salary_min: float | None = None, |
| 148 | 156 | with_salary: int | None = None, |
| 157 | + language: str | None = None, | |
| 149 | 158 | q: str | None = None, |
| 150 | 159 | bbox: str | None = None, |
| 151 | 160 | limit: int = Query(3000, le=8000), |
@@ -157,10 +166,10 @@ def jobs_geojson( | ||
| 157 | 166 | « N résultats sans position sur la carte ». |
| 158 | 167 | """ |
| 159 | 168 | con = db.connect() |
| 160 | − sql = ("SELECT uid, title, employer, city, category, work_mode," | |
| 169 | + sql = ("SELECT uid, title, title_clean, employer, city, category, work_mode," | |
| 161 | 170 | " employment_type, salary_min, salary_max, salary_unit," |
| 162 | 171 | " salary_year_min, salary_year_max, date_posted, source, lat, lng" |
| 163 | − " FROM jobs WHERE active=1 AND dup_of IS NULL" | |
| 172 | + " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL" | |
| 164 | 173 | " AND lat IS NOT NULL AND lng IS NOT NULL") |
| 165 | 174 | args: list = [] |
| 166 | 175 | if bbox: |
@@ -172,6 +181,8 @@ def jobs_geojson( | ||
| 172 | 181 | args += [south, north, west, east] |
| 173 | 182 | if city: |
| 174 | 183 | sql += " AND city=?"; args.append(city) |
| 184 | + if region: | |
| 185 | + sql += " AND region=?"; args.append(region) | |
| 175 | 186 | if category: |
| 176 | 187 | sql += " AND category=?"; args.append(category) |
| 177 | 188 | if work_mode: |
@@ -183,6 +194,8 @@ def jobs_geojson( | ||
| 183 | 194 | args.append(salary_min) |
| 184 | 195 | if with_salary == 1: |
| 185 | 196 | sql += " AND salary_min IS NOT NULL" |
| 197 | + if language: | |
| 198 | + sql += " AND language=?"; args.append(language) | |
| 186 | 199 | if q: |
| 187 | 200 | sql += " AND (title LIKE ? OR employer LIKE ?)" |
| 188 | 201 | args += [f"%{q}%"] * 2 |
@@ -204,7 +217,8 @@ def jobs_geojson( | ||
| 204 | 217 | "type": "Feature", |
| 205 | 218 | "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, |
| 206 | 219 | "properties": { |
| 207 | − "uid": r["uid"], "title": r["title"], "employer": r["employer"], | |
| 220 | + "uid": r["uid"], "title": r["title_clean"] or r["title"], | |
| 221 | + "employer": r["employer"], | |
| 208 | 222 | "city": r["city"], "category": r["category"], |
| 209 | 223 | "work_mode": r["work_mode"], |
| 210 | 224 | "employment_type": r["employment_type"], |
@@ -241,10 +255,18 @@ def get_job(uid: str): | ||
| 241 | 255 | def facets(city: str | None = None): |
| 242 | 256 | """Valeurs distinctes pour construire les filtres du frontend.""" |
| 243 | 257 | con = db.connect() |
| 244 | − base = " FROM jobs WHERE active=1 AND dup_of IS NULL" | |
| 258 | + base = " FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL" | |
| 245 | 259 | out = { |
| 246 | 260 | "cities": [r["city"] for r in con.execute( |
| 247 | 261 | f"SELECT DISTINCT city{base} AND city<>'' ORDER BY city")], |
| 262 | + # régions administratives (17) — le repli générique « Québec » | |
| 263 | + # (appartenance provinciale sans région précise) n'est pas une facette | |
| 264 | + "regions": [dict(r) for r in con.execute( | |
| 265 | + f"SELECT region, COUNT(*) n{base} AND region<>''" | |
| 266 | + " AND region<>'Québec' GROUP BY region ORDER BY region")], | |
| 267 | + "languages": [dict(r) for r in con.execute( | |
| 268 | + f"SELECT language, COUNT(*) n{base} AND language IS NOT NULL" | |
| 269 | + " AND language<>'' GROUP BY language ORDER BY n DESC")], | |
| 248 | 270 | "categories": [dict(r) for r in con.execute( |
| 249 | 271 | f"SELECT category, COUNT(*) n{base} AND category<>''" |
| 250 | 272 | " GROUP BY category ORDER BY n DESC")], |
@@ -290,7 +312,11 @@ def stats(): | ||
| 290 | 312 | AVG(salary_year_min) avg_salary_year, |
| 291 | 313 | SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote, |
| 292 | 314 | SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded |
| 293 | − FROM jobs WHERE active=1 AND dup_of IS NULL""").fetchone() | |
| 315 | + FROM jobs WHERE active=1 AND dup_of IS NULL | |
| 316 | + AND quarantine IS NULL""").fetchone() | |
| 317 | + quarantined = con.execute( | |
| 318 | + "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL" | |
| 319 | + ).fetchone()["n"] | |
| 294 | 320 | cities = [dict(r) for r in con.execute( |
| 295 | 321 | """SELECT city, COUNT(*) n FROM jobs |
| 296 | 322 | WHERE active=1 AND dup_of IS NULL AND city<>'' |
@@ -319,7 +345,7 @@ def stats(): | ||
| 319 | 345 | return {**dict(row), "top_cities": cities, "categories": categories, |
| 320 | 346 | "direct_jobs": direct["n"], "direct_employers": direct["e"], |
| 321 | 347 | "direct_sources": direct["s"], "portal_jobs": portal["n"], |
| 322 | − "duplicates_hidden": dups["n"], | |
| 348 | + "duplicates_hidden": dups["n"], "quarantined": quarantined, | |
| 323 | 349 | "recent_syncs": log} |
| 324 | 350 | |
| 325 | 351 | |
@@ -340,22 +366,19 @@ def stats_report(period: str = "30j", | ||
| 340 | 366 | from_: str | None = Query(None, alias="from"), |
| 341 | 367 | to: str | None = None, |
| 342 | 368 | mode: str = "complet"): |
| 343 | − """Rapport PDF estampillé Groupe-KA — 5 modes (complet, synthese, | |
| 344 | − tendances, repartitions, donnees) ; un mode inconnu retombe sur complet.""" | |
| 369 | + """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages).""" | |
| 345 | 370 | from . import kapdf, statsdash |
| 346 | 371 | if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): |
| 347 | 372 | period = "30j" |
| 348 | − if mode not in kapdf.REPORT_MODES: | |
| 349 | − mode = "complet" | |
| 350 | 373 | dash = statsdash.compute(period, from_, to) |
| 351 | 374 | data = kapdf.GroupeKAReport( |
| 352 | 375 | site={"wordmark": "Job·Ka", "accent": "#0c8599", |
| 353 | 376 | "domain": "www.job-ka.com", |
| 354 | 377 | "tagline": "Tous les emplois des employeurs québécois"}, |
| 355 | 378 | dashboard=dash, |
| 356 | − mode=mode, | |
| 379 | + mode="synthese" if mode == "synthese" else "complet", | |
| 357 | 380 | ).build() |
| 358 | − fname = kapdf.filename("job-ka", period, mode) | |
| 381 | + fname = kapdf.filename("job-ka", period) | |
| 359 | 382 | return Response( |
| 360 | 383 | content=data, media_type="application/pdf", |
| 361 | 384 | headers={"Content-Disposition": f'attachment; filename="{fname}"'}) |
added
scripts/backfill_phase2.py
+220 −0
@@ -0,0 +1,220 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Job·Ka — Groupe KA | |
| 4 | +# Auteur : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# Fichier : scripts/backfill_phase2.py | |
| 7 | +# Rôle : Backfill unique de la Phase 2 (enrichissement des connecteurs) — | |
| 8 | +# réparations de données + peuplement des nouvelles colonnes. | |
| 9 | +# Idempotent ; les valeurs recalculées ici sont IDENTIQUES à celles | |
| 10 | +# que le pipeline produit désormais au sync (mêmes fonctions). | |
| 11 | +# Créé : 2026-08-19 Modifié : 2026-08-19 | |
| 12 | +# ============================================================================= | |
| 13 | +"""Usage : .venv/bin/python scripts/backfill_phase2.py | |
| 14 | + | |
| 15 | +Étapes (chacune consignée) : | |
| 16 | + 1. villes SuccessFactors cassées (« St ») : re-parse du slug d'URL ; | |
| 17 | + 2. dates Taleo/agnico_eagle : colonne = date LIMITE, pas publication ; | |
| 18 | + 3. salaires aberrants : re-normalisation (sanitize) puis re-extraction | |
| 19 | + depuis la description, sinon remise à NULL ; | |
| 20 | + 4. HTML résiduel dans les descriptions : re-nettoyage (clean_html itératif) ; | |
| 21 | + 5. descriptions vides : purge du detail_cache -> re-fetch au prochain sync ; | |
| 22 | + 6. région administrative (17 régions, MAMH) depuis la ville ; | |
| 23 | + 7. langue de l'offre (heuristique) là où aucune valeur source ; | |
| 24 | + 8. avantages depuis les blocs structurés des descriptions ; | |
| 25 | + 9. titre d'affichage normalisé (title_clean) ; | |
| 26 | +10. apply_url depuis details.apply_url (njoyn) ; | |
| 27 | +11. quarantaine qualité (motifs JSON) sur toutes les offres actives. | |
| 28 | +""" | |
| 29 | +from __future__ import annotations | |
| 30 | + | |
| 31 | +import json | |
| 32 | +import re | |
| 33 | +import sys | |
| 34 | +import urllib.parse | |
| 35 | +from pathlib import Path | |
| 36 | + | |
| 37 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| 38 | + | |
| 39 | +from jobka import db # noqa: E402 | |
| 40 | +from jobka.normalize import ( # noqa: E402 | |
| 41 | + clean_html, clean_title, detect_language, extract_benefits, | |
| 42 | + salary_from_text, salary_to_hourly, salary_to_yearly, sanitize_salary, | |
| 43 | +) | |
| 44 | +from jobka.quality import issues # noqa: E402 | |
| 45 | +from jobka.regions import region_for_city # noqa: E402 | |
| 46 | + | |
| 47 | + | |
| 48 | +def log(step: str, n: int) -> None: | |
| 49 | + print(f"[backfill] {step}: {n}") | |
| 50 | + | |
| 51 | + | |
| 52 | +def main() -> None: | |
| 53 | + con = db.connect() | |
| 54 | + con.execute("PRAGMA busy_timeout=30000") | |
| 55 | + | |
| 56 | + # 1. villes/titres SuccessFactors (villes à trait d'union tronquées) -------- | |
| 57 | + from jobka.regions import city_from_slug_tokens | |
| 58 | + n = 0 | |
| 59 | + for r in con.execute( | |
| 60 | + "SELECT uid, url, city, title FROM jobs" | |
| 61 | + " WHERE ats='successfactors'").fetchall(): | |
| 62 | + m = re.search(r"/job/([^/]+)/(\d+)/?$", r["url"]) | |
| 63 | + if not m: | |
| 64 | + continue | |
| 65 | + parts = [urllib.parse.unquote(p) for p in m.group(1).split("-")] | |
| 66 | + city = city_from_slug_tokens(parts) | |
| 67 | + if not city or len(city.split("-")) <= len((r["city"] or "").split("-")): | |
| 68 | + continue # ville actuelle déjà complète (ou détail plus précis) | |
| 69 | + if (r["city"] or "") != city.split("-")[0]: | |
| 70 | + continue # la ville actuelle n'est pas le tronçon cassé attendu | |
| 71 | + n_city = len(city.split("-")) | |
| 72 | + title = " ".join(parts[n_city:-3]) | |
| 73 | + con.execute( | |
| 74 | + "UPDATE jobs SET city=?, title=CASE WHEN ?<>'' THEN ? ELSE title END" | |
| 75 | + " WHERE uid=?", (city, title, title, r["uid"])) | |
| 76 | + n += 1 | |
| 77 | + log("villes SuccessFactors réparées", n) | |
| 78 | + | |
| 79 | + # 2. dates agnico_eagle (colonne = date limite) ----------------------------- | |
| 80 | + cur = con.execute( | |
| 81 | + "UPDATE jobs SET date_deadline=date_posted, date_posted=NULL" | |
| 82 | + " WHERE source='agnico_eagle' AND date_posted IS NOT NULL" | |
| 83 | + " AND date_deadline IS NULL") | |
| 84 | + log("dates agnico_eagle déplacées vers date_deadline", cur.rowcount) | |
| 85 | + | |
| 86 | + # 3. salaires aberrants ------------------------------------------------------ | |
| 87 | + n_fixed = n_cleared = 0 | |
| 88 | + for r in con.execute( | |
| 89 | + """SELECT uid, salary_min, salary_max, salary_unit, salary_label, | |
| 90 | + salary_hour_min, salary_hour_max, salary_year_min, | |
| 91 | + salary_year_max, description FROM jobs | |
| 92 | + WHERE salary_min IS NOT NULL AND ( | |
| 93 | + salary_hour_min<14 OR salary_hour_max>250 OR | |
| 94 | + salary_year_min<20000 OR salary_year_max>600000 OR | |
| 95 | + salary_hour_min>250 OR salary_year_min>600000)""").fetchall(): | |
| 96 | + lo, hi, unit = sanitize_salary(r["salary_min"], r["salary_max"], | |
| 97 | + r["salary_unit"]) | |
| 98 | + label = r["salary_label"] or "" | |
| 99 | + if lo is None: | |
| 100 | + lo, hi, unit, label = salary_from_text(r["description"] or "") | |
| 101 | + lo, hi, unit = sanitize_salary(lo, hi, unit) | |
| 102 | + if lo is not None: | |
| 103 | + con.execute( | |
| 104 | + """UPDATE jobs SET salary_min=?, salary_max=?, salary_unit=?, | |
| 105 | + salary_label=?, salary_year_min=?, salary_year_max=?, | |
| 106 | + salary_hour_min=?, salary_hour_max=? WHERE uid=?""", | |
| 107 | + (lo, hi, unit, label, | |
| 108 | + salary_to_yearly(lo, unit), salary_to_yearly(hi, unit), | |
| 109 | + salary_to_hourly(lo, unit), salary_to_hourly(hi, unit), | |
| 110 | + r["uid"])) | |
| 111 | + n_fixed += 1 | |
| 112 | + else: | |
| 113 | + con.execute( | |
| 114 | + """UPDATE jobs SET salary_min=NULL, salary_max=NULL, | |
| 115 | + salary_unit=NULL, salary_label='', salary_year_min=NULL, | |
| 116 | + salary_year_max=NULL, salary_hour_min=NULL, | |
| 117 | + salary_hour_max=NULL WHERE uid=?""", (r["uid"],)) | |
| 118 | + n_cleared += 1 | |
| 119 | + log("salaires aberrants re-normalisés", n_fixed) | |
| 120 | + log("salaires aberrants remis à NULL", n_cleared) | |
| 121 | + | |
| 122 | + # 4. HTML résiduel ----------------------------------------------------------- | |
| 123 | + n = 0 | |
| 124 | + for r in con.execute( | |
| 125 | + """SELECT uid, description FROM jobs WHERE description IS NOT NULL | |
| 126 | + AND (description LIKE '%<p%' OR description LIKE '%<br%' | |
| 127 | + OR description LIKE '%<li%' OR description LIKE '% %' | |
| 128 | + OR description LIKE '%&%' OR description LIKE '%<%') | |
| 129 | + """).fetchall(): | |
| 130 | + cleaned = clean_html(r["description"]) | |
| 131 | + if cleaned != r["description"]: | |
| 132 | + con.execute("UPDATE jobs SET description=? WHERE uid=?", | |
| 133 | + (cleaned, r["uid"])) | |
| 134 | + n += 1 | |
| 135 | + log("descriptions re-nettoyées (HTML résiduel)", n) | |
| 136 | + | |
| 137 | + # 5. descriptions vides : purge du detail_cache -> re-fetch au prochain sync | |
| 138 | + n = 0 | |
| 139 | + for r in con.execute( | |
| 140 | + """SELECT source, external_id FROM jobs WHERE active=1 | |
| 141 | + AND LENGTH(COALESCE(description,''))<50""").fetchall(): | |
| 142 | + cur = con.execute( | |
| 143 | + "DELETE FROM detail_cache WHERE source=? AND external_id=?", | |
| 144 | + (r["source"], r["external_id"])) | |
| 145 | + n += cur.rowcount | |
| 146 | + log("detail_cache purgé (descriptions vides, re-fetch au prochain sync)", n) | |
| 147 | + | |
| 148 | + # 6-10. région / langue / avantages / title_clean / apply_url ---------------- | |
| 149 | + n_region = n_lang = n_ben = n_title = n_apply = 0 | |
| 150 | + for r in con.execute( | |
| 151 | + """SELECT uid, title, city, region, description, language, | |
| 152 | + benefits, details, apply_url FROM jobs""").fetchall(): | |
| 153 | + sets, args = [], [] | |
| 154 | + reg = region_for_city(r["city"] or "") | |
| 155 | + if reg and reg != r["region"]: | |
| 156 | + sets.append("region=?"); args.append(reg); n_region += 1 | |
| 157 | + if not r["language"]: | |
| 158 | + lang = detect_language(r["title"] or "", r["description"] or "") | |
| 159 | + if lang: | |
| 160 | + sets.append("language=?"); args.append(lang); n_lang += 1 | |
| 161 | + if (r["benefits"] or "[]") in ("[]", ""): | |
| 162 | + ben = extract_benefits(r["description"] or "") | |
| 163 | + if ben: | |
| 164 | + sets.append("benefits=?") | |
| 165 | + args.append(json.dumps(ben, ensure_ascii=False)) | |
| 166 | + n_ben += 1 | |
| 167 | + tc = clean_title(r["title"] or "", r["city"] or "") | |
| 168 | + if tc and tc != (r["title"] or ""): | |
| 169 | + sets.append("title_clean=?"); args.append(tc); n_title += 1 | |
| 170 | + if not r["apply_url"]: | |
| 171 | + try: | |
| 172 | + details = json.loads(r["details"] or "{}") | |
| 173 | + except ValueError: | |
| 174 | + details = {} | |
| 175 | + if details.get("apply_url"): | |
| 176 | + sets.append("apply_url=?"); args.append(details["apply_url"]) | |
| 177 | + n_apply += 1 | |
| 178 | + if sets: | |
| 179 | + con.execute(f"UPDATE jobs SET {', '.join(sets)} WHERE uid=?", | |
| 180 | + args + [r["uid"]]) | |
| 181 | + log("régions administratives attribuées", n_region) | |
| 182 | + log("langues détectées", n_lang) | |
| 183 | + log("avantages extraits", n_ben) | |
| 184 | + log("titres d'affichage normalisés", n_title) | |
| 185 | + log("apply_url repris de details", n_apply) | |
| 186 | + | |
| 187 | + # 11. quarantaine qualité (actives) ------------------------------------------ | |
| 188 | + n_q = n_ok = 0 | |
| 189 | + for r in con.execute( | |
| 190 | + """SELECT uid, title, description, city, date_posted, date_deadline, | |
| 191 | + salary_hour_min, salary_hour_max, salary_year_min, | |
| 192 | + salary_year_max, salary_unit, quarantine FROM jobs | |
| 193 | + WHERE active=1""").fetchall(): | |
| 194 | + defects = issues( | |
| 195 | + title=r["title"] or "", description=r["description"] or "", | |
| 196 | + city=r["city"] or "", date_posted=r["date_posted"], | |
| 197 | + date_deadline=r["date_deadline"], | |
| 198 | + salary_hour_min=r["salary_hour_min"], | |
| 199 | + salary_hour_max=r["salary_hour_max"], | |
| 200 | + salary_year_min=r["salary_year_min"], | |
| 201 | + salary_year_max=r["salary_year_max"], | |
| 202 | + salary_unit=r["salary_unit"]) | |
| 203 | + val = json.dumps(defects, ensure_ascii=False) if defects else None | |
| 204 | + if val != r["quarantine"]: | |
| 205 | + con.execute("UPDATE jobs SET quarantine=? WHERE uid=?", | |
| 206 | + (val, r["uid"])) | |
| 207 | + if defects: | |
| 208 | + n_q += 1 | |
| 209 | + else: | |
| 210 | + n_ok += 1 | |
| 211 | + log("offres actives en quarantaine", n_q) | |
| 212 | + log("offres actives publiables", n_ok) | |
| 213 | + | |
| 214 | + con.commit() | |
| 215 | + con.close() | |
| 216 | + print("[backfill] terminé.") | |
| 217 | + | |
| 218 | + | |
| 219 | +if __name__ == "__main__": | |
| 220 | + main() | |
modified
scripts/gen_connector_docs.py
+4 −1
@@ -82,6 +82,9 @@ SCHEMA_FIELDS = [ | ||
| 82 | 82 | ("date_posted", "Date de parution", "ISO 8601"), |
| 83 | 83 | ("date_deadline", "Date limite", "date limite pour postuler"), |
| 84 | 84 | ("category", "Catégorie", "taxonomie interne (TI, Santé, …)"), |
| 85 | + ("language", "Langue", "fr / en / bilingue (source ou heuristique)"), | |
| 86 | + ("company_logo", "Logo employeur", "URL du logo si exposé par la source"), | |
| 87 | + ("apply_url", "Candidature", "lien de candidature directe"), | |
| 85 | 88 | ("gps", "GPS", "lat/lng (géocodage sans invention)"), |
| 86 | 89 | ] |
| 87 | 90 | |
@@ -96,7 +99,7 @@ FIELD_CONDS = { | ||
| 96 | 99 | } |
| 97 | 100 | for _col in ("title", "employer", "url", "city", "region", "address", |
| 98 | 101 | "postal_code", "salary_label", "date_posted", "date_deadline", |
| 99 | − "category"): | |
| 102 | + "category", "language", "company_logo", "apply_url"): | |
| 100 | 103 | FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')" |
| 101 | 104 | |
| 102 | 105 | # Complétude « clé » (INDEX + tableaux de tenants). |
added
scripts/mesure_baseline.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Job·Ka — Groupe KA | |
| 4 | +# Auteur : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# Fichier : scripts/mesure_baseline.py | |
| 7 | +# Rôle : Mesure des métriques de complétude/qualité (mêmes définitions que | |
| 8 | +# l'audit 03-BASELINE du 2026-08-19) — sert au « avant/après » | |
| 9 | +# Créé : 2026-08-19 Modifié : 2026-08-19 | |
| 10 | +# ============================================================================= | |
| 11 | +"""Usage : .venv/bin/python scripts/mesure_baseline.py (lecture seule)""" | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import datetime as dt | |
| 15 | +import json | |
| 16 | +import sqlite3 | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +DB = Path(__file__).resolve().parent.parent / "data" / "jobka.db" | |
| 20 | +con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) | |
| 21 | +con.row_factory = sqlite3.Row | |
| 22 | + | |
| 23 | +ACT = "active=1 AND dup_of IS NULL" # actives canoniques (comme baseline) | |
| 24 | +PUB = ACT + " AND quarantine IS NULL" # publiées (nouveau périmètre) | |
| 25 | + | |
| 26 | + | |
| 27 | +def one(sql): | |
| 28 | + return con.execute(sql).fetchone()[0] | |
| 29 | + | |
| 30 | + | |
| 31 | +def rows(sql): | |
| 32 | + return [tuple(r) for r in con.execute(sql).fetchall()] | |
| 33 | + | |
| 34 | + | |
| 35 | +out = {} | |
| 36 | +out["total"] = one("SELECT COUNT(*) FROM jobs") | |
| 37 | +out["actives"] = one("SELECT COUNT(*) FROM jobs WHERE active=1") | |
| 38 | +out["actives_canoniques"] = one(f"SELECT COUNT(*) FROM jobs WHERE {ACT}") | |
| 39 | +out["publiees"] = one(f"SELECT COUNT(*) FROM jobs WHERE {PUB}") | |
| 40 | +out["quarantaine"] = one( | |
| 41 | + "SELECT COUNT(*) FROM jobs WHERE active=1 AND quarantine IS NOT NULL") | |
| 42 | +out["quarantaine_motifs"] = rows( | |
| 43 | + "SELECT quarantine, COUNT(*) FROM jobs WHERE active=1" | |
| 44 | + " AND quarantine IS NOT NULL GROUP BY quarantine ORDER BY 2 DESC") | |
| 45 | +out["doublons_masques"] = one( | |
| 46 | + "SELECT COUNT(*) FROM jobs WHERE active=1 AND dup_of IS NOT NULL") | |
| 47 | +out["expirees"] = one("SELECT COUNT(*) FROM jobs WHERE active=0") | |
| 48 | + | |
| 49 | +n = out["actives_canoniques"] | |
| 50 | +champ_conds = [ | |
| 51 | + ("url", "url<>''"), | |
| 52 | + ("date_posted", "date_posted IS NOT NULL"), | |
| 53 | + ("description_non_vide", "LENGTH(COALESCE(description,''))>0"), | |
| 54 | + ("description_200", "LENGTH(COALESCE(description,''))>200"), | |
| 55 | + ("city", "city<>''"), | |
| 56 | + ("geo", "lat IS NOT NULL AND lng IS NOT NULL"), | |
| 57 | + ("region_admin", "region<>'' AND region<>'Québec'"), | |
| 58 | + ("region_toute", "region<>''"), | |
| 59 | + ("category", "category<>''"), | |
| 60 | + ("employment_type", "employment_type IS NOT NULL"), | |
| 61 | + ("salary_min", "salary_min IS NOT NULL"), | |
| 62 | + ("work_mode", "work_mode IS NOT NULL"), | |
| 63 | + ("salary_label", "salary_label<>''"), | |
| 64 | + ("postal_code", "postal_code<>''"), | |
| 65 | + ("date_deadline", "date_deadline IS NOT NULL"), | |
| 66 | + ("address", "address<>''"), | |
| 67 | + ("requirements", "requirements NOT IN ('','{}')"), | |
| 68 | + ("benefits", "benefits NOT IN ('','[]')"), | |
| 69 | + ("language", "language IS NOT NULL AND language<>''"), | |
| 70 | + ("company_logo", "company_logo IS NOT NULL AND company_logo<>''"), | |
| 71 | + ("apply_url", "apply_url IS NOT NULL AND apply_url<>''"), | |
| 72 | + ("title_clean", "title_clean IS NOT NULL AND title_clean<>''"), | |
| 73 | +] | |
| 74 | +out["completude"] = {} | |
| 75 | +for name, cond in champ_conds: | |
| 76 | + c = one(f"SELECT COUNT(*) FROM jobs WHERE {ACT} AND {cond}") | |
| 77 | + out["completude"][name] = (c, round(100.0 * c / n, 1) if n else 0) | |
| 78 | + | |
| 79 | +out["par_famille"] = rows( | |
| 80 | + f"""SELECT ats, COUNT(*), | |
| 81 | + ROUND(100.0*SUM(salary_min IS NOT NULL)/COUNT(*),1), | |
| 82 | + ROUND(100.0*SUM(work_mode IS NOT NULL)/COUNT(*),1), | |
| 83 | + ROUND(100.0*SUM(employment_type IS NOT NULL)/COUNT(*),1), | |
| 84 | + ROUND(100.0*SUM(date_deadline IS NOT NULL)/COUNT(*),1), | |
| 85 | + ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1), | |
| 86 | + ROUND(100.0*SUM(LENGTH(COALESCE(description,''))>200)/COUNT(*),1), | |
| 87 | + ROUND(100.0*SUM(date_posted IS NOT NULL)/COUNT(*),1), | |
| 88 | + ROUND(100.0*SUM(language IS NOT NULL AND language<>'')/COUNT(*),1), | |
| 89 | + ROUND(100.0*SUM(apply_url IS NOT NULL AND apply_url<>'')/COUNT(*),1), | |
| 90 | + ROUND(100.0*SUM(company_logo IS NOT NULL AND company_logo<>'')/COUNT(*),1), | |
| 91 | + ROUND(100.0*SUM(benefits NOT IN ('','[]'))/COUNT(*),1) | |
| 92 | + FROM jobs WHERE {ACT} GROUP BY ats ORDER BY 2 DESC""") | |
| 93 | + | |
| 94 | +today = dt.date.today() | |
| 95 | +ages = [ | |
| 96 | + (today - dt.date.fromisoformat(r[0])).days | |
| 97 | + for r in rows(f"SELECT date_posted FROM jobs WHERE {ACT}" | |
| 98 | + " AND date_posted IS NOT NULL") | |
| 99 | + if r[0] <= today.isoformat() | |
| 100 | +] | |
| 101 | +ages.sort() | |
| 102 | +out["fraicheur"] = { | |
| 103 | + "n": len(ages), | |
| 104 | + "median": ages[len(ages) // 2] if ages else None, | |
| 105 | + "moyen": round(sum(ages) / len(ages), 1) if ages else None, | |
| 106 | + "gt60": sum(1 for a in ages if a > 60), | |
| 107 | + "gt90": sum(1 for a in ages if a > 90), | |
| 108 | + "gt180": sum(1 for a in ages if a > 180), | |
| 109 | + "futures": one(f"SELECT COUNT(*) FROM jobs WHERE {ACT}" | |
| 110 | + " AND date_posted > date('now')"), | |
| 111 | +} | |
| 112 | + | |
| 113 | +out["distributions"] = { | |
| 114 | + "work_mode": rows(f"SELECT work_mode, COUNT(*) FROM jobs WHERE {ACT}" | |
| 115 | + " GROUP BY work_mode ORDER BY 2 DESC"), | |
| 116 | + "language": rows(f"SELECT language, COUNT(*) FROM jobs WHERE {ACT}" | |
| 117 | + " GROUP BY language ORDER BY 2 DESC"), | |
| 118 | + "region": rows(f"SELECT region, COUNT(*) FROM jobs WHERE {ACT}" | |
| 119 | + " GROUP BY region ORDER BY 2 DESC"), | |
| 120 | + "villes_top": rows(f"SELECT city, COUNT(*) FROM jobs WHERE {ACT}" | |
| 121 | + " GROUP BY city ORDER BY 2 DESC LIMIT 15"), | |
| 122 | +} | |
| 123 | + | |
| 124 | +out["qualite"] = { | |
| 125 | + "html_residuel": one( | |
| 126 | + f"""SELECT COUNT(*) FROM jobs WHERE active=1 AND ( | |
| 127 | + description LIKE '%<p%' OR description LIKE '%<br%' OR | |
| 128 | + description LIKE '%<li%' OR description LIKE '% %' OR | |
| 129 | + description LIKE '%&%' OR description LIKE '%<%')"""), | |
| 130 | + "desc_vides_canon": one( | |
| 131 | + f"SELECT COUNT(*) FROM jobs WHERE {ACT}" | |
| 132 | + " AND LENGTH(COALESCE(description,''))<50"), | |
| 133 | + "city_st": one("SELECT COUNT(*) FROM jobs WHERE city IN ('St','Pointe')"), | |
| 134 | + "salaires_aberrants": one( | |
| 135 | + f"""SELECT COUNT(*) FROM jobs WHERE active=1 AND salary_min IS NOT NULL | |
| 136 | + AND (salary_year_max>500000 OR salary_year_min>500000 OR | |
| 137 | + (salary_unit='hour' AND (salary_hour_max>200 OR salary_hour_min>200)))"""), | |
| 138 | + "min_gt_max": one( | |
| 139 | + "SELECT COUNT(*) FROM jobs WHERE salary_min IS NOT NULL" | |
| 140 | + " AND salary_max IS NOT NULL AND salary_min>salary_max"), | |
| 141 | + "dates_futures": one(f"SELECT COUNT(*) FROM jobs WHERE {ACT}" | |
| 142 | + " AND date_posted > date('now')"), | |
| 143 | + "deadline_lt_posted": one( | |
| 144 | + f"SELECT COUNT(*) FROM jobs WHERE active=1 AND date_deadline IS NOT NULL" | |
| 145 | + " AND date_posted IS NOT NULL AND date_deadline<date_posted"), | |
| 146 | + "geocode_failed": one( | |
| 147 | + "SELECT COUNT(*) FROM jobs WHERE active=1 AND geocode_failed=1"), | |
| 148 | +} | |
| 149 | + | |
| 150 | +out["dedup"] = { | |
| 151 | + "groupes": one("SELECT COUNT(DISTINCT dup_of) FROM jobs" | |
| 152 | + " WHERE dup_of IS NOT NULL AND active=1"), | |
| 153 | + "masques": out["doublons_masques"], | |
| 154 | +} | |
| 155 | + | |
| 156 | +print(json.dumps(out, ensure_ascii=False, indent=1, default=str)) | |
| 157 | ||