SPB Git

spb/job-ka

Public

Fondation Job·Ka : socle backend, 5 connecteurs pilotes, frontend, tests

- Schéma JobPosting + normalisation (salaires $/h↔$/an, dates ISO, villes
  canoniques, catégories) — 25 tests unitaires
- Pipeline hérité de Lou·Ka : diff par content_hash, délai de grâce,
  anti-dérive, expiration sur date limite, dédup union-find, géocodage
  Nominatim + Adresses Québec avec cache
- Plateformes ATS : Workday (facette Québec auto-découverte), Lever,
  Greenhouse, SmartRecruiters — connecteurs pilotes CAE, Desjardins, Poka,
  Mistplay, Ubisoft (442 offres QC)
- API FastAPI (/api/jobs, geojson, facets, sources, stats, /health) +
  frontend React/Vite (liste, fiche, carte MapLibre, stats)
- Fixtures HTTP enregistrées/rejouées hors ligne + hook pre-commit
  d'en-tête d'auteur (section 17)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) Browse files

54 changed files +7,657 −0

54 changed files
added .env.example +11 −0
@@ -0,0 +1,11 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : .env.example
6 +# Rôle : Gabarit des variables d'environnement (copier vers .env)
7 +# Créé : 2026-08-17 Modifié : 2026-08-17
8 +# =============================================================================
9 +
10 +# Clé API Scrapfly (scraping des pages carrières protégées / rendu JS)
11 +SCRAPFLY_API_KEY=scp-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
added .gitignore +30 −0
@@ -0,0 +1,30 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : .gitignore
6 +# Rôle : Exclusions git (secrets, caches, artefacts de build)
7 +# Créé : 2026-08-17 Modifié : 2026-08-17
8 +# =============================================================================
9 +
10 +# Secrets — jamais commités
11 +.env
12 +
13 +# Python
14 +__pycache__/
15 +*.py[cod]
16 +.venv/
17 +.pytest_cache/
18 +
19 +# Node / frontend
20 +node_modules/
21 +dist/
22 +*.tsbuildinfo
23 +
24 +# macOS
25 +.DS_Store
26 +
27 +# Données locales et rapports générés
28 +data/*.db
29 +data/*.sqlite*
30 +data/cache/
added CLAUDE.md +159 −0
@@ -0,0 +1,159 @@
1 +# CLAUDE.md — Job·Ka
2 +
3 +> Agrégateur d'offres d'emploi québécoises du **Groupe KA** (groupe-ka.com).
4 +> Fichier d'instructions pour Claude Code. Lis-le en entier avant toute action.
5 +
6 +---
7 +
8 +## Règle nº 1 — En-tête d'auteur OBLIGATOIRE
9 +
10 +**Chaque fichier créé ou modifié** (code, script, config, doc, migration, test — sans exception) doit commencer par l'en-tête d'auteur suivant, adapté à la syntaxe de commentaire du langage :
11 +
12 +```python
13 +# =============================================================================
14 +# Job·Ka — Groupe KA
15 +# Auteur : Simon-Pierre Boucher
16 +# Contact : contact@spboucher.ai
17 +# Fichier : <chemin/nom_du_fichier>
18 +# Rôle : <description en une ligne>
19 +# Créé : <YYYY-MM-DD> Modifié : <YYYY-MM-DD>
20 +# =============================================================================
21 +```
22 +
23 +```javascript
24 +/**
25 + * =============================================================================
26 + * Job·Ka — Groupe KA
27 + * Auteur : Simon-Pierre Boucher
28 + * Contact : contact@spboucher.ai
29 + * Fichier : <chemin/nom_du_fichier>
30 + * Rôle : <description en une ligne>
31 + * Créé : <YYYY-MM-DD> Modifié : <YYYY-MM-DD>
32 + * =============================================================================
33 + */
34 +```
35 +
36 +- Aucun fichier sans cet en-tête ne doit être commité. Point final.
37 +- Si tu modifies un fichier existant sans en-tête : ajoute-le.
38 +- Si tu modifies un fichier avec en-tête : mets à jour la date `Modifié`.
39 +- Voir la **section 17** pour la politique complète d'attribution.
40 +
41 +---
42 +
43 +## 2. Contexte du projet
44 +
45 +**Job·Ka** agrège les offres d'emploi **directement depuis les pages carrières des employeurs québécois** (pas depuis Indeed/Jobillico). Même philosophie que les autres plateformes du Groupe KA (Lou·Ka, Immo·Ka, Vrai-Prix, ValoPlex, Auto·Ka, Fabri·Ka, Food·Ka) :
46 +
47 +1. **Zéro boîte noire** — chaque offre est traçable à sa source, avec URL originale.
48 +2. **Données réelles à la source** — connecteurs automatisés, aucune saisie manuelle.
49 +3. **Indépendance** — aucun employeur ne paie pour être mis en avant.
50 +
51 +Architecture cible : **un connecteur = un employeur** (ou une plateforme ATS partagée : Workday, Lever, Greenhouse, SmartRecruiters, BambooHR, etc.), un scheduler qui resynchronise jour et nuit, une normalisation vers un schéma unifié.
52 +
53 +---
54 +
55 +## 3. Règle nº 2 — Étudier le dépôt Lou·Ka AVANT tout connecteur
56 +
57 +**Avant d'écrire ou de modifier le moindre connecteur**, tu dois explorer le dépôt Lou·Ka et produire un résumé de ce que tu as compris. Job·Ka doit réutiliser les patterns éprouvés de Lou·Ka, pas les réinventer.
58 +
59 +À étudier obligatoirement dans Lou·Ka :
60 +
61 +- [ ] Structure d'un connecteur : interface commune, classe de base, conventions de nommage
62 +- [ ] Schéma de données normalisé et pipeline de normalisation
63 +- [ ] Gestion des erreurs, retries, timeouts, rate limiting par source
64 +- [ ] Scheduler / orchestration des resynchronisations
65 +- [ ] Déduplication des annonces entre sources
66 +- [ ] Détection et retrait des annonces expirées
67 +- [ ] Stockage (BD, index, cache) et exposition API/frontend
68 +- [ ] Logging et monitoring des connecteurs (taux de succès, fraîcheur)
69 +
70 +**Livrable avant de coder :** un rapport `docs/audit-louka.md` (avec en-tête d'auteur, règle nº 1) résumant l'architecture de Lou·Ka et le plan d'adaptation pour Job·Ka. Attends la validation de Simon-Pierre avant d'implémenter.
71 +
72 +---
73 +
74 +## 4. Schéma de données cible (offre d'emploi)
75 +
76 +Adapte les noms au style hérité de Lou·Ka, mais chaque connecteur doit tenter d'extraire :
77 +
78 +- `id_source` — identifiant unique chez l'employeur/ATS
79 +- `employeur` — nom de l'entreprise
80 +- `url_offre` — lien direct vers l'offre originale (obligatoire, zéro boîte noire)
81 +- `titre` — titre du poste
82 +- `description` — texte complet (HTML nettoyé)
83 +- `lieu` — ville, région, code postal si dispo
84 +- `mode_travail` — présentiel / hybride / télétravail
85 +- `type_emploi` — temps plein / partiel / contractuel / stage / saisonnier
86 +- `salaire_min`, `salaire_max`, `salaire_unite` — nombres, pas des strings (transparence salariale = différenciateur clé)
87 +- `avantages` — liste si disponible
88 +- `exigences` — scolarité, années d'expérience, langues
89 +- `date_publication`, `date_limite` — dates ISO normalisées
90 +- `categorie` — taxonomie interne (TI, santé, construction, etc.)
91 +- `ats` — plateforme source (workday, lever, custom, etc.)
92 +- `scrape_timestamp` — horodatage de la collecte
93 +
94 +Règles de normalisation : dates en ISO 8601, salaires convertis en $/h et $/an, géocodage adresse → lat/lng (réutiliser le pipeline Nominatim + cache de Lou·Ka, 1 req/s max).
95 +
96 +---
97 +
98 +## 5. Conventions de développement
99 +
100 +- Langue du code : anglais pour les identifiants, français pour la doc et les commentaires métier.
101 +- Un connecteur = un module isolé, testable seul (`make test-connector NAME=x`).
102 +- Jamais de scraping agressif : respecter robots.txt quand raisonnable, throttling par domaine, User-Agent identifiable `JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)`.
103 +- Aucun secret en dur dans le code : `.env` uniquement, jamais commité.
104 +- Chaque PR/commit : message clair en français, en-tête d'auteur vérifié (règle nº 1).
105 +- Avant de coder une fonctionnalité : présenter le plan et attendre validation.
106 +
107 +---
108 +
109 +## 6. Déploiement — node m3u96a + ngrok
110 +
111 +**Environnement de déploiement :**
112 +
113 +- Nœud cible : **`m3u96a`**
114 +- Exposition publique : **ngrok** avec le domaine réservé **`www.job-ka.com`**
115 +
116 +Procédure attendue :
117 +
118 +```bash
119 +# 1. Sur le node m3u96a — build et démarrage du service
120 +# (adapter au stack hérité de Lou·Ka : pm2, systemd, docker compose…)
121 +
122 +# 2. Tunnel ngrok vers le port de l'app (ex. 3000)
123 +ngrok http --domain=www.job-ka.com 3000
124 +```
125 +
126 +Règles :
127 +
128 +- Le tunnel ngrok doit être supervisé (pm2/systemd) pour redémarrer seul.
129 +- Vérifier après chaque déploiement : `https://www.job-ka.com` répond, healthcheck OK, connecteurs planifiés actifs.
130 +- Documenter toute variation dans `docs/deploiement.md` (avec en-tête, règle nº 1).
131 +- Ne jamais exposer d'interface d'admin ou de BD via le tunnel public.
132 +
133 +---
134 +
135 +## 7–16. Sections réservées
136 +
137 +Réservées pour les futures politiques (monitoring, SEO, alertes courriel, API publique, etc.). Ne pas supprimer la numérotation : la **section 17** doit rester la section 17.
138 +
139 +---
140 +
141 +## 17. Politique d'attribution d'auteur
142 +
143 +1. **Auteur unique du projet : Simon-Pierre Boucher** (`contact@spboucher.ai`). Toute contribution générée par Claude est réputée réalisée pour le compte de l'auteur.
144 +2. L'en-tête d'auteur (règle nº 1) est **non négociable** : il s'applique aux fichiers de code, scripts, configs, docs, tests, migrations, workflows CI, Dockerfiles et Makefiles.
145 +3. Les métadonnées de projet doivent refléter l'auteur :
146 + - `package.json` → `"author": "Simon-Pierre Boucher <contact@spboucher.ai>"`
147 + - `pyproject.toml` → `authors = [{name = "Simon-Pierre Boucher", email = "contact@spboucher.ai"}]`
148 + - `LICENSE` et `README.md` → copyright « © Groupe KA — Simon-Pierre Boucher »
149 +4. Configuration git du dépôt :
150 + ```bash
151 + git config user.name "Simon-Pierre Boucher"
152 + git config user.email "contact@spboucher.ai"
153 + ```
154 +5. Toute page publique de Job·Ka affiche dans le pied de page : « Une plateforme du Groupe KA » avec le contact `contact@spboucher.ai`.
155 +6. **Vérification automatique** : ajouter un hook pre-commit (ou étape CI) qui rejette tout fichier sans l'en-tête d'auteur. Ce hook porte lui-même l'en-tête.
156 +
157 +---
158 +
159 +*Dernière mise à jour : 2026-08-17 — Simon-Pierre Boucher (contact@spboucher.ai)*
added README.md +281 −0
@@ -0,0 +1,281 @@
1 +<!--
2 +=============================================================================
3 +Job·Ka — Groupe KA
4 +Auteur : Simon-Pierre Boucher
5 +Contact : contact@spboucher.ai
6 +Fichier : README.md
7 +Rôle : Documentation principale du projet
8 +Créé : 2026-08-17 Modifié : 2026-08-17
9 +=============================================================================
10 +-->
11 +
12 +<div align="center">
13 +
14 +# Job·Ka
15 +
16 +### Tous les emplois des employeurs québécois. Un seul endroit.
17 +
18 +**[www.job-ka.com](https://www.job-ka.com)**
19 +
20 +*Job·Ka collecte les offres directement sur les pages carrières des employeurs
21 +du Québec — pas sur les job boards. Chaque offre est traçable à sa source,
22 +les salaires sont normalisés en $/h et $/an, et la candidature se fait chez
23 +l'employeur. Zéro boîte noire, zéro intermédiaire.*
24 +
25 +</div>
26 +
27 +---
28 +
29 +## Sommaire
30 +
31 +- [Pourquoi Job·Ka ?](#pourquoi-job-ka-)
32 +- [L'architecture en 30 secondes](#larchitecture-en-30-secondes)
33 +- [Le pipeline en détail](#le-pipeline-en-détail)
34 +- [Structure du dépôt](#structure-du-dépôt)
35 +- [Démarrage rapide](#démarrage-rapide)
36 +- [CLI `run.py`](#cli-runpy)
37 +- [Variables d'environnement](#variables-denvironnement)
38 +- [API](#api)
39 +- [Ajouter un employeur (≈ 10 lignes)](#ajouter-un-employeur--10-lignes)
40 +- [Tests](#tests)
41 +- [Production](#production)
42 +- [Principes](#principes)
43 +- [Écosystème Groupe KA](#écosystème-groupe-ka)
44 +
45 +---
46 +
47 +## Pourquoi Job·Ka ?
48 +
49 +Les grands sites d'emploi mélangent annonces payantes, doublons repostés et
50 +offres mortes. L'offre originale — celle de la page carrière de l'employeur —
51 +est la seule source fiable : à jour, complète, sans frais de mise en avant.
52 +
53 +> Job·Ka se branche directement sur les systèmes de recrutement (ATS) des
54 +> employeurs : Workday, Lever, Greenhouse, SmartRecruiters, ou leur site
55 +> maison. Un connecteur = un employeur. Le tout resynchronisé jour et nuit.
56 +
57 +Ce n'est pas un job board qui vend de la visibilité, c'est un index fidèle de
58 +ce que les employeurs québécois affichent réellement — **avec les salaires en
59 +clair quand ils existent** (normalisés en $/h et $/an), parce que la
60 +transparence salariale est le différenciateur clé.
61 +
62 +## L'architecture en 30 secondes
63 +
64 +```mermaid
65 +flowchart LR
66 + subgraph Sources
67 + W["Workday"] --> C
68 + L["Lever"] --> C
69 + G["Greenhouse"] --> C
70 + S["SmartRecruiters"] --> C
71 + X["Sites maison<br/>(HTML / Scrapfly)"] --> C
72 + end
73 + C["Connecteurs<br/>(auto-découverts)"] --> N["finalize()<br/>normalisation"]
74 + N --> D[("SQLite<br/>data/jobka.db")]
75 + W2["⏱ Watcher horaire<br/>(PM2)"] -.-> C
76 + D --> A["FastAPI<br/>/api/*"]
77 + A --> F["React + Vite<br/>frontend/dist"]
78 + D --> GEO["Géocodage<br/>Nominatim + Adresses Québec"]
79 + GEO --> D
80 +```
81 +
82 +| Couche | Rôle | Fichiers |
83 +|---|---|---|
84 +| Connecteurs | 1 employeur = 1 module ; plateformes ATS partagées | `jobka/connectors/` |
85 +| Schéma | `JobPosting` + `finalize()` (normalisation commune) | `jobka/schema.py`, `jobka/normalize.py` |
86 +| Persistance | diff par `content_hash`, cycle de vie, anti-dérive | `jobka/db.py` |
87 +| Pipeline | ingestion, boucle `watch`, géocodage, dédup | `jobka/ingest.py`, `geocode.py`, `dedup.py` |
88 +| API + Web | FastAPI + frontend Vite servi en statique | `jobka/web.py`, `frontend/` |
89 +| Tests | fixtures HTTP enregistrées, rejeu hors ligne | `jobka/fixtures.py`, `tests/` |
90 +
91 +## Le pipeline en détail
92 +
93 +### Connecteurs auto-découverts
94 +Toute sous-classe de `BaseConnector` avec un `source_id` est enregistrée par
95 +`pkgutil` — aucun registre à maintenir. Les plateformes ATS (Workday, Lever,
96 +Greenhouse, SmartRecruiters) sont des classes de base : brancher un nouvel
97 +employeur = ~10 lignes. Politesse : throttling par source (`request_delay`),
98 +User-Agent identifiable `JobKaBot/1.0`, budgets de requêtes, cache BD des
99 +pages détail.
100 +
101 +### Diff engine avec garde-fou anti-dérive
102 +Chaque sync compare le `content_hash` de chaque offre : ajout, mise à jour, ou
103 +disparition. Une offre absente 2 syncs consécutifs passe `active=0` (jamais de
104 +DELETE). Si une source retourne ≤ 25 % de sa médiane historique, alerte et
105 +**retraits suspendus** — un connecteur cassé ne vide pas la base. Les offres
106 +dont la `date_limite` est dépassée sont retirées même si encore affichées.
107 +
108 +### Salaires normalisés — jamais inventés
109 +`parse_salary()` exige un signe monétaire ou une unité explicite, applique des
110 +bornes de plausibilité par unité, et convertit en $/h et $/an (40 h × 52 sem.).
111 +Une offre sans salaire reste sans salaire. L'historique des changements est
112 +conservé (`salary_log`).
113 +
114 +### Géocodage sans invention
115 +Nominatim (1 req/1,1 s) + repli officiel Adresses Québec (MERN, lots de 200),
116 +cache persistant par lieu normalisé, validation par bounding box provinciale.
117 +Sans adresse : centroïde de ville. Introuvable : `geocode_failed=1`, jamais de
118 +coordonnées bidon.
119 +
120 +### Déduplication inter-sources
121 +Blocage par clé exacte `titre normalisé | employeur | ville` + union-find avec
122 +exigence d'un second signal concordant (type, salaire ±4 %, date ±7 j). Le
123 +canonique est la source la plus autoritaire (page carrière avant agrégat) ;
124 +les doublons sont masqués (`dup_of`), jamais supprimés.
125 +
126 +## Structure du dépôt
127 +
128 +```
129 +job-ka/
130 +├── run.py # point d'entrée : sync | watch | serve | geocode | record
131 +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4
132 +├── jobka/
133 +│ ├── schema.py # dataclass JobPosting + finalize()
134 +│ ├── normalize.py # salaires, dates, mode/type, lieux, catégories
135 +│ ├── db.py # SQLite : diff, cycle de vie, anti-dérive
136 +│ ├── ingest.py # run() / watch() — orchestration
137 +│ ├── dedup.py # déduplication inter-sources (union-find)
138 +│ ├── geocode.py # Nominatim + Adresses Québec + cache
139 +│ ├── web.py # API FastAPI + /health + frontend statique
140 +│ ├── fixtures.py # enregistrement/rejeu HTTP pour les tests
141 +│ └── connectors/
142 +│ ├── base.py # BaseConnector (throttling, Scrapfly, cache détail)
143 +│ ├── workday.py # plateforme Workday (facette Québec auto-découverte)
144 +│ ├── lever.py # plateforme Lever
145 +│ ├── greenhouse.py # plateforme Greenhouse
146 +│ ├── smartrecruiters.py # plateforme SmartRecruiters
147 +│ └── <employeur>.py # 1 fichier = 1 employeur (sous-classe)
148 +├── data/sources.json # registre descriptif des employeurs
149 +├── frontend/ # React 18 + Vite + TS (carte MapLibre, sans jeton)
150 +├── tests/ # pytest : normalisation + rejeu des connecteurs
151 +├── scripts/check_headers.py # hook pre-commit : en-tête d'auteur obligatoire
152 +└── docs/ # audit Lou·Ka, déploiement
153 +```
154 +
155 +## Démarrage rapide
156 +
157 +```bash
158 +git clone <repo> job-ka && cd job-ka
159 +
160 +# Backend
161 +python3 -m venv .venv
162 +.venv/bin/pip install -r requirements.txt
163 +
164 +# Frontend
165 +cd frontend && npm install && npm run build && cd ..
166 +
167 +# Secrets (Scrapfly pour les sites anti-bot — facultatif au départ)
168 +cp .env.example .env # puis remplir SCRAPFLY_API_KEY
169 +
170 +# Amorçage : première synchronisation + géocodage
171 +.venv/bin/python run.py sync
172 +.venv/bin/python run.py geocode
173 +
174 +# Service (API + frontend) — http://localhost:8096
175 +.venv/bin/python run.py serve 8096
176 +```
177 +
178 +En dev frontend : `npm run dev` dans `frontend/` (proxy `/api` → :8096).
179 +
180 +## CLI `run.py`
181 +
182 +| Commande | Rôle |
183 +|---|---|
184 +| `run.py sync [source ...]` | synchronise les offres (toutes les sources ou celles données) |
185 +| `run.py watch [minutes]` | boucle : sync + géocodage + dédup (défaut 60 min) |
186 +| `run.py serve [port]` | API + frontend (défaut 8096) |
187 +| `run.py geocode [n]` | géocode les offres sans coordonnées |
188 +| `run.py record <source ...>` | enregistre les fixtures de test d'une source |
189 +
190 +## Variables d'environnement
191 +
192 +| Variable | Rôle |
193 +|---|---|
194 +| `SCRAPFLY_API_KEY` | rendu JS / anti-bot pour les pages carrières maison |
195 +| `JOBKA_WORKDAY_DETAIL_LIMIT` | plafond de pages détail Workday par sync (défaut 300) |
196 +| `JOBKA_SR_DETAIL_LIMIT` | plafond de pages détail SmartRecruiters (défaut 300) |
197 +
198 +## API
199 +
200 +| Route | Rôle |
201 +|---|---|
202 +| `GET /health` | healthcheck (BD + fraîcheur des syncs) |
203 +| `GET /api/jobs` | recherche : `q, city, category, work_mode, employment_type, salary_min, with_salary, sort, limit, offset` |
204 +| `GET /api/jobs.geojson` | offres géolocalisées (carte), `bbox` + mêmes filtres |
205 +| `GET /api/jobs/{uid}` | fiche complète + historique salarial |
206 +| `GET /api/facets` | valeurs distinctes pour les filtres |
207 +| `GET /api/sources` | registre des employeurs + fraîcheur par source |
208 +| `GET /api/stats` | totaux, catégories, villes, dernières syncs |
209 +| `POST /api/sync` | déclenche une synchronisation (webhook) |
210 +
211 +## Ajouter un employeur (≈ 10 lignes)
212 +
213 +```python
214 +# jobka/connectors/mon_employeur.py (+ en-tête d'auteur, règle nº 1)
215 +from .workday import WorkdayConnector
216 +
217 +class MonEmployeurConnector(WorkdayConnector):
218 + source_id = "mon_employeur"
219 + EMPLOYER = "Mon Employeur inc."
220 + TENANT = "monemployeur" # monemployeur.wd3.myworkdayjobs.com
221 + HOST = "wd3"
222 + SITE = "Careers"
223 +```
224 +
225 +Puis : ajouter l'entrée dans `data/sources.json`, `run.py sync mon_employeur`
226 +pour valider, `run.py record mon_employeur` pour figer les fixtures de test.
227 +
228 +## Tests
229 +
230 +```bash
231 +.venv/bin/python -m pytest # tout
232 +.venv/bin/python -m pytest tests/test_normalize.py # normalisation seule
233 +```
234 +
235 +- `test_normalize.py` — salaires, dates, modes, types, lieux, catégories ;
236 +- `test_connectors.py` — rejeu hors ligne des fixtures : instantané exact +
237 + invariants (uid uniques, URLs absolues, salaires plausibles, valeurs jamais
238 + inventées) + `finalize()` sûr.
239 +
240 +## Production
241 +
242 +```
243 +job-ka-web .venv/bin/python run.py serve 8096 # API + frontend
244 +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire, jour et nuit
245 +job-ka-ngrok ngrok http --url=www.job-ka.com 8096 # tunnel public
246 +```
247 +
248 +**Philosophie d'exploitation : on ne pousse que le code — le serveur maintient
249 +ses données lui-même.** `data/*.db`, `frontend/dist/` et `.env` ne sont jamais
250 +versionnés. Détails : `docs/deploiement.md`.
251 +
252 +## Principes
253 +
254 +1. **Politesse** — throttling par source, robots.txt respecté, User-Agent
255 + identifiable, budgets de requêtes, caches.
256 +2. **Fidélité** — les champs reflètent la source ; une donnée absente reste
257 + absente, jamais de valeur inventée (salaire, date, coordonnées).
258 +3. **Traçabilité** — chaque offre garde son URL originale ; la candidature se
259 + fait chez l'employeur. Zéro boîte noire.
260 +4. **Robustesse** — une source cassée ne bloque pas les autres et ne vide pas
261 + la base (délai de grâce + anti-dérive).
262 +5. **Transparence salariale** — salaires numériques normalisés en $/h et
263 + $/an ; filtre « salaire affiché » de premier ordre.
264 +
265 +## Écosystème Groupe KA
266 +
267 +Job·Ka partage la philosophie et l'architecture des plateformes sœurs :
268 +[Lou·Ka](https://www.lou-ka.com) (logements), Immo·Ka, Vrai-Prix, ValoPlex,
269 +Auto·Ka, Fabri·Ka, Food·Ka.
270 +
271 +---
272 +
273 +<div align="center">
274 +
275 +## Auteur
276 +
277 +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
278 +
279 +© 2026 Groupe KA — Simon-Pierre Boucher. Tous droits réservés.
280 +
281 +</div>
added data/sources.json +60 −0
@@ -0,0 +1,60 @@
1 +{
2 + "_meta": {
3 + "project": "Job·Ka — Groupe KA",
4 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
5 + "role": "Registre descriptif des sources (alimente la page Sources)",
6 + "updated": "2026-08-17"
7 + },
8 + "sources": [
9 + {
10 + "id": "cae",
11 + "name": "CAE",
12 + "url": "https://www.cae.com",
13 + "careers_url": "https://cae.wd3.myworkdayjobs.com/career",
14 + "sectors": ["Aéronautique", "Simulation", "TI"],
15 + "connector": "workday",
16 + "status": "actif",
17 + "region": "Montréal"
18 + },
19 + {
20 + "id": "desjardins",
21 + "name": "Mouvement Desjardins",
22 + "url": "https://www.desjardins.com",
23 + "careers_url": "https://desjardins.wd10.myworkdayjobs.com/Desjardins",
24 + "sectors": ["Finance", "Assurance", "TI"],
25 + "connector": "workday",
26 + "status": "actif",
27 + "region": "Lévis / province"
28 + },
29 + {
30 + "id": "poka",
31 + "name": "Poka",
32 + "url": "https://www.poka.io",
33 + "careers_url": "https://job-boards.greenhouse.io/poka",
34 + "sectors": ["TI", "SaaS manufacturier"],
35 + "connector": "greenhouse",
36 + "status": "actif",
37 + "region": "Québec"
38 + },
39 + {
40 + "id": "mistplay",
41 + "name": "Mistplay",
42 + "url": "https://www.mistplay.com",
43 + "careers_url": "https://jobs.lever.co/mistplay",
44 + "sectors": ["TI", "Jeu mobile"],
45 + "connector": "lever",
46 + "status": "actif",
47 + "region": "Montréal"
48 + },
49 + {
50 + "id": "ubisoft",
51 + "name": "Ubisoft",
52 + "url": "https://www.ubisoft.com",
53 + "careers_url": "https://jobs.smartrecruiters.com/company/Ubisoft2",
54 + "sectors": ["Jeux vidéo", "TI", "Création"],
55 + "connector": "smartrecruiters",
56 + "status": "actif",
57 + "region": "Montréal / Québec / Sherbrooke"
58 + }
59 + ]
60 +}
added docs/audit-louka.md +354 −0
@@ -0,0 +1,354 @@
1 +<!--
2 +=============================================================================
3 +Job·Ka — Groupe KA
4 +Auteur : Simon-Pierre Boucher
5 +Contact : contact@spboucher.ai
6 +Fichier : docs/audit-louka.md
7 +Rôle : Audit de l'architecture Lou·Ka et plan d'adaptation pour Job·Ka
8 +Créé : 2026-08-17 Modifié : 2026-08-17
9 +=============================================================================
10 +-->
11 +
12 +# Audit Lou·Ka → Plan d'adaptation Job·Ka
13 +
14 +> Livrable de la règle nº 2 du `CLAUDE.md`. Dépôt étudié : `~/Desktop/lou-ka`
15 +> (212 connecteurs, ~18 500 annonces actives, SQLite + FastAPI + React/Vite,
16 +> production PM2 + ngrok). **Aucune implémentation n'a été faite : ce document
17 +> attend la validation de Simon-Pierre avant tout code.**
18 +
19 +---
20 +
21 +## Partie A — Ce que fait Lou·Ka (architecture éprouvée)
22 +
23 +### A.1 Connecteurs : interface, conventions, découverte
24 +
25 +- **Classe de base** `BaseConnector` (`louka/connectors/base.py`), contrat minimal :
26 + - attributs de classe : `source_id: str`, `request_delay: float = 0.6`,
27 + `timeout: int = 30`, `use_detail_cache: bool = True` ;
28 + - une **seule méthode abstraite par convention** (pas d'`abc`) :
29 + `fetch(self) -> list[Listing]` ;
30 + - helpers fournis : `get()`/`post()` (session `requests` throttlée +
31 + `raise_for_status`), `get_rendered()` (Firecrawl), `scrapfly()`/`get_scrapfly()`
32 + (rendu JS + anti-bot), `detail()` (cache BD des pages détail).
33 +- **Conventions** : un fichier = un connecteur = un `source_id` ; fichier
34 + `snake_case.py` → classe `CamelCaseConnector` → `source_id` = nom du fichier.
35 + Bannière commentée en tête de fichier (site, plateforme, particularités,
36 + robots.txt).
37 +- **Découverte automatique** (`connectors/__init__.py`) : `pkgutil.iter_modules`
38 + + `importlib` remplissent le dict `CONNECTORS` avec toute sous-classe de
39 + `BaseConnector` ayant un `source_id`. Un module qui plante à l'import est
40 + **ignoré avec un avertissement** — jamais de liste à maintenir à la main.
41 +- **Plateformes partagées** : une classe de plateforme expose des constantes
42 + (`BASE`, `LIST_PATH`, `CITY_DEFAULT`…) et des *crochets* surchargeables
43 + (`_parse_card`, `_fetch_detail`…) ; un client de la plateforme = une
44 + sous-classe de ~15 lignes (ex. `DynamicConnector` → `AgenceSherbrookeConnector`).
45 +- **Registre descriptif parallèle** `data/sources.json`
46 + (`id, name, url, listing_url, sectors, connector, status, region`) — alimente
47 + la page publique « Sources », pas la découverte.
48 +
49 +### A.2 Schéma normalisé et pipeline de normalisation
50 +
51 +- **`@dataclass Listing`** (`louka/schema.py`) — stdlib, pas de Pydantic.
52 + Champs requis : `source`, `external_id`, `url`. Dérivés :
53 + `uid = "{source}:{external_id}"` (clé primaire), `content_hash()` (sha256 du
54 + dict trié → moteur de diff), `finalize()` (normalisation commune, idempotente,
55 + **n'écrase jamais une valeur explicite du connecteur**).
56 +- **`louka/normalize.py`** centralise : `parse_price` (exige `$` adjacent,
57 + bornes de plausibilité, retourne le min d'une fourchette),
58 + `parse_availability_date` (FR + EN → ISO, **jamais de date inventée**),
59 + `parse_area_sqft`, `normalize_unit_type`, `extract_details` (~20 règles à
60 + motifs positifs/négatifs, **dict sparse : clé absente = inconnu, jamais de
61 + `false` deviné**), `clean_address`, `strip_accents`.
62 +- Nettoyage HTML à trois niveaux : `BeautifulSoup.get_text()` dans le
63 + connecteur, helpers partagés `_detailutil.py` (JSON-LD, aplatissement,
64 + coordonnées Google Maps), et `textmine.py` (analyse regex FR déterministe de
65 + la description → digest structuré ; aucun LLM).
66 +- Garde-fou géographique dans `finalize()` : coordonnées hors Québec rejetées.
67 +
68 +### A.3 Erreurs, retries, timeouts, throttling
69 +
70 +- **Pas de retry HTTP générique** (ni tenacity ni urllib3.Retry) : la
71 + robustesse vient de `try/except` par source + par annonce, et de la reprise
72 + au cycle suivant. Philosophie : une panne coûte une heure, pas des données.
73 +- **Throttling par source** : `request_delay` (0.6 s défaut, jusqu'à 20 s quand
74 + le robots.txt exige un `Crawl-delay`), appliqué dans `get()/post()`.
75 +- **Budgets de requêtes** plutôt que rate limit externe : `max_pages`,
76 + `max_details`, plafonds surchargés par variables d'env
77 + (`LOUKA_<PORTAIL>_<LIMITE>`), exceptions sentinelles locales.
78 +- **User-Agent identifiable** unique, déclaré une fois dans `base.py`
79 + (`LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)`).
80 +- **Timeouts** : 30 s défaut, surchargables par classe ; Firecrawl 90-150 s,
81 + Scrapfly 180 s.
82 +- Scrapfly / Firecrawl = seule stratégie anti-bot et rendu JS ; aucun proxy
83 + maison ; tout est `requests` synchrone.
84 +
85 +### A.4 Scheduler / orchestration
86 +
87 +- **Boucle maison**, pas d'APScheduler ni cron : `run.py watch 60` →
88 + `ingest.watch(3600)` = `while True: run(); sleep(3600)`.
89 +- Cycle strictement séquentiel : **ingestion → géocodage en lot → POI →
90 + quartier → déduplication** (la dédup vient après le géocodage car les
91 + coordonnées alimentent le blocage). Étapes 2-5 chacune dans un `try/except`
92 + non bloquant.
93 +- Aucun parallélisme entre connecteurs (choix délibéré de politesse) ;
94 + `POST /api/sync` déclenche un cycle sous `threading.Lock`.
95 +- CLI unique `run.py` : `sync | watch | serve | geocode | record …`,
96 + dispatch manuel sur `sys.argv`, chargement `.env` maison, imports paresseux.
97 +
98 +### A.5 Déduplication inter-sources
99 +
100 +`louka/dedup.py` — **clé exacte + union-find, aucun fuzzy** :
101 +1. Blocage par clé d'adresse normalisée (`civique|mots-de-rue|ville`, sans
102 + accents ni types de voie) — la proximité GPS a été **abandonnée** (fusionnait
103 + des immeubles voisins).
104 +2. Confirmation d'une paire : même clé obligatoire + aucun signal contradictoire
105 + (unité, type, prix ±4 %) + **au moins un second signal concordant**.
106 +3. Union-find avec contrainte : jamais deux annonces de la même source dans une
107 + composante ; jamais de dédup intra-source.
108 +4. Canonique choisi par **autorité de la source** (site direct < portail <
109 + petites annonces) puis richesse du contenu. Les doublons sont **masqués**
110 + (`dup_of`), jamais supprimés ; recalcul intégral idempotent à chaque cycle.
111 +
112 +### A.6 Expiration des annonces
113 +
114 +- Marquage **vu/non-vu par cycle** : `last_seen`, `miss_count` ; non vue 2 cycles
115 + consécutifs (`MISS_GRACE = 2`) → `active = 0`. **Jamais de `DELETE`.**
116 +- **Garde-fou anti-dérive** (`_drift_alert`) : si une source retourne ≤ 25 % de
117 + sa médiane des 5 dernières syncs OK, ou si le taux de prix nuls explose →
118 + alerte consignée et **retraits suspendus** (un connecteur cassé ne vide pas
119 + la base).
120 +- SEO : annonce retirée → HTTP 410 Gone sur sa page.
121 +
122 +### A.7 Stockage et exposition
123 +
124 +- **SQLite unique** (`data/louka.db`, `sqlite3` stdlib, pas d'ORM). Schéma
125 + idempotent exécuté à chaque `connect()` ; **migrations maison** par
126 + `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` (dict `_MIGRATIONS`).
127 +- Tables clés : `listings` (uid PK, colonnes du dataclass + cycle de vie),
128 + `sync_log` (monitoring), `price_log` (historique), `geocode_cache`,
129 + `detail_cache`, `poi_cache`, comptes/favoris.
130 +- Index : source, city, active, `(lat, lng)` pour les bbox de la carte.
131 +- **FastAPI** (`louka/web.py`) : `/api/listings` (filtres + pagination),
132 + `/api/listings.geojson` (bbox), `/api/listings/{uid}` (fiche enrichie),
133 + `/api/facets`, `/api/sources`, `/api/stats[.detailed]`, PDF, `POST /api/sync`.
134 + CORS ouvert + GZip. Frontend React 18 + Vite + TS servi en statique par
135 + FastAPI (catch-all avec vraies 404), SSR léger + sitemaps dans `seo.py`.
136 +- Géocodage : **Nominatim (1,1 s/req)** + repli **Adresses Québec (lot de 200)**,
137 + cache SQLite avec mémorisation des échecs (retente après 30 j), validation
138 + par bounding box, granularité par immeuble. Jamais de coordonnées inventées.
139 +
140 +### A.8 Logging, monitoring, tests
141 +
142 +- Pas de module `logging` : `print("[lou-ka] …")` capté par PM2. La vraie
143 + télémétrie est en base : `sync_log(source, ts, found, added, updated,
144 + removed, ok, message, stats JSON)` → exposée par `/api/stats`
145 + (20 dernières syncs) et `/api/sources` (`last_sync` par source).
146 +- **Fixtures HTTP enregistrées/rejouées** (`louka/fixtures.py`) :
147 + `run.py record <source>` capture les requêtes réelles (adapter monté sur la
148 + session, secrets caviardés) ; `pytest` rejoue **hors ligne** et compare à un
149 + instantané déterministe + invariants (uid uniques, URLs absolues, bornes de
150 + prix/superficie, pas de valeurs inventées). 193 sources couvertes.
151 +- Une **fiche Markdown par connecteur** dans `reports/connectors/<source>.md`
152 + (méthode, couverture des champs en %, fragilités, échantillon).
153 +- Production : 3 processus PM2 — `lou-ka-web` (serve 8095), `lou-ka-sync`
154 + (watch 60), `lou-ka-ngrok` (`ngrok http --url=www.lou-ka.com 8095`).
155 + Philosophie : « on ne pousse que le code — le serveur maintient ses données
156 + lui-même » (`data/*.db`, `dist/`, `.env` jamais versionnés).
157 +
158 +---
159 +
160 +## Partie B — Plan d'adaptation pour Job·Ka
161 +
162 +Principe directeur : **reprendre l'architecture de Lou·Ka telle quelle** —
163 +paquet `jobka/`, mêmes mécanismes (registre auto-découvrant, diff par
164 +`content_hash`, `miss_count` + anti-dérive, fixtures rejouables, SQLite,
165 +FastAPI, `run.py`, PM2 + ngrok) — et n'adapter que ce qui est propre au
166 +domaine de l'emploi.
167 +
168 +### B.1 Ce qui se transpose tel quel (≈ 80 %)
169 +
170 +| Brique Lou·Ka | Job·Ka |
171 +|---|---|
172 +| `BaseConnector` + registre auto-découvrant | Identique (`jobka/connectors/`) |
173 +| `finalize()` + `normalize.py` | Identique dans l'esprit ; parseurs adaptés (salaires, dates limites) |
174 +| Diff `content_hash` + `sync_log` + anti-dérive | Identique — critique pour l'emploi (offres qui disparaissent vite) |
175 +| Expiration `miss_count`/`MISS_GRACE=2` + `active=0` | Identique + prise en compte de `date_limite` dépassée |
176 +| Fixtures record/replay + tests d'invariants | Identique (`run.py record`, pytest hors ligne) |
177 +| SQLite + migrations maison + FastAPI + frontend Vite | Identique |
178 +| Géocodage Nominatim + Adresses Québec + cache | **Réutilisé tel quel** (le lieu de travail se géocode comme une adresse) |
179 +| CLI `run.py` (sync/watch/serve/record) | Identique |
180 +| Déploiement 3 processus PM2 + ngrok | Identique, domaine `www.job-ka.com`, nœud `m3u96a` |
181 +| Fiches `reports/connectors/<source>.md` | Identique |
182 +
183 +### B.2 Schéma `JobPosting` (adaptation du dataclass `Listing`)
184 +
185 +Mêmes conventions (champs requis `source`, `external_id`, `url` ; `uid`,
186 +`content_hash()`, `finalize()`), champs métier du `CLAUDE.md` §4 mappés au
187 +style Lou·Ka (identifiants en anglais) :
188 +
189 +```python
190 +@dataclass
191 +class JobPosting:
192 + source: str # id du connecteur (= employeur ou ATS) [id_source]
193 + external_id: str
194 + url: str # lien direct vers l'offre — zéro boîte noire
195 + employer: str = "" # [employeur]
196 + title: str = ""
197 + description: str = "" # HTML nettoyé
198 + address: str = "" # lieu de travail si dispo
199 + city: str = ""
200 + region: str = ""
201 + postal_code: str = ""
202 + work_mode: str | None = None # presentiel | hybride | teletravail
203 + employment_type: str | None = None # temps_plein | partiel | contractuel | stage | saisonnier
204 + salary_min: float | None = None # nombres, jamais des strings
205 + salary_max: float | None = None
206 + salary_unit: str | None = None # "hour" | "year" (+ conversions dérivées)
207 + benefits: list[str] = field(default_factory=list)
208 + requirements: dict = field(default_factory=dict) # scolarité, expérience, langues
209 + date_posted: str | None = None # ISO 8601
210 + date_deadline: str | None = None # ISO 8601
211 + category: str = "" # taxonomie interne (TI, santé, construction…)
212 + ats: str = "" # workday | lever | greenhouse | custom…
213 + details: dict = field(default_factory=dict) # sparse, comme Lou·Ka
214 + lat: float | None = None
215 + lng: float | None = None
216 +```
217 +
218 +Normalisation spécifique emploi (équivalents de `parse_price`/`parse_area_sqft`) :
219 +- `parse_salary(raw)` → `(min, max, unit)` ; bornes de plausibilité
220 + (~15–200 $/h, ~20 000–500 000 $/an) ; conversion $/h ↔ $/an
221 + (base 40 h × 52 sem.) stockée en champs dérivés — la **transparence salariale**
222 + est le différenciateur clé, jamais de salaire inventé ;
223 +- `parse_work_mode`, `parse_employment_type` : tables de mots-clés FR/EN
224 + (comme `_UNIT_WORDS`) ;
225 +- `parse_date` : réutilise `parse_availability_date` (FR + EN → ISO) ;
226 +- `categorize(title, description)` : taxonomie par mots-clés déterministes
227 + (approche `textmine.py`, pas de LLM au départ).
228 +
229 +### B.3 Connecteurs : l'avantage ATS
230 +
231 +Différence favorable vs l'immobilier : une grande part des employeurs québécois
232 +passe par un **ATS mutualisé** avec API JSON publique et stable. Une classe de
233 +plateforme par ATS (pattern `DynamicConnector`/`LaCourConnector`) donne des
234 +connecteurs-clients de ~10 lignes :
235 +
236 +| Classe de plateforme | Méthode | Coût par employeur |
237 +|---|---|---|
238 +| `WorkdayConnector` | API JSON `…/wday/cxs/<tenant>/<site>/jobs` (POST paginé) | tenant + site |
239 +| `LeverConnector` | `api.lever.co/v0/postings/<org>?mode=json` | slug org |
240 +| `GreenhouseConnector` | `boards-api.greenhouse.io/v1/boards/<org>/jobs?content=true` | slug org |
241 +| `SmartRecruitersConnector` | `api.smartrecruiters.com/v1/companies/<org>/postings` | slug org |
242 +| `BambooHRConnector` | `<org>.bamboohr.com/careers/list` (JSON) | sous-domaine |
243 +| `JazzHRConnector`, `RecruiteeConnector`, `UKGConnector`… | à documenter au fil des sources | — |
244 +| Connecteurs custom (HTML) | `BeautifulSoup`, ou **Scrapfly** si rendu JS / anti-bot | 1 module dédié |
245 +
246 +- **Scrapfly** : méthode `scrapfly()` reprise de `base.py` telle quelle ;
247 + clé déjà en place dans `.env` (`SCRAPFLY_API_KEY`). Usage réservé aux pages
248 + carrières à rendu JS ou protégées (rôle qu'avait `fb_marketplace` dans
249 + Lou·Ka) — les ATS JSON n'en ont pas besoin.
250 +- User-Agent : `JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)`
251 + (CLAUDE.md §5), déclaré une seule fois dans `base.py`.
252 +- Throttling par domaine (`request_delay`), budgets `max_pages`/`max_details`,
253 + plafonds par env `JOBKA_<SOURCE>_<LIMITE>` : repris à l'identique.
254 +
255 +### B.4 Déduplication adaptée à l'emploi
256 +
257 +Le blocage par adresse civique ne se transpose pas (une offre n'a pas toujours
258 +d'adresse précise). Même squelette (blocage + confirmation + union-find +
259 +autorité), clés adaptées :
260 +- **Blocage** : `titre normalisé (sans accents, stopwords RH retirés) | employeur normalisé | ville` ;
261 +- **Confirmation** : refus si signaux contradictoires (type d'emploi différent,
262 + salaires connus s'écartant de > 4 %) + exigence d'un second signal concordant
263 + (même date de publication ±7 j, même salaire, même code postal) ;
264 +- **Autorité** : page carrière de l'employeur (0) < ATS mutualisé (10) —
265 + pertinent le jour où des agrégats s'ajoutent ; masquage `dup_of`, jamais de
266 + suppression ; contrainte « jamais deux offres de la même source dans une
267 + composante » conservée.
268 +
269 +### B.5 Expiration spécifique emploi
270 +
271 +- `miss_count` + `MISS_GRACE = 2` + anti-dérive : repris tels quels ;
272 +- règle additionnelle : `date_deadline` dépassée → `active = 0` même si l'offre
273 + est encore en ligne (offres zombies fréquentes sur les pages carrières) ;
274 +- offre retirée → HTTP 410 sur sa page publique (pattern `seo.py`).
275 +
276 +### B.6 Structure de dépôt cible
277 +
278 +```
279 +job-ka/
280 +├── run.py # sync | watch | serve | geocode | record
281 +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4
282 +├── jobka/
283 +│ ├── schema.py # JobPosting (dataclass) + finalize()
284 +│ ├── normalize.py # salaires, dates, mode/type d'emploi, catégories
285 +│ ├── db.py # SQLite, sync_log, anti-dérive, migrations
286 +│ ├── ingest.py # run() / watch()
287 +│ ├── dedup.py # blocage titre|employeur|ville + union-find
288 +│ ├── geocode.py # Nominatim + Adresses Québec + cache (repris)
289 +│ ├── web.py # FastAPI : /api/jobs, /api/facets, /api/sources, /api/stats
290 +│ ├── seo.py # SSR léger + sitemaps + 410
291 +│ ├── fixtures.py # record/replay hors ligne
292 +│ └── connectors/
293 +│ ├── base.py # BaseConnector (JobKaBot UA, scrapfly(), detail())
294 +│ ├── _atsutil.py # helpers partagés (équiv. _detailutil)
295 +│ ├── workday.py, lever.py, greenhouse.py, … # classes de plateforme
296 +│ └── <employeur>.py # 1 fichier = 1 source
297 +├── data/sources.json # registre descriptif des employeurs
298 +├── frontend/ # React + Vite + TS, servi par FastAPI
299 +├── tests/ # test_connectors (fixtures), test_normalize
300 +├── reports/connectors/ # 1 fiche .md par connecteur
301 +└── docs/
302 +```
303 +
304 +### B.7 Déploiement (CLAUDE.md §6)
305 +
306 +3 processus PM2 sur **m3u96a**, calqués sur Lou·Ka :
307 +
308 +```
309 +job-ka-web .venv/bin/python run.py serve <port> # API + SSR + frontend
310 +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire, jour et nuit
311 +job-ka-ngrok ngrok http --url=www.job-ka.com <port>
312 +```
313 +
314 +Vérifications post-déploiement : `https://www.job-ka.com` répond,
315 +`/api/sources` montre des `last_sync` récents, `sync_log` sans alerte.
316 +Jamais d'admin/BD exposé par le tunnel. Enregistrement dans
317 +`~/Desktop/cluster-skill/cluster-deployments.json`. À noter : m3u96a est
318 +normalement réservé au staging/calcul — dérogation explicite du CLAUDE.md §6.
319 +
320 +### B.8 Écarts assumés vs Lou·Ka (améliorations légères)
321 +
322 +1. **Endpoint `/health`** ajouté (Lou·Ka n'en a pas ; le CLAUDE.md de Job·Ka
323 + exige un healthcheck au déploiement).
324 +2. `dedup.run()` aussi appelé en fin de `run.py sync` (bogue mineur relevé chez
325 + Lou·Ka : la dédup ne tourne que dans `watch`).
326 +3. Réinitialiser `geocode_failed` quand l'adresse d'une offre change (second
327 + bogue mineur relevé chez Lou·Ka).
328 +4. En-tête d'auteur Job·Ka (règle nº 1) sur chaque fichier + hook pre-commit de
329 + vérification (section 17 du CLAUDE.md).
330 +
331 +### B.9 Ordre d'implémentation proposé
332 +
333 +1. **Socle** : `schema.py`, `normalize.py` (+ tests), `db.py`, `base.py`,
334 + registre auto-découvrant, `run.py`, `fixtures.py`.
335 +2. **3 premiers connecteurs pilotes** : 1 Workday + 1 Lever ou Greenhouse
336 + (employeurs québécois à confirmer) + 1 custom HTML — pour valider le socle
337 + sur les trois familles.
338 +3. `ingest.py` + expiration + anti-dérive + `dedup.py`.
339 +4. `web.py` + frontend minimal (liste, fiche, filtres, sources, stats).
340 +5. Vague de connecteurs (choix des employeurs à valider avec Simon-Pierre).
341 +6. `seo.py`, géocodage, déploiement m3u96a + ngrok.
342 +
343 +---
344 +
345 +## Questions ouvertes pour validation
346 +
347 +1. **Liste des premiers employeurs/ATS cibles** — as-tu déjà une liste
348 + (ex. Desjardins, Hydro-Québec, CGI, gouvernement du Québec via Workday…) ou
349 + je propose une première vague de ~20 employeurs québécois par ATS ?
350 +2. **Port du service** sur m3u96a (Lou·Ka occupe 8095 ailleurs ; proposer 8096 ?).
351 +3. Le géocodage des lieux de travail est-il prioritaire dès la v1, ou la ville
352 + textuelle suffit-elle au lancement (carte en v2) ?
353 +
354 +**Statut : en attente de validation avant toute implémentation (règle nº 2).**
added docs/deploiement.md +84 −0
@@ -0,0 +1,84 @@
1 +<!--
2 +=============================================================================
3 +Job·Ka — Groupe KA
4 +Auteur : Simon-Pierre Boucher
5 +Contact : contact@spboucher.ai
6 +Fichier : docs/deploiement.md
7 +Rôle : Procédure de déploiement sur m3u96a + ngrok www.job-ka.com
8 +Créé : 2026-08-17 Modifié : 2026-08-17
9 +=============================================================================
10 +-->
11 +
12 +# Déploiement Job·Ka — nœud m3u96a
13 +
14 +## Cible
15 +
16 +- **Nœud** : `M3U96a.maclustr.io` (Mac Studio, 32 cœurs, 96 Go) — imposé par le
17 + CLAUDE.md du projet (§6). Dérogation assumée à la règle « M3U96a = staging » :
18 + demande explicite du projet, comme `vrai-prix` et `sorti-ka` déjà sur ce nœud.
19 +- **Répertoire** : `~/apps/job-ka` (convention des projets Ka remote-first)
20 +- **Port interne** : `8096` (8095 = lou-ka ; vérifier les conflits avec
21 + `pick-node.py`/`lsof` avant de démarrer)
22 +- **Exposition** : ngrok, domaine réservé `www.job-ka.com`
23 +- **Superviseur** : PM2 (3 processus)
24 +
25 +## Processus PM2
26 +
27 +```
28 +job-ka-web .venv/bin/python run.py serve 8096 # API + frontend
29 +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire
30 +job-ka-ngrok ngrok http --url=www.job-ka.com 8096 # tunnel supervisé
31 +```
32 +
33 +## Procédure
34 +
35 +```bash
36 +# 1. Pousser le code (jamais les données : data/*.db, dist/, .env restent locaux au nœud)
37 +ssh M3U96a 'mkdir -p ~/apps'
38 +rsync -a --delete \
39 + --exclude .venv --exclude node_modules --exclude dist \
40 + --exclude 'data/*.db' --exclude .env --exclude .git \
41 + ~/Desktop/job-ka/ M3U96a:~/apps/job-ka/
42 +
43 +# 2. Sur le nœud : runtime + build + secrets
44 +ssh M3U96a 'cd ~/apps/job-ka \
45 + && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt \
46 + && cd frontend && npm install && npm run build'
47 +# .env : créer sur le nœud (SCRAPFLY_API_KEY) — jamais commité ni rsyncé
48 +
49 +# 3. Amorçage des données
50 +ssh M3U96a 'cd ~/apps/job-ka && .venv/bin/python run.py sync && .venv/bin/python run.py geocode'
51 +
52 +# 4. PM2
53 +ssh M3U96a 'cd ~/apps/job-ka \
54 + && pm2 start ".venv/bin/python run.py serve 8096" --name job-ka-web \
55 + && pm2 start ".venv/bin/python run.py watch 60" --name job-ka-sync \
56 + && pm2 start "ngrok http --url=www.job-ka.com 8096" --name job-ka-ngrok \
57 + && pm2 save'
58 +```
59 +
60 +## Vérifications post-déploiement
61 +
62 +1. `curl https://www.job-ka.com/health` → `{"status":"ok", ...}` avec
63 + `last_sync_age_s` < 7200 ;
64 +2. `https://www.job-ka.com` répond (frontend) ;
65 +3. `https://www.job-ka.com/api/sources` → `last_sync` récents sur chaque source ;
66 +4. `pm2 ls` : les 3 processus `online` ;
67 +5. Mettre à jour `~/Desktop/cluster-skill/cluster-deployments.json` (registre
68 + cross-session : app, node, port, domain, process_manager, dir, start, deployed).
69 +
70 +## Règles
71 +
72 +- Jamais d'interface d'admin ni de BD exposée par le tunnel (seuls `/`,
73 + `/api/*`, `/health` existent ; `POST /api/sync` est le seul point mutateur —
74 + il ne fait que déclencher une resynchronisation).
75 +- Les mises à jour de code passent par rsync (ou git sur le nœud, protocole
76 + remote-first des projets Ka) puis `pm2 restart job-ka-web job-ka-sync`.
77 +
78 +## Variations par rapport à Lou·Ka
79 +
80 +- Carte : **MapLibre GL + fond Carto libre** au lieu de Mapbox/ka-maps — aucun
81 + jeton requis, aucun paquet lié à builder à côté du dépôt.
82 +- `dedup.run()` s'exécute aussi après `run.py sync` (pas seulement dans
83 + `watch`) ; `geocode_failed` est remis à 0 quand l'adresse d'une offre change.
84 +- Endpoint `/health` dédié (Lou·Ka n'en a pas).
added frontend/index.html +27 −0
@@ -0,0 +1,27 @@
1 +<!doctype html>
2 +<!--
3 +=============================================================================
4 +Job·Ka — Groupe KA
5 +Auteur : Simon-Pierre Boucher
6 +Contact : contact@spboucher.ai
7 +Fichier : frontend/index.html
8 +Rôle : Coquille HTML de l'application (SPA React)
9 +Créé : 2026-08-17 Modifié : 2026-08-17
10 +=============================================================================
11 +-->
12 +<html lang="fr">
13 + <head>
14 + <meta charset="UTF-8" />
15 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16 + <meta name="description" content="Job·Ka — toutes les offres d'emploi des employeurs québécois, directement à la source. Salaires transparents, zéro boîte noire." />
17 + <title>Job·Ka — L'emploi au Québec, à la source</title>
18 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141814'/%3E%3Ctext x='32' y='42' font-family='Inter,sans-serif' font-size='28' font-weight='800' fill='%23d9f26b' text-anchor='middle'%3EJ·K%3C/text%3E%3C/svg%3E" />
19 + <link rel="preconnect" href="https://fonts.googleapis.com" />
20 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
21 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
22 + </head>
23 + <body>
24 + <div id="root"></div>
25 + <script type="module" src="/src/main.tsx"></script>
26 + </body>
27 +</html>
added frontend/package-lock.json +2212 −0
Large diff (2,212 lines) is not loaded by default to keep the page fast.Load diff
added frontend/package.json +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/App.tsx +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/api.ts +171 −0
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/JobCard.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/main.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/pages/Home.tsx +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/pages/Job.tsx +71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/pages/MapPage.tsx +98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/pages/Sources.tsx +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/pages/Stats.tsx +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/styles.css +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added frontend/tsconfig.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added frontend/vite.config.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added jobka/__init__.py +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/__init__.py +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/base.py +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/cae.py +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/desjardins.py +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/greenhouse.py +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/lever.py +85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/mistplay.py +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/poka.py +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/smartrecruiters.py +124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/ubisoft.py +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added jobka/connectors/workday.py +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added jobka/db.py +369 −0
Large diff (369 lines) is not loaded by default to keep the page fast.Load diff
added jobka/dedup.py +202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
added jobka/fixtures.py +194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added jobka/geocode.py +344 −0
Large diff (344 lines) is not loaded by default to keep the page fast.Load diff
added jobka/ingest.py +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added jobka/normalize.py +480 −0
Large diff (480 lines) is not loaded by default to keep the page fast.Load diff
added jobka/schema.py +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added jobka/web.py +338 −0
Large diff (338 lines) is not loaded by default to keep the page fast.Load diff
added requirements.txt +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added run.py +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added scripts/check_headers.py +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added tests/conftest.py +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/mistplay/b53675f2801b6632ebba.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/mistplay/expected.json +47 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/mistplay/index.json +9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/poka/0b773b59b34e454db34d.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/poka/expected.json +75 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/poka/index.json +9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_connectors.py +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_normalize.py +178 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme