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 | active: number; | 44 | active: number; | ||
| 45 | dup_sources?: string[]; | 45 | dup_sources?: string[]; | ||
| 46 | is_direct?: boolean; | 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 | export interface JobList { | 53 | export interface JobList { | ||
@@ -54,6 +58,8 @@ export interface JobList { | |||||
| 54 | 58 | ||||
| 55 | export interface Facets { | 59 | export interface Facets { | ||
| 56 | cities: string[]; | 60 | cities: string[]; | ||
| 61 | + regions: { region: string; n: number }[]; | ||||
| 62 | + languages: { language: string; n: number }[]; | ||||
| 57 | categories: { category: string; n: number }[]; | 63 | categories: { category: string; n: number }[]; | ||
| 58 | employers: { employer: string; n: number }[]; | 64 | employers: { employer: string; n: number }[]; | ||
| 59 | work_modes: string[]; | 65 | work_modes: string[]; | ||
@@ -116,9 +122,11 @@ async function get<T>(path: string, params?: Record<string, string | number | un | |||||
| 116 | export interface JobFilters { | 122 | export interface JobFilters { | ||
| 117 | q?: string; | 123 | q?: string; | ||
| 118 | city?: string; | 124 | city?: string; | ||
| 125 | + region?: string; | ||||
| 119 | category?: string; | 126 | category?: string; | ||
| 120 | work_mode?: string; | 127 | work_mode?: string; | ||
| 121 | employment_type?: string; | 128 | employment_type?: string; | ||
| 129 | + language?: string; | ||||
| 122 | salary_min?: number; | 130 | salary_min?: number; | ||
| 123 | with_salary?: number; | 131 | with_salary?: number; | ||
| 124 | sort?: string; | 132 | sort?: string; | ||
@@ -170,6 +178,14 @@ export const TYPE_FR: Record<string, string> = { | |||||
| 170 | contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier", | 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 | export function formatDate(iso: string | null): string { | 189 | export function formatDate(iso: string | null): string { | ||
| 174 | if (!iso) return ""; | 190 | if (!iso) return ""; | ||
| 175 | const d = new Date(`${iso}T12:00:00`); | 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 | import { Link } from "react-router-dom"; | 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 | export default function JobCard({ job }: { job: Job }) { | 14 | export default function JobCard({ job }: { job: Job }) { | ||
| 15 | const salary = formatSalary(job); | 15 | const salary = formatSalary(job); | ||
| 16 | return ( | 16 | return ( | ||
| 17 | <Link to={`/emploi/${job.uid}`} className="job-card"> | 17 | <Link to={`/emploi/${job.uid}`} className="job-card"> | ||
| 18 | − <h3>{job.title}</h3> | 18 | + <h3>{displayTitle(job)}</h3> | ||
| 19 | <span className="employer">{job.employer}</span> | 19 | <span className="employer">{job.employer}</span> | ||
| 20 | {job.city ? <span className="muted"> — {job.city}</span> : null} | 20 | {job.city ? <span className="muted"> — {job.city}</span> : null} | ||
| 21 | <div className="meta"> | 21 | <div className="meta"> | ||
modified
frontend/src/pages/Home.tsx
+15 −1
@@ -10,7 +10,7 @@ | |||||
| 10 | */ | 10 | */ | ||
| 11 | import { useEffect, useMemo, useState } from "react"; | 11 | import { useEffect, useMemo, useState } from "react"; | ||
| 12 | import { useSearchParams } from "react-router-dom"; | 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 | import JobCard from "../components/JobCard"; | 14 | import JobCard from "../components/JobCard"; | ||
| 15 | 15 | ||||
| 16 | const PAGE = 30; | 16 | const PAGE = 30; | ||
@@ -26,9 +26,11 @@ export default function Home() { | |||||
| 26 | const filters = useMemo(() => ({ | 26 | const filters = useMemo(() => ({ | ||
| 27 | q: params.get("q") ?? "", | 27 | q: params.get("q") ?? "", | ||
| 28 | city: params.get("ville") ?? "", | 28 | city: params.get("ville") ?? "", | ||
| 29 | + region: params.get("region") ?? "", | ||||
| 29 | category: params.get("categorie") ?? "", | 30 | category: params.get("categorie") ?? "", | ||
| 30 | work_mode: params.get("mode") ?? "", | 31 | work_mode: params.get("mode") ?? "", | ||
| 31 | employment_type: params.get("type") ?? "", | 32 | employment_type: params.get("type") ?? "", | ||
| 33 | + language: params.get("langue") ?? "", | ||||
| 32 | with_salary: params.get("salaire") === "1" ? 1 : undefined, | 34 | with_salary: params.get("salaire") === "1" ? 1 : undefined, | ||
| 33 | sort: params.get("tri") ?? "recent", | 35 | sort: params.get("tri") ?? "recent", | ||
| 34 | }), [params]); | 36 | }), [params]); | ||
@@ -77,6 +79,12 @@ export default function Home() { | |||||
| 77 | <option value="">Toutes les villes</option> | 79 | <option value="">Toutes les villes</option> | ||
| 78 | {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)} | 80 | {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)} | ||
| 79 | </select> | 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 | <select value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}> | 88 | <select value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}> | ||
| 81 | <option value="">Toutes les catégories</option> | 89 | <option value="">Toutes les catégories</option> | ||
| 82 | {facets?.categories.map((c) => ( | 90 | {facets?.categories.map((c) => ( | ||
@@ -91,6 +99,12 @@ export default function Home() { | |||||
| 91 | <option value="">Tous les types</option> | 99 | <option value="">Tous les types</option> | ||
| 92 | {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)} | 100 | {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)} | ||
| 93 | </select> | 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 | <label className="check"> | 108 | <label className="check"> | ||
| 95 | <input | 109 | <input | ||
| 96 | type="checkbox" | 110 | type="checkbox" | ||
modified
frontend/src/pages/Job.tsx
+24 −4
@@ -10,7 +10,7 @@ | |||||
| 10 | */ | 10 | */ | ||
| 11 | import { useEffect, useState } from "react"; | 11 | import { useEffect, useState } from "react"; | ||
| 12 | import { Link, useParams } from "react-router-dom"; | 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 | export default function JobPage() { | 15 | export default function JobPage() { | ||
| 16 | const { uid = "" } = useParams(); | 16 | const { uid = "" } = useParams(); | ||
@@ -35,23 +35,35 @@ export default function JobPage() { | |||||
| 35 | <main className="container"> | 35 | <main className="container"> | ||
| 36 | <div className="sheet"> | 36 | <div className="sheet"> | ||
| 37 | <p className="muted"><Link to="/">← Toutes les offres</Link></p> | 37 | <p className="muted"><Link to="/">← Toutes les offres</Link></p> | ||
| 38 | − <h1>{job.title}</h1> | 38 | + <div style={{ display: "flex", alignItems: "center", gap: 14 }}> | ||
| 39 | − <p style={{ margin: 0 }}> | 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 | <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> | 50 | <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> | ||
| 41 | {job.location_label && <span className="muted"> — {job.location_label}</span>} | 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 | </p> | 53 | </p> | ||
| 43 | <div className="facts"> | 54 | <div className="facts"> | ||
| 44 | {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>} | 55 | {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>} | ||
| 45 | {!salary && <span className="badge">Salaire non affiché par l'employeur</span>} | 56 | {!salary && <span className="badge">Salaire non affiché par l'employeur</span>} | ||
| 46 | {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} | 57 | {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} | ||
| 47 | {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} | 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 | {job.category && <span className="badge">{job.category}</span>} | 60 | {job.category && <span className="badge">{job.category}</span>} | ||
| 49 | {job.date_posted && <span className="badge">Publiée le {formatDate(job.date_posted)}</span>} | 61 | {job.date_posted && <span className="badge">Publiée le {formatDate(job.date_posted)}</span>} | ||
| 50 | {job.date_deadline && <span className="badge">Date limite : {formatDate(job.date_deadline)}</span>} | 62 | {job.date_deadline && <span className="badge">Date limite : {formatDate(job.date_deadline)}</span>} | ||
| 51 | {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>} | 63 | {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>} | ||
| 52 | </div> | 64 | </div> | ||
| 53 | <p> | 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 | Postuler chez {job.employer} ↗ | 67 | Postuler chez {job.employer} ↗ | ||
| 56 | </a> | 68 | </a> | ||
| 57 | </p> | 69 | </p> | ||
@@ -59,6 +71,14 @@ export default function JobPage() { | |||||
| 59 | Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka | 71 | Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka | ||
| 60 | n'est pas un intermédiaire, la candidature se fait chez l'employeur. | 72 | n'est pas un intermédiaire, la candidature se fait chez l'employeur. | ||
| 61 | </p> | 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 | {job.description && ( | 82 | {job.description && ( | ||
| 63 | <> | 83 | <> | ||
| 64 | <h2 className="section-title">Description du poste</h2> | 84 | <h2 className="section-title">Description du poste</h2> | ||
modified
jobka/web.py
+35 −12
@@ -86,8 +86,10 @@ def list_jobs( | |||||
| 86 | salary_min: float | None = None, # $/an (converti côté serveur) | 86 | salary_min: float | None = None, # $/an (converti côté serveur) | ||
| 87 | with_salary: int | None = None, # 1 = transparence salariale seulement | 87 | with_salary: int | None = None, # 1 = transparence salariale seulement | ||
| 88 | posted_after: str | None = None, # ISO : offres publiées depuis… | 88 | posted_after: str | None = None, # ISO : offres publiées depuis… | ||
| 89 | + language: str | None = None, # fr | en | bilingue | ||||
| 89 | q: str | None = None, | 90 | q: str | None = None, | ||
| 90 | active: int = 1, | 91 | active: int = 1, | ||
| 92 | + quarantined: int | None = None, # 1 = quarantaine seulement (supervision) | ||||
| 91 | sort: str = "recent", # recent | salary | 93 | sort: str = "recent", # recent | salary | ||
| 92 | limit: int = Query(100, le=2000), | 94 | limit: int = Query(100, le=2000), | ||
| 93 | offset: int = 0, | 95 | offset: int = 0, | ||
@@ -95,12 +97,17 @@ def list_jobs( | |||||
| 95 | con = db.connect() | 97 | con = db.connect() | ||
| 96 | sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués | 98 | sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués | ||
| 97 | args: list = [] | 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 | if active in (0, 1): | 103 | if active in (0, 1): | ||
| 99 | sql += " AND active=?"; args.append(active) | 104 | sql += " AND active=?"; args.append(active) | ||
| 100 | if city: | 105 | if city: | ||
| 101 | sql += " AND city=?"; args.append(city) | 106 | sql += " AND city=?"; args.append(city) | ||
| 102 | if region: | 107 | if region: | ||
| 103 | sql += " AND region=?"; args.append(region) | 108 | sql += " AND region=?"; args.append(region) | ||
| 109 | + if language: | ||||
| 110 | + sql += " AND language=?"; args.append(language) | ||||
| 104 | if category: | 111 | if category: | ||
| 105 | sql += " AND category=?"; args.append(category) | 112 | sql += " AND category=?"; args.append(category) | ||
| 106 | if employer: | 113 | if employer: | ||
@@ -141,11 +148,13 @@ def list_jobs( | |||||
| 141 | @app.get("/api/jobs.geojson") | 148 | @app.get("/api/jobs.geojson") | ||
| 142 | def jobs_geojson( | 149 | def jobs_geojson( | ||
| 143 | city: str | None = None, | 150 | city: str | None = None, | ||
| 151 | + region: str | None = None, | ||||
| 144 | category: str | None = None, | 152 | category: str | None = None, | ||
| 145 | work_mode: str | None = None, | 153 | work_mode: str | None = None, | ||
| 146 | employment_type: str | None = None, | 154 | employment_type: str | None = None, | ||
| 147 | salary_min: float | None = None, | 155 | salary_min: float | None = None, | ||
| 148 | with_salary: int | None = None, | 156 | with_salary: int | None = None, | ||
| 157 | + language: str | None = None, | ||||
| 149 | q: str | None = None, | 158 | q: str | None = None, | ||
| 150 | bbox: str | None = None, | 159 | bbox: str | None = None, | ||
| 151 | limit: int = Query(3000, le=8000), | 160 | limit: int = Query(3000, le=8000), | ||
@@ -157,10 +166,10 @@ def jobs_geojson( | |||||
| 157 | « N résultats sans position sur la carte ». | 166 | « N résultats sans position sur la carte ». | ||
| 158 | """ | 167 | """ | ||
| 159 | con = db.connect() | 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 | " employment_type, salary_min, salary_max, salary_unit," | 170 | " employment_type, salary_min, salary_max, salary_unit," | ||
| 162 | " salary_year_min, salary_year_max, date_posted, source, lat, lng" | 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 | " AND lat IS NOT NULL AND lng IS NOT NULL") | 173 | " AND lat IS NOT NULL AND lng IS NOT NULL") | ||
| 165 | args: list = [] | 174 | args: list = [] | ||
| 166 | if bbox: | 175 | if bbox: | ||
@@ -172,6 +181,8 @@ def jobs_geojson( | |||||
| 172 | args += [south, north, west, east] | 181 | args += [south, north, west, east] | ||
| 173 | if city: | 182 | if city: | ||
| 174 | sql += " AND city=?"; args.append(city) | 183 | sql += " AND city=?"; args.append(city) | ||
| 184 | + if region: | ||||
| 185 | + sql += " AND region=?"; args.append(region) | ||||
| 175 | if category: | 186 | if category: | ||
| 176 | sql += " AND category=?"; args.append(category) | 187 | sql += " AND category=?"; args.append(category) | ||
| 177 | if work_mode: | 188 | if work_mode: | ||
@@ -183,6 +194,8 @@ def jobs_geojson( | |||||
| 183 | args.append(salary_min) | 194 | args.append(salary_min) | ||
| 184 | if with_salary == 1: | 195 | if with_salary == 1: | ||
| 185 | sql += " AND salary_min IS NOT NULL" | 196 | sql += " AND salary_min IS NOT NULL" | ||
| 197 | + if language: | ||||
| 198 | + sql += " AND language=?"; args.append(language) | ||||
| 186 | if q: | 199 | if q: | ||
| 187 | sql += " AND (title LIKE ? OR employer LIKE ?)" | 200 | sql += " AND (title LIKE ? OR employer LIKE ?)" | ||
| 188 | args += [f"%{q}%"] * 2 | 201 | args += [f"%{q}%"] * 2 | ||
@@ -204,7 +217,8 @@ def jobs_geojson( | |||||
| 204 | "type": "Feature", | 217 | "type": "Feature", | ||
| 205 | "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, | 218 | "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, | ||
| 206 | "properties": { | 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 | "city": r["city"], "category": r["category"], | 222 | "city": r["city"], "category": r["category"], | ||
| 209 | "work_mode": r["work_mode"], | 223 | "work_mode": r["work_mode"], | ||
| 210 | "employment_type": r["employment_type"], | 224 | "employment_type": r["employment_type"], | ||
@@ -241,10 +255,18 @@ def get_job(uid: str): | |||||
| 241 | def facets(city: str | None = None): | 255 | def facets(city: str | None = None): | ||
| 242 | """Valeurs distinctes pour construire les filtres du frontend.""" | 256 | """Valeurs distinctes pour construire les filtres du frontend.""" | ||
| 243 | con = db.connect() | 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 | out = { | 259 | out = { | ||
| 246 | "cities": [r["city"] for r in con.execute( | 260 | "cities": [r["city"] for r in con.execute( | ||
| 247 | f"SELECT DISTINCT city{base} AND city<>'' ORDER BY city")], | 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 | "categories": [dict(r) for r in con.execute( | 270 | "categories": [dict(r) for r in con.execute( | ||
| 249 | f"SELECT category, COUNT(*) n{base} AND category<>''" | 271 | f"SELECT category, COUNT(*) n{base} AND category<>''" | ||
| 250 | " GROUP BY category ORDER BY n DESC")], | 272 | " GROUP BY category ORDER BY n DESC")], | ||
@@ -290,7 +312,11 @@ def stats(): | |||||
| 290 | AVG(salary_year_min) avg_salary_year, | 312 | AVG(salary_year_min) avg_salary_year, | ||
| 291 | SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote, | 313 | SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote, | ||
| 292 | SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded | 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 | cities = [dict(r) for r in con.execute( | 320 | cities = [dict(r) for r in con.execute( | ||
| 295 | """SELECT city, COUNT(*) n FROM jobs | 321 | """SELECT city, COUNT(*) n FROM jobs | ||
| 296 | WHERE active=1 AND dup_of IS NULL AND city<>'' | 322 | WHERE active=1 AND dup_of IS NULL AND city<>'' | ||
@@ -319,7 +345,7 @@ def stats(): | |||||
| 319 | return {**dict(row), "top_cities": cities, "categories": categories, | 345 | return {**dict(row), "top_cities": cities, "categories": categories, | ||
| 320 | "direct_jobs": direct["n"], "direct_employers": direct["e"], | 346 | "direct_jobs": direct["n"], "direct_employers": direct["e"], | ||
| 321 | "direct_sources": direct["s"], "portal_jobs": portal["n"], | 347 | "direct_sources": direct["s"], "portal_jobs": portal["n"], | ||
| 322 | − "duplicates_hidden": dups["n"], | 348 | + "duplicates_hidden": dups["n"], "quarantined": quarantined, | ||
| 323 | "recent_syncs": log} | 349 | "recent_syncs": log} | ||
| 324 | 350 | ||||
| 325 | 351 | ||||
@@ -340,22 +366,19 @@ def stats_report(period: str = "30j", | |||||
| 340 | from_: str | None = Query(None, alias="from"), | 366 | from_: str | None = Query(None, alias="from"), | ||
| 341 | to: str | None = None, | 367 | to: str | None = None, | ||
| 342 | mode: str = "complet"): | 368 | mode: str = "complet"): | ||
| 343 | − """Rapport PDF estampillé Groupe-KA — 5 modes (complet, synthese, | 369 | + """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages).""" | ||
| 344 | − tendances, repartitions, donnees) ; un mode inconnu retombe sur complet.""" | ||||
| 345 | from . import kapdf, statsdash | 370 | from . import kapdf, statsdash | ||
| 346 | if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): | 371 | if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"): | ||
| 347 | period = "30j" | 372 | period = "30j" | ||
| 348 | − if mode not in kapdf.REPORT_MODES: | ||||
| 349 | − mode = "complet" | ||||
| 350 | dash = statsdash.compute(period, from_, to) | 373 | dash = statsdash.compute(period, from_, to) | ||
| 351 | data = kapdf.GroupeKAReport( | 374 | data = kapdf.GroupeKAReport( | ||
| 352 | site={"wordmark": "Job·Ka", "accent": "#0c8599", | 375 | site={"wordmark": "Job·Ka", "accent": "#0c8599", | ||
| 353 | "domain": "www.job-ka.com", | 376 | "domain": "www.job-ka.com", | ||
| 354 | "tagline": "Tous les emplois des employeurs québécois"}, | 377 | "tagline": "Tous les emplois des employeurs québécois"}, | ||
| 355 | dashboard=dash, | 378 | dashboard=dash, | ||
| 356 | − mode=mode, | 379 | + mode="synthese" if mode == "synthese" else "complet", | ||
| 357 | ).build() | 380 | ).build() | ||
| 358 | − fname = kapdf.filename("job-ka", period, mode) | 381 | + fname = kapdf.filename("job-ka", period) | ||
| 359 | return Response( | 382 | return Response( | ||
| 360 | content=data, media_type="application/pdf", | 383 | content=data, media_type="application/pdf", | ||
| 361 | headers={"Content-Disposition": f'attachment; filename="{fname}"'}) | 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 | ("date_posted", "Date de parution", "ISO 8601"), | 82 | ("date_posted", "Date de parution", "ISO 8601"), | ||
| 83 | ("date_deadline", "Date limite", "date limite pour postuler"), | 83 | ("date_deadline", "Date limite", "date limite pour postuler"), | ||
| 84 | ("category", "Catégorie", "taxonomie interne (TI, Santé, …)"), | 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 | ("gps", "GPS", "lat/lng (géocodage sans invention)"), | 88 | ("gps", "GPS", "lat/lng (géocodage sans invention)"), | ||
| 86 | ] | 89 | ] | ||
| 87 | 90 | ||||
@@ -96,7 +99,7 @@ FIELD_CONDS = { | |||||
| 96 | } | 99 | } | ||
| 97 | for _col in ("title", "employer", "url", "city", "region", "address", | 100 | for _col in ("title", "employer", "url", "city", "region", "address", | ||
| 98 | "postal_code", "salary_label", "date_posted", "date_deadline", | 101 | "postal_code", "salary_label", "date_posted", "date_deadline", | ||
| 99 | − "category"): | 102 | + "category", "language", "company_logo", "apply_url"): | ||
| 100 | FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')" | 103 | FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')" | ||
| 101 | 104 | ||||
| 102 | # Complétude « clé » (INDEX + tableaux de tenants). | 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 | |||||