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